Compare commits
No commits in common. "b9e13b5bca23a13867abdf8a81dd9a1f8cf46653" and "0b408a801441377192ba3602b9696a7713aafb8b" have entirely different histories.
b9e13b5bca
...
0b408a8014
10 changed files with 131 additions and 495 deletions
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useLicenseContext } from "../../contexts/LicenseContext";
|
import { useLicense } from "../../hooks/useLicense";
|
||||||
import {
|
import {
|
||||||
MachineInfo,
|
MachineInfo,
|
||||||
ActivationStatus,
|
ActivationStatus,
|
||||||
|
|
@ -16,8 +16,7 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
||||||
|
|
||||||
export default function LicenseCard() {
|
export default function LicenseCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
const { state, submitKey } = useLicense();
|
||||||
useLicenseContext();
|
|
||||||
const [keyInput, setKeyInput] = useState("");
|
const [keyInput, setKeyInput] = useState("");
|
||||||
const [showInput, setShowInput] = useState(false);
|
const [showInput, setShowInput] = useState(false);
|
||||||
const [showMachines, setShowMachines] = useState(false);
|
const [showMachines, setShowMachines] = useState(false);
|
||||||
|
|
@ -27,7 +26,7 @@ export default function LicenseCard() {
|
||||||
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
||||||
const [machineError, setMachineError] = useState<string | null>(null);
|
const [machineError, setMachineError] = useState<string | null>(null);
|
||||||
|
|
||||||
const hasLicense = edition !== "free";
|
const hasLicense = state.edition !== "free";
|
||||||
|
|
||||||
const loadActivation = useCallback(async () => {
|
const loadActivation = useCallback(async () => {
|
||||||
if (!hasLicense) return;
|
if (!hasLicense) return;
|
||||||
|
|
@ -109,7 +108,7 @@ export default function LicenseCard() {
|
||||||
return new Date(timestamp * 1000).toLocaleDateString();
|
return new Date(timestamp * 1000).toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const editionLabel = t(`license.editions.${edition}`);
|
const editionLabel = t(`license.editions.${state.edition}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
|
@ -125,25 +124,25 @@ export default function LicenseCard() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-medium">
|
<p className="text-base font-medium">
|
||||||
{editionLabel}
|
{editionLabel}
|
||||||
{edition !== "free" && (
|
{state.edition !== "free" && (
|
||||||
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{info && info.expires_at > 0 && (
|
{state.info && state.info.expires_at > 0 && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{t("license.expiresAt")}
|
{t("license.expiresAt")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{formatExpiry(info.expires_at)}</p>
|
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{licenseStatus === "error" && error && (
|
{state.status === "error" && state.error && (
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
<p>{error}</p>
|
<p>{state.error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -156,7 +155,7 @@ export default function LicenseCard() {
|
||||||
>
|
>
|
||||||
{t("license.enterKey")}
|
{t("license.enterKey")}
|
||||||
</button>
|
</button>
|
||||||
{edition === "free" && (
|
{state.edition === "free" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePurchase}
|
onClick={handlePurchase}
|
||||||
|
|
@ -179,19 +178,13 @@ export default function LicenseCard() {
|
||||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{validationError && (
|
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
||||||
<p>{validationError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={validating || !keyInput.trim()}
|
disabled={state.status === "validating" || !keyInput.trim()}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{validating && <Loader2 size={14} className="animate-spin" />}
|
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />}
|
||||||
{t("license.activate")}
|
{t("license.activate")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { licenseReducer, initialLicenseState } from "./LicenseContext";
|
|
||||||
import type { Edition, LicenseInfo } from "../services/licenseService";
|
|
||||||
|
|
||||||
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
|
|
||||||
edition,
|
|
||||||
email: "max@example.com",
|
|
||||||
features: [],
|
|
||||||
machine_limit: 3,
|
|
||||||
issued_at: 1,
|
|
||||||
expires_at: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
|
|
||||||
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
|
|
||||||
// load lifecycle: it would arm the auto refresh, which would wipe the very
|
|
||||||
// message the user is reading (~1s after submitting an invalid key).
|
|
||||||
|
|
||||||
const premiumReady = licenseReducer(
|
|
||||||
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
|
||||||
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("licenseReducer — load lifecycle (CWE-703)", () => {
|
|
||||||
it("LOAD_ERROR preserves the last-known edition and info", () => {
|
|
||||||
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
|
|
||||||
expect(errored.status).toBe("error");
|
|
||||||
expect(errored.error).toBe("ipc down");
|
|
||||||
expect(errored.edition).toBe("premium");
|
|
||||||
expect(errored.info).toEqual(makeInfo("premium"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("LOAD_START clears the load error but not a pending validation error", () => {
|
|
||||||
const rejected = licenseReducer(
|
|
||||||
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
|
|
||||||
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
|
||||||
);
|
|
||||||
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
|
|
||||||
expect(refreshed.status).toBe("loading");
|
|
||||||
expect(refreshed.error).toBeNull();
|
|
||||||
expect(refreshed.validationError).toBe("invalid key");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
|
|
||||||
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
|
|
||||||
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
|
|
||||||
expect(validating.status).toBe("ready");
|
|
||||||
expect(validating.validating).toBe(true);
|
|
||||||
|
|
||||||
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
|
|
||||||
expect(rejected.status).toBe("ready");
|
|
||||||
expect(rejected.validating).toBe(false);
|
|
||||||
expect(rejected.validationError).toBe("invalid key");
|
|
||||||
expect(rejected.edition).toBe("premium");
|
|
||||||
expect(rejected.info).toEqual(makeInfo("premium"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
|
|
||||||
const bootError = licenseReducer(
|
|
||||||
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
|
||||||
{ type: "LOAD_ERROR", error: "boot fail" },
|
|
||||||
);
|
|
||||||
const rejected = licenseReducer(
|
|
||||||
licenseReducer(bootError, { type: "VALIDATE_START" }),
|
|
||||||
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
|
||||||
);
|
|
||||||
expect(rejected.status).toBe("error");
|
|
||||||
expect(rejected.error).toBe("boot fail");
|
|
||||||
expect(rejected.validationError).toBe("invalid key");
|
|
||||||
expect(rejected.edition).toBe("free");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("VALIDATE_START clears the previous validation error", () => {
|
|
||||||
const rejected = licenseReducer(
|
|
||||||
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
|
|
||||||
{ type: "VALIDATE_ERROR", error: "invalid key" },
|
|
||||||
);
|
|
||||||
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
|
|
||||||
expect(resubmit.validating).toBe(true);
|
|
||||||
expect(resubmit.validationError).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
|
|
||||||
const bootError = licenseReducer(
|
|
||||||
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
|
||||||
{ type: "LOAD_ERROR", error: "boot fail" },
|
|
||||||
);
|
|
||||||
const done = licenseReducer(
|
|
||||||
licenseReducer(bootError, { type: "VALIDATE_START" }),
|
|
||||||
{ type: "VALIDATE_DONE", info: makeInfo("base") },
|
|
||||||
);
|
|
||||||
expect(done).toEqual({
|
|
||||||
status: "ready",
|
|
||||||
edition: "base",
|
|
||||||
info: makeInfo("base"),
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
useRef,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
getEdition,
|
|
||||||
readLicense,
|
|
||||||
storeLicense,
|
|
||||||
type Edition,
|
|
||||||
type LicenseInfo,
|
|
||||||
} from "../services/licenseService";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Machine-level license context — loaded ONCE at boot, above ProfileProvider.
|
|
||||||
*
|
|
||||||
* Modeled on ProfileContext (createContext<T | null>, useReducer, consumer hook
|
|
||||||
* that throws). Mounting above the profile layer means it survives the
|
|
||||||
* `BrowserRouter key={refreshKey}` remount that a profile switch triggers — the
|
|
||||||
* license is a property of the machine, not of the active profile.
|
|
||||||
*
|
|
||||||
* Error recovery (CWE-703): the provider is a SPOF. If the boot invoke throws,
|
|
||||||
* we expose a NEUTRAL state (previous edition preserved, `status: "error"`) and
|
|
||||||
* retry with capped exponential backoff. Consumers must render a neutral
|
|
||||||
* placeholder while `status !== "ready"` — never the upsell — so a transient IPC
|
|
||||||
* failure never locks a paying user out.
|
|
||||||
*
|
|
||||||
* Key validation is orthogonal to that load lifecycle: a rejected `submitKey`
|
|
||||||
* leaves the stored license — and therefore `status` — untouched, and surfaces
|
|
||||||
* `validationError` instead. The retry loop keys off `status === "error"`, so
|
|
||||||
* it can never arm on a bad key and auto-clear the message the user is reading,
|
|
||||||
* and `ready` consumers never regress on a typo'd key.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type LicenseStatus = "idle" | "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
interface LicenseState {
|
|
||||||
status: LicenseStatus;
|
|
||||||
edition: Edition;
|
|
||||||
info: LicenseInfo | null;
|
|
||||||
/** Load-lifecycle error — target of the CWE-703 retry loop. */
|
|
||||||
error: string | null;
|
|
||||||
validating: boolean;
|
|
||||||
/** Key-submission error — never retried, cleared on the next submit. */
|
|
||||||
validationError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LicenseAction =
|
|
||||||
| { type: "LOAD_START" }
|
|
||||||
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
|
|
||||||
| { type: "LOAD_ERROR"; error: string }
|
|
||||||
| { type: "VALIDATE_START" }
|
|
||||||
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
|
||||||
| { type: "VALIDATE_ERROR"; error: string };
|
|
||||||
|
|
||||||
/** Exported for unit tests only — not part of the context API. */
|
|
||||||
export const initialLicenseState: LicenseState = {
|
|
||||||
status: "idle",
|
|
||||||
edition: "free",
|
|
||||||
info: null,
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Exported for unit tests only — not part of the context API. */
|
|
||||||
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
|
|
||||||
switch (action.type) {
|
|
||||||
case "LOAD_START":
|
|
||||||
return { ...state, status: "loading", error: null };
|
|
||||||
case "LOAD_DONE":
|
|
||||||
return { ...state, status: "ready", edition: action.edition, info: action.info, error: null };
|
|
||||||
case "LOAD_ERROR":
|
|
||||||
// Preserve the last-known edition: an error must not downgrade a paying
|
|
||||||
// user to the upsell (that is what fail-closed + `ready` guard protect).
|
|
||||||
return { ...state, status: "error", error: action.error };
|
|
||||||
case "VALIDATE_START":
|
|
||||||
return { ...state, validating: true, validationError: null };
|
|
||||||
case "VALIDATE_DONE":
|
|
||||||
return {
|
|
||||||
status: "ready",
|
|
||||||
edition: action.info.edition,
|
|
||||||
info: action.info,
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
};
|
|
||||||
case "VALIDATE_ERROR":
|
|
||||||
// Status untouched: "ready" stays ready (no gating flash on a typo'd
|
|
||||||
// key), and a boot-error retry loop keeps running through the failure.
|
|
||||||
return { ...state, validating: false, validationError: action.error };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubmitKeyResult =
|
|
||||||
| { ok: true; info: LicenseInfo }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
interface LicenseContextValue {
|
|
||||||
status: LicenseStatus;
|
|
||||||
edition: Edition;
|
|
||||||
features: string[];
|
|
||||||
info: LicenseInfo | null;
|
|
||||||
error: string | null;
|
|
||||||
validating: boolean;
|
|
||||||
validationError: string | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LicenseContext = createContext<LicenseContextValue | null>(null);
|
|
||||||
|
|
||||||
// Capped exponential backoff for boot-error retries: 1s, 2s, 4s, ... max 30s.
|
|
||||||
const RETRY_BASE_MS = 1000;
|
|
||||||
const RETRY_MAX_MS = 30_000;
|
|
||||||
|
|
||||||
export function LicenseProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
|
|
||||||
const retryAttempt = useRef(0);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
dispatch({ type: "LOAD_START" });
|
|
||||||
try {
|
|
||||||
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
|
|
||||||
dispatch({ type: "LOAD_DONE", edition, info });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({
|
|
||||||
type: "LOAD_ERROR",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submitKey = useCallback(async (key: string): Promise<SubmitKeyResult> => {
|
|
||||||
dispatch({ type: "VALIDATE_START" });
|
|
||||||
try {
|
|
||||||
const info = await storeLicense(key);
|
|
||||||
dispatch({ type: "VALIDATE_DONE", info });
|
|
||||||
return { ok: true, info };
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
dispatch({ type: "VALIDATE_ERROR", error: message });
|
|
||||||
return { ok: false, error: message };
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load once at boot.
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
// Error recovery: retry with capped exponential backoff (CWE-703). Load
|
|
||||||
// errors only by construction — validation failures never set status.
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.status === "ready") {
|
|
||||||
retryAttempt.current = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.status !== "error") return;
|
|
||||||
const attempt = retryAttempt.current;
|
|
||||||
retryAttempt.current = attempt + 1;
|
|
||||||
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void refresh();
|
|
||||||
}, delay);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [state.status, refresh]);
|
|
||||||
|
|
||||||
const value: LicenseContextValue = {
|
|
||||||
status: state.status,
|
|
||||||
edition: state.edition,
|
|
||||||
features: state.info?.features ?? [],
|
|
||||||
info: state.info,
|
|
||||||
error: state.error,
|
|
||||||
validating: state.validating,
|
|
||||||
validationError: state.validationError,
|
|
||||||
refresh,
|
|
||||||
submitKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLicenseContext(): LicenseContextValue {
|
|
||||||
const ctx = useContext(LicenseContext);
|
|
||||||
if (!ctx) throw new Error("useLicenseContext must be used within LicenseProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { useLicenseContext } from "../contexts/LicenseContext";
|
|
||||||
import { isEntitled, type FeatureKey } from "../shared/entitlements";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous feature-gating hook.
|
|
||||||
*
|
|
||||||
* Returns `{ allowed, ready }` (NOT a bare boolean) so consumers can suppress
|
|
||||||
* the lock/upsell while the license is still loading or errored (`ready` is
|
|
||||||
* false) instead of flashing "locked" to a paying user. `allowed` is fail-closed
|
|
||||||
* in Free and during boot (edition defaults to "free"), so it is safe to read
|
|
||||||
* even before `ready`.
|
|
||||||
*/
|
|
||||||
export function useEntitlement(feature: FeatureKey): { allowed: boolean; ready: boolean } {
|
|
||||||
const { status, edition, features } = useLicenseContext();
|
|
||||||
return {
|
|
||||||
allowed: isEntitled(feature, edition, features),
|
|
||||||
ready: status === "ready",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +1,41 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { useIsPremium } from "./useIsPremium";
|
import { useIsPremium } from "./useIsPremium";
|
||||||
|
|
||||||
// useIsPremium now reads LicenseContext (was: useLicense). Mock the context hook.
|
vi.mock("./useLicense", () => ({
|
||||||
vi.mock("../contexts/LicenseContext", () => ({
|
useLicense: vi.fn(),
|
||||||
useLicenseContext: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { useLicenseContext } from "../contexts/LicenseContext";
|
import { useLicense } from "./useLicense";
|
||||||
|
|
||||||
const mockUseLicenseContext = vi.mocked(useLicenseContext);
|
const mockUseLicense = vi.mocked(useLicense);
|
||||||
|
|
||||||
describe("useIsPremium", () => {
|
describe("useIsPremium", () => {
|
||||||
it('returns true when edition is "premium"', () => {
|
it('returns true when edition is "premium"', () => {
|
||||||
mockUseLicenseContext.mockReturnValue({
|
mockUseLicense.mockReturnValue({
|
||||||
status: "ready",
|
state: { status: "ready", edition: "premium", info: null, error: null },
|
||||||
edition: "premium",
|
|
||||||
features: [],
|
|
||||||
info: null,
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(true);
|
expect(useIsPremium()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when edition is "base"', () => {
|
it('returns false when edition is "base"', () => {
|
||||||
mockUseLicenseContext.mockReturnValue({
|
mockUseLicense.mockReturnValue({
|
||||||
status: "ready",
|
state: { status: "ready", edition: "base", info: null, error: null },
|
||||||
edition: "base",
|
|
||||||
features: [],
|
|
||||||
info: null,
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when edition is "free"', () => {
|
it('returns false when edition is "free"', () => {
|
||||||
mockUseLicenseContext.mockReturnValue({
|
mockUseLicense.mockReturnValue({
|
||||||
status: "ready",
|
state: { status: "ready", edition: "free", info: null, error: null },
|
||||||
edition: "free",
|
|
||||||
features: [],
|
|
||||||
info: null,
|
|
||||||
error: null,
|
|
||||||
validating: false,
|
|
||||||
validationError: null,
|
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { useLicenseContext } from "../contexts/LicenseContext";
|
import { useLicense } from "./useLicense";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the active license edition is "premium".
|
* Returns true if the active license edition is "premium".
|
||||||
* Ergonomic helper only — the server enforces entitlements independently (cf. ADR 0011 §UX).
|
* Ergonomic helper only — the server enforces entitlements independently (cf. ADR 0011 §UX).
|
||||||
* Reads the shared LicenseContext (single boot load) — no per-call invoke.
|
|
||||||
*/
|
*/
|
||||||
export function useIsPremium(): boolean {
|
export function useIsPremium(): boolean {
|
||||||
const { edition } = useLicenseContext();
|
const { state } = useLicense();
|
||||||
return edition === "premium";
|
return state.edition === "premium";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
src/hooks/useLicense.ts
Normal file
98
src/hooks/useLicense.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useCallback, useEffect, useReducer } from "react";
|
||||||
|
import {
|
||||||
|
Edition,
|
||||||
|
LicenseInfo,
|
||||||
|
checkEntitlement as checkEntitlementCmd,
|
||||||
|
getEdition,
|
||||||
|
readLicense,
|
||||||
|
storeLicense,
|
||||||
|
} from "../services/licenseService";
|
||||||
|
|
||||||
|
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
|
||||||
|
|
||||||
|
interface LicenseState {
|
||||||
|
status: LicenseStatus;
|
||||||
|
edition: Edition;
|
||||||
|
info: LicenseInfo | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseAction =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
|
||||||
|
| { type: "VALIDATE_START" }
|
||||||
|
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
||||||
|
| { type: "ERROR"; error: string };
|
||||||
|
|
||||||
|
const initialState: LicenseState = {
|
||||||
|
status: "idle",
|
||||||
|
edition: "free",
|
||||||
|
info: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...state, status: "loading", error: null };
|
||||||
|
case "LOAD_DONE":
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
edition: action.edition,
|
||||||
|
info: action.info,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case "VALIDATE_START":
|
||||||
|
return { ...state, status: "validating", error: null };
|
||||||
|
case "VALIDATE_DONE":
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
edition: action.info.edition,
|
||||||
|
info: action.info,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case "ERROR":
|
||||||
|
return { ...state, status: "error", error: action.error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLicense() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
try {
|
||||||
|
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
|
||||||
|
dispatch({ type: "LOAD_DONE", edition, info });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: "ERROR",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitKey = useCallback(async (key: string) => {
|
||||||
|
dispatch({ type: "VALIDATE_START" });
|
||||||
|
try {
|
||||||
|
const info = await storeLicense(key);
|
||||||
|
dispatch({ type: "VALIDATE_DONE", info });
|
||||||
|
return { ok: true as const, info };
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
dispatch({ type: "ERROR", error: message });
|
||||||
|
return { ok: false as const, error: message };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkEntitlement = useCallback(
|
||||||
|
(feature: string) => checkEntitlementCmd(feature),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { state, refresh, submitKey, checkEntitlement };
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { LicenseProvider } from "./contexts/LicenseContext";
|
|
||||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
import { initLogCapture } from "./services/logService";
|
import { initLogCapture } from "./services/logService";
|
||||||
|
|
@ -12,12 +11,10 @@ initLogCapture();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<LicenseProvider>
|
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ProfileProvider>
|
</ProfileProvider>
|
||||||
</LicenseProvider>
|
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
// Subject lives in src/shared/entitlements.ts; test co-located here per the
|
|
||||||
// issue's file plan (services/).
|
|
||||||
import { isEntitled, ENTITLEMENTS, type FeatureKey } from "../shared/entitlements";
|
|
||||||
import type { Edition } from "./licenseService";
|
|
||||||
|
|
||||||
const EDITIONS: Edition[] = ["free", "base", "premium"];
|
|
||||||
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
|
|
||||||
|
|
||||||
describe("isEntitled — matrix", () => {
|
|
||||||
it("denies every feature in Free (fail-closed)", () => {
|
|
||||||
FEATURES.forEach((f) => {
|
|
||||||
expect(isEntitled(f, "free", [])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Base unlocks budget/adjustments/reports-advanced/multi-profile, not balance", () => {
|
|
||||||
expect(isEntitled("budget", "base", [])).toBe(true);
|
|
||||||
expect(isEntitled("adjustments", "base", [])).toBe(true);
|
|
||||||
expect(isEntitled("reports-advanced", "base", [])).toBe(true);
|
|
||||||
expect(isEntitled("multi-profile", "base", [])).toBe(true);
|
|
||||||
expect(isEntitled("balance", "base", [])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Premium unlocks everything including balance", () => {
|
|
||||||
FEATURES.forEach((f) => {
|
|
||||||
expect(isEntitled(f, "premium", [])).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the ENTITLEMENTS table for every feature × edition", () => {
|
|
||||||
FEATURES.forEach((f) => {
|
|
||||||
EDITIONS.forEach((ed) => {
|
|
||||||
const expected = ed !== "free" && ENTITLEMENTS[f].includes(ed);
|
|
||||||
expect(isEntitled(f, ed, [])).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isEntitled — features[] override", () => {
|
|
||||||
it("grants a higher-tier feature when the signed override lists it", () => {
|
|
||||||
// balance is Premium-only; a Base license carrying balance in features[] gets it.
|
|
||||||
expect(isEntitled("balance", "base", ["balance"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("IGNORES the override when edition is free (CWE-863)", () => {
|
|
||||||
// A copied key downgrades to free (machine-binding) but still carries its
|
|
||||||
// signed features[]. Those must not be re-granted.
|
|
||||||
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
|
|
||||||
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not grant unrelated features via the override", () => {
|
|
||||||
expect(isEntitled("balance", "base", ["budget"])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isEntitled — unknown feature", () => {
|
|
||||||
it("denies an unknown feature key (deny-all)", () => {
|
|
||||||
expect(isEntitled("web-sync" as FeatureKey, "premium", [])).toBe(false);
|
|
||||||
expect(isEntitled("web-sync" as FeatureKey, "base", [])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still honours a signed override for an unknown key when non-free", () => {
|
|
||||||
expect(isEntitled("web-sync" as FeatureKey, "premium", ["web-sync"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an unknown feature denied in Free even with an override", () => {
|
|
||||||
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import type { Edition } from "../services/licenseService";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI entitlements matrix — single front-end source of truth for feature gating.
|
|
||||||
*
|
|
||||||
* Keys are kebab-case because the JWT `features[]` array is a namespace shared
|
|
||||||
* between the Rust override and this TS layer (cf. `auto-update` string on the
|
|
||||||
* Rust side). Enforcement is UI-only (soft-paywall, GPL assumed) — the edition
|
|
||||||
* itself is still resolved by the machine-bound Rust `current_edition()`.
|
|
||||||
*
|
|
||||||
* Modules that stay Free (dashboard, import, transactions, categories,
|
|
||||||
* reports/trends, export, changelog, docs) have NO key here → never gated.
|
|
||||||
*/
|
|
||||||
export type FeatureKey =
|
|
||||||
| "budget"
|
|
||||||
| "adjustments"
|
|
||||||
| "reports-advanced"
|
|
||||||
| "multi-profile"
|
|
||||||
| "balance";
|
|
||||||
|
|
||||||
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
|
|
||||||
budget: ["base", "premium"],
|
|
||||||
adjustments: ["base", "premium"],
|
|
||||||
"reports-advanced": ["base", "premium"],
|
|
||||||
"multi-profile": ["base", "premium"],
|
|
||||||
balance: ["premium"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure entitlement check.
|
|
||||||
*
|
|
||||||
* Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
|
|
||||||
* downgrades `edition` → "free" (machine-binding), but still carries its signed
|
|
||||||
* `features[]`. We must NOT re-grant those features to a downgraded license, so
|
|
||||||
* the Free short-circuit runs BEFORE the `features[]` override.
|
|
||||||
*
|
|
||||||
* `licenseFeatures` is the JWT override namespace. An unknown feature (not in
|
|
||||||
* the matrix — the namespace is shared with Rust and may drift) is deny-all
|
|
||||||
* unless explicitly present in the signed override.
|
|
||||||
*/
|
|
||||||
export function isEntitled(
|
|
||||||
f: FeatureKey,
|
|
||||||
edition: Edition,
|
|
||||||
licenseFeatures: string[],
|
|
||||||
): boolean {
|
|
||||||
if (edition === "free") return false;
|
|
||||||
const tiers = ENTITLEMENTS[f];
|
|
||||||
if (!tiers) return licenseFeatures.includes(f);
|
|
||||||
return tiers.includes(edition) || licenseFeatures.includes(f);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue