Add the reusable gating guard components on top of the #297 foundation: - UpsellGate: full locked screen (lock icon, tier title, per-feature description). Two CTAs: "Get <tier>" rendered VISIBLE but DISABLED with an "online purchase coming soon" note (per planning decision — #270 will activate it), and "I already have a key" navigating to /settings/users (LicenseCard). - RequireFeature: renders a neutral loader while the license is not ready (no upsell flash at boot), then children or UpsellGate. Renders <Outlet/> when children are omitted so it also works as a layout route grouping all routes of one feature. - requiredTierFor() pure helper in shared/entitlements.ts derives the minimum unlocking tier from matrix membership (not array order). - i18n: upsell.* (title, per-feature descriptions, CTAs) + nav.locked in BOTH locales; tier labels reuse the existing license.editions.* keys. - Tests: requiredTierFor mapping/minimality + upsell i18n coverage for every FeatureKey in fr and en (the components themselves are not testable without jsdom). Resolves #298 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
59 lines
2.1 KiB
TypeScript
59 lines
2.1 KiB
TypeScript
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);
|
|
}
|
|
|
|
/**
|
|
* Minimum paid tier that unlocks a feature — drives the upsell copy
|
|
* ("Obtenir Base" vs "Obtenir Premium"). Derived from matrix MEMBERSHIP, not
|
|
* array order, so reordering an ENTITLEMENTS row can never change the answer.
|
|
*/
|
|
export function requiredTierFor(f: FeatureKey): Edition {
|
|
return ENTITLEMENTS[f].includes("base") ? "base" : "premium";
|
|
}
|