Simpl-Resultat/src/components/shared/UpsellGate.tsx
le king fu e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.

Rust
- license.check, next to the key, holds what the license server last said
  about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
  missing, unreadable or invalid file, or a state about another key, reads
  as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
  locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
  the key only. Nothing is sent without a stored key that validates, nor
  within 24 h of the last attempt, which is written before the request.
  Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
  under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
  premium_required. It records nothing: the price route judges the license
  of the activation token, which the client cannot tie to the stored key,
  and 0.15.0 left the token of a previous key next to a new one (#367).

Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
  session flag and markRevoked are gone. The check starts once per app
  start, writes its outcome to the in-app log, and reloads the context only
  when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.

Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:45:34 -04:00

91 lines
3.6 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
import { upsellCtaFor } from "../../shared/upsellCta";
import { useLicenseContext } from "../../contexts/LicenseContext";
import PurchaseButton from "./PurchaseButton";
import HaveKeyButton from "./HaveKeyButton";
interface UpsellGateProps {
feature: FeatureKey;
requiredTier: Edition;
/**
* Called right after "I already have a key" navigates to the license card. Lets
* modal hosts (the ProfileSwitcher upsell dialog, ProfileFormModal) close
* themselves — the Sidebar stays mounted across navigations, so an uncontrolled
* modal would linger on top of the destination page. The purchase button never
* calls it: the product page opens in the browser and the host stays open.
* Optional: route-level usage ignores it.
*/
onNavigate?: () => void;
}
/**
* Full locked screen shown in place of a gated module (soft paywall).
*
* Two CTAs, the first one decided by `upsellCtaFor` (issue #346):
* - "Get <tier>" — active for Base, which opens the product page in the browser
* (PurchaseButton); disabled for Premium, with a note, since no Premium offer
* is sold yet.
* - "I already have a key" — navigates to the license card at /settings/users.
* Rendered only inside BrowserRouter (HaveKeyButton), so the gate also works in
* ProfileFormModal opened from ProfileSelectionPage, outside the router.
*
* The tier label reuses the existing `license.editions.*` keys; the feature
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
*
* When the stored key was revoked by the license server (#365), one line says so above
* the CTAs: the lock would otherwise appear with no reason given.
*/
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
const { t } = useTranslation();
const { revoked } = useLicenseContext();
const tier = t(`license.editions.${requiredTier}`);
const cta = upsellCtaFor(requiredTier);
return (
<div className="flex min-h-full items-center justify-center p-4">
<div className="max-w-md w-full space-y-6 text-center">
<Lock className="mx-auto h-16 w-16 text-[var(--muted-foreground)]" />
<div className="space-y-2">
<h1 className="text-2xl font-bold text-[var(--foreground)]">
{t("upsell.title", { tier })}
</h1>
<p className="text-[var(--muted-foreground)]">
{t(`upsell.features.${feature}`)}
</p>
</div>
{revoked && (
<p role="alert" className="text-sm text-[var(--negative)]">
{t("upsell.revoked")}
</p>
)}
<div className="flex flex-col gap-3">
{cta.purchasable ? (
<PurchaseButton />
) : (
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier })}
</button>
{cta.noteKey && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{t(cta.noteKey)}</p>
)}
</div>
)}
<HaveKeyButton onNavigate={onNavigate} />
</div>
</div>
</div>
);
}