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>
91 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|