feat(gating): socle — LicenseProvider + matrice d'entitlements + useEntitlement #303
4 changed files with 164 additions and 18 deletions
|
|
@ -16,7 +16,8 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
|||
|
||||
export default function LicenseCard() {
|
||||
const { t } = useTranslation();
|
||||
const { status: licenseStatus, edition, info, error, submitKey } = useLicenseContext();
|
||||
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
||||
useLicenseContext();
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [showMachines, setShowMachines] = useState(false);
|
||||
|
|
@ -178,13 +179,19 @@ 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)]"
|
||||
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">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={licenseStatus === "validating" || !keyInput.trim()}
|
||||
disabled={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"
|
||||
>
|
||||
{licenseStatus === "validating" && <Loader2 size={14} className="animate-spin" />}
|
||||
{validating && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("license.activate")}
|
||||
</button>
|
||||
<button
|
||||
|
|
|
|||
102
src/contexts/LicenseContext.test.ts
Normal file
102
src/contexts/LicenseContext.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -28,45 +28,71 @@ import {
|
|||
* 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" | "validating" | "error";
|
||||
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: "ERROR"; error: string };
|
||||
| { type: "VALIDATE_ERROR"; error: string };
|
||||
|
||||
const initialState: LicenseState = {
|
||||
/** 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,
|
||||
};
|
||||
|
||||
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
|
||||
/** 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 { 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: "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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +106,8 @@ interface LicenseContextValue {
|
|||
features: string[];
|
||||
info: LicenseInfo | null;
|
||||
error: string | null;
|
||||
validating: boolean;
|
||||
validationError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
||||
}
|
||||
|
|
@ -91,7 +119,7 @@ const RETRY_BASE_MS = 1000;
|
|||
const RETRY_MAX_MS = 30_000;
|
||||
|
||||
export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
|
||||
const retryAttempt = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
|
@ -101,7 +129,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
dispatch({ type: "LOAD_DONE", edition, info });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "ERROR",
|
||||
type: "LOAD_ERROR",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
|
@ -115,7 +143,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
return { ok: true, info };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
dispatch({ type: "ERROR", error: message });
|
||||
dispatch({ type: "VALIDATE_ERROR", error: message });
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -125,9 +153,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Error recovery: retry with capped exponential backoff (CWE-703).
|
||||
// Error recovery: retry with capped exponential backoff (CWE-703). Load
|
||||
// errors only by construction — validation failures never set status.
|
||||
useEffect(() => {
|
||||
if (state.status === "ready" || state.status === "validating") {
|
||||
if (state.status === "ready") {
|
||||
retryAttempt.current = 0;
|
||||
return;
|
||||
}
|
||||
|
|
@ -147,6 +176,8 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
features: state.info?.features ?? [],
|
||||
info: state.info,
|
||||
error: state.error,
|
||||
validating: state.validating,
|
||||
validationError: state.validationError,
|
||||
refresh,
|
||||
submitKey,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ describe("useIsPremium", () => {
|
|||
features: [],
|
||||
info: null,
|
||||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
});
|
||||
|
|
@ -31,6 +33,8 @@ describe("useIsPremium", () => {
|
|||
features: [],
|
||||
info: null,
|
||||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
});
|
||||
|
|
@ -44,6 +48,8 @@ describe("useIsPremium", () => {
|
|||
features: [],
|
||||
info: null,
|
||||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue