mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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".
135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
/**
|
|
* The "linked" dimension of the account-link surface (combined billing),
|
|
* a sibling to TierContext. It answers one question the rest of the portal asks:
|
|
* has this self-hosted org linked its SaaS account, and if so, is it on the free
|
|
* grant or actively subscribed?
|
|
*
|
|
* - `unlinked` — no SaaS account linked. Billable features render a
|
|
* "link to unlock" affordance.
|
|
* - `linked-free` — linked, running on the one-time free grant (500 PDFs).
|
|
* - `linked-subscribed` — linked with a live PAYG subscription.
|
|
*
|
|
* The portal admin establishes the link by signing in to the SaaS Supabase
|
|
* project in-app (auth/saasSupabase.ts + the shared Supabase login) and
|
|
* registering the instance (api/link.ts); the subscribed-vs-free distinction
|
|
* comes from the wallet (api/billing.ts Wallet.status).
|
|
*/
|
|
export type LinkState = "unlinked" | "linked-free" | "linked-subscribed";
|
|
|
|
export interface LinkInfo {
|
|
/** i18n key for the badge label; resolve with `t()` at the call site. */
|
|
labelKey: string;
|
|
/** English fallback for {@link labelKey}, passed as the t() default value. */
|
|
labelDefault: string;
|
|
/** Whether billable features are unlocked (any linked state). */
|
|
unlocked: boolean;
|
|
}
|
|
|
|
export const LINK_INFO: Record<LinkState, LinkInfo> = {
|
|
unlinked: {
|
|
labelKey: "portal.accountLink.state.unlinked",
|
|
labelDefault: "Not linked",
|
|
unlocked: false,
|
|
},
|
|
"linked-free": {
|
|
labelKey: "portal.accountLink.state.free",
|
|
labelDefault: "Editor plan",
|
|
unlocked: true,
|
|
},
|
|
"linked-subscribed": {
|
|
labelKey: "portal.accountLink.state.subscribed",
|
|
labelDefault: "Processor plan",
|
|
unlocked: true,
|
|
},
|
|
};
|
|
|
|
interface LinkContextValue {
|
|
linkState: LinkState;
|
|
setLinkState: (state: LinkState) => void;
|
|
/** True for any linked state — gates "link to unlock" prompts. */
|
|
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);
|
|
|
|
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 {
|
|
linkState,
|
|
setLinkState,
|
|
isLinked: linkState !== "unlinked",
|
|
featuresUnlocked: unlocked,
|
|
statusKnown,
|
|
markStatusKnown,
|
|
};
|
|
}, [linkState, statusKnown, markStatusKnown]);
|
|
return <LinkContext.Provider value={value}>{children}</LinkContext.Provider>;
|
|
}
|
|
|
|
export function useLink(): LinkContextValue {
|
|
const v = useContext(LinkContext);
|
|
if (!v) throw new Error("useLink must be used inside <LinkProvider>");
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* Null rather than throwing where there is no provider. The SaaS portal mounts none on purpose, so
|
|
* absent means "linking does not apply here" — a real answer, not a mistake.
|
|
*/
|
|
export function useLinkOptional(): LinkContextValue | null {
|
|
return useContext(LinkContext);
|
|
}
|
|
|
|
/**
|
|
* Derives the linked state from raw facts: whether the org has linked its SaaS
|
|
* account and whether it carries a live subscription. Keeps the unlinked /
|
|
* linked-free / linked-subscribed mapping in one place.
|
|
*/
|
|
export function deriveLinkState(
|
|
linked: boolean,
|
|
subscribed: boolean,
|
|
): LinkState {
|
|
if (!linked) return "unlinked";
|
|
return subscribed ? "linked-subscribed" : "linked-free";
|
|
}
|
|
|
|
/** Hook returning a setter that maps raw link/subscription facts to LinkState. */
|
|
export function useApplyLinkFacts(): (
|
|
linked: boolean,
|
|
subscribed: boolean,
|
|
) => void {
|
|
const { setLinkState } = useLink();
|
|
return useCallback(
|
|
(linked: boolean, subscribed: boolean) =>
|
|
setLinkState(deriveLinkState(linked, subscribed)),
|
|
[setLinkState],
|
|
);
|
|
}
|