From fd7e053239d8d98146e6acbac009dcffa62adb1e Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Jul 2026 21:05:10 -0400 Subject: [PATCH] feat(gating): license provider + entitlements matrix + useEntitlement (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socle for tier-based feature gating (UI-only soft-paywall). - LicenseContext: machine-level provider (createContext, useReducer, throwing consumer hook), mounted above ProfileProvider in main.tsx so a profile switch (BrowserRouter key remount) does not reload the license. Loads edition + info once; exposes { status, edition, features, info, error, refresh, submitKey }. Boot-error recovery (CWE-703): neutral state + capped exponential-backoff retry, never the upsell. - shared/entitlements.ts: FeatureKey (kebab-case), ENTITLEMENTS matrix, pure isEntitled() fail-closed in Free (CWE-863) — the features[] override is ignored before edition==="free" is checked. - useEntitlement(f): { allowed, ready } (ready = status==="ready"), synchronous. - useIsPremium + its test migrated onto the context (drops the per-call double invoke); LicenseCard consumes the context. useLicense.ts removed (fully replaced, no remaining consumers). - services/entitlements.test.ts: matrix, features[] override, override ignored in Free, unknown-feature deny-all. Resolves #297 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/settings/LicenseCard.tsx | 24 ++-- src/contexts/LicenseContext.tsx | 161 ++++++++++++++++++++++++ src/hooks/useEntitlement.ts | 19 +++ src/hooks/useIsPremium.test.ts | 36 ++++-- src/hooks/useIsPremium.ts | 7 +- src/hooks/useLicense.ts | 98 --------------- src/main.tsx | 13 +- src/services/entitlements.test.ts | 72 +++++++++++ src/shared/entitlements.ts | 50 ++++++++ 9 files changed, 349 insertions(+), 131 deletions(-) create mode 100644 src/contexts/LicenseContext.tsx create mode 100644 src/hooks/useEntitlement.ts delete mode 100644 src/hooks/useLicense.ts create mode 100644 src/services/entitlements.test.ts create mode 100644 src/shared/entitlements.ts diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index c502aa5..7fb968e 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { openUrl } from "@tauri-apps/plugin-opener"; import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react"; -import { useLicense } from "../../hooks/useLicense"; +import { useLicenseContext } from "../../contexts/LicenseContext"; import { MachineInfo, ActivationStatus, @@ -16,7 +16,7 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat"; export default function LicenseCard() { const { t } = useTranslation(); - const { state, submitKey } = useLicense(); + const { status: licenseStatus, edition, info, error, submitKey } = useLicenseContext(); const [keyInput, setKeyInput] = useState(""); const [showInput, setShowInput] = useState(false); const [showMachines, setShowMachines] = useState(false); @@ -26,7 +26,7 @@ export default function LicenseCard() { const [deactivatingId, setDeactivatingId] = useState(null); const [machineError, setMachineError] = useState(null); - const hasLicense = state.edition !== "free"; + const hasLicense = edition !== "free"; const loadActivation = useCallback(async () => { if (!hasLicense) return; @@ -108,7 +108,7 @@ export default function LicenseCard() { return new Date(timestamp * 1000).toLocaleDateString(); }; - const editionLabel = t(`license.editions.${state.edition}`); + const editionLabel = t(`license.editions.${edition}`); return (
@@ -124,25 +124,25 @@ export default function LicenseCard() {

{editionLabel} - {state.edition !== "free" && ( + {edition !== "free" && ( )}

- {state.info && state.info.expires_at > 0 && ( + {info && info.expires_at > 0 && (

{t("license.expiresAt")}

-

{formatExpiry(state.info.expires_at)}

+

{formatExpiry(info.expires_at)}

)} - {state.status === "error" && state.error && ( + {licenseStatus === "error" && error && (
-

{state.error}

+

{error}

)} @@ -155,7 +155,7 @@ export default function LicenseCard() { > {t("license.enterKey")} - {state.edition === "free" && ( + {edition === "free" && (