Simpl-Resultat/src/shared/entitlements.ts
le king fu 554373e7d8
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s
feat(gating): UI guard — RequireFeature + UpsellGate + i18n
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>
2026-07-20 22:05:04 -04:00

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";
}