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>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { licenseIdentity, useLicenseContext } from "../../contexts/LicenseContext";
|
|
import { useLicenseMachines } from "../../hooks/useLicenseMachines";
|
|
import { parseLicenseError, type LicenseError } from "../../services/licenseService";
|
|
import { purchaseUrl } from "../../shared/purchaseUrl";
|
|
import { licenseExpiryDisplay } from "../../shared/licenseExpiry";
|
|
import { logError } from "../../services/logService";
|
|
import LicenseKeyForm from "./LicenseKeyForm";
|
|
|
|
/**
|
|
* License card, keyed by the stored license (#347): another key has its own activation
|
|
* and machines, so the body remounts. That drops the previous key's status and list,
|
|
* and re-reads the activation, which `store_license` has just cleared for a new key.
|
|
*/
|
|
export default function LicenseCard() {
|
|
const { info } = useLicenseContext();
|
|
return <LicenseCardBody key={licenseIdentity(info) ?? "no-license"} />;
|
|
}
|
|
|
|
function LicenseCardBody() {
|
|
const { t, i18n } = useTranslation();
|
|
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
|
useLicenseContext();
|
|
const [keyInput, setKeyInput] = useState("");
|
|
const [showInput, setShowInput] = useState(false);
|
|
|
|
const hasLicense = edition !== "free";
|
|
const {
|
|
state: machines,
|
|
revoked,
|
|
activate,
|
|
deactivate,
|
|
toggleList,
|
|
showEmptyState,
|
|
canDeactivate,
|
|
canActivate,
|
|
} = useLicenseMachines(hasLicense);
|
|
|
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = keyInput.trim();
|
|
if (!trimmed) return;
|
|
const result = await submitKey(trimmed);
|
|
if (result.ok) {
|
|
setKeyInput("");
|
|
setShowInput(false);
|
|
}
|
|
};
|
|
|
|
const handlePurchase = () => {
|
|
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
|
|
logError("Product page: openUrl failed", e),
|
|
);
|
|
};
|
|
|
|
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
|
|
|
|
// A revoked key is named once, at card level (#347): not repeated under Machines. Since
|
|
// #365 the revocation is persisted and the edition of a revoked key is Free, so the
|
|
// Machines section is gone with it; these branches only still show under a paid edition
|
|
// that comes from the Maximus account while a revoked key is stored.
|
|
const shownUnderMachines = (err: LicenseError | null) =>
|
|
err && err.code !== "license_revoked" ? err : null;
|
|
const activationError = shownUnderMachines(machines.activationError);
|
|
const listError = shownUnderMachines(machines.listError);
|
|
|
|
const editionLabel = t(`license.editions.${edition}`);
|
|
// No expiry date for a revoked key: it would read as a date until which it works.
|
|
const expiry =
|
|
info && !revoked && info.expires_at > 0
|
|
? licenseExpiryDisplay(info.expires_at, Math.floor(Date.now() / 1000), dateLocale)
|
|
: null;
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<KeyRound size={18} />
|
|
{t("license.title")}
|
|
</h2>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("license.currentEdition")}
|
|
</p>
|
|
<p className="text-base font-medium">
|
|
{editionLabel}
|
|
{edition !== "free" &&
|
|
(revoked ? (
|
|
<AlertCircle size={16} className="inline ml-2 text-[var(--negative)]" />
|
|
) : (
|
|
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
|
))}
|
|
</p>
|
|
</div>
|
|
{expiry && (
|
|
<div className="text-right">
|
|
{expiry.kind === "date" ? (
|
|
<>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("license.expiresAt")}
|
|
</p>
|
|
<p className="text-sm">{expiry.text}</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm">{t("license.noExpiry")}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{revoked && (
|
|
<div role="alert" className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
<p>{t("license.errors.licenseRevoked")}</p>
|
|
</div>
|
|
)}
|
|
|
|
{licenseStatus === "error" && error && (
|
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
<p>{errorMessage(parseLicenseError(error))}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!showInput && (
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowInput(true)}
|
|
// An activation in flight writes the token of the key it read: a key stored
|
|
// meanwhile would end up next to the previous key's token (#364 review).
|
|
disabled={machines.activating}
|
|
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm disabled:opacity-50"
|
|
>
|
|
{t("license.enterKey")}
|
|
</button>
|
|
{edition === "free" && (
|
|
<button
|
|
type="button"
|
|
onClick={handlePurchase}
|
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
|
|
>
|
|
<ExternalLink size={14} />
|
|
{t("license.purchase")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showInput && (
|
|
<LicenseKeyForm
|
|
value={keyInput}
|
|
onChange={setKeyInput}
|
|
onSubmit={handleSubmit}
|
|
onCancel={() => {
|
|
setShowInput(false);
|
|
setKeyInput("");
|
|
}}
|
|
validating={validating}
|
|
busy={machines.activating}
|
|
validationError={validationError}
|
|
/>
|
|
)}
|
|
|
|
{hasLicense && (
|
|
<div className="border-t border-[var(--border)] pt-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
|
<Monitor size={16} />
|
|
{t("license.machines.title")}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{machines.activation && !machines.activation.is_activated && (
|
|
<>
|
|
<span className="text-xs text-[var(--muted-foreground)]">
|
|
{t("license.machines.notActivated")}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void activate()}
|
|
// Not while a key is being stored: the activation would read the
|
|
// previous key and write its token after the new one (#364 review).
|
|
disabled={!canActivate || validating}
|
|
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
|
|
>
|
|
{machines.activating && <Loader2 size={12} className="animate-spin" />}
|
|
{machines.activating ? t("license.machines.activating") : t("license.activate")}
|
|
</button>
|
|
</>
|
|
)}
|
|
{/* A revoked key has no activated machine left on the server. */}
|
|
{machines.activation?.is_activated && !revoked && (
|
|
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
|
<CheckCircle size={12} />
|
|
{t("license.machines.activated")}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={toggleList}
|
|
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
|
|
>
|
|
{machines.listOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{activationError && (
|
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
|
<p>{errorMessage(activationError)}</p>
|
|
</div>
|
|
)}
|
|
|
|
{machines.listOpen && (
|
|
<div className="space-y-2">
|
|
{machines.listLoading && machines.machines.length === 0 && (
|
|
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
|
<Loader2 size={12} className="animate-spin" />
|
|
{t("common.loading")}
|
|
</div>
|
|
)}
|
|
{listError && (
|
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
|
<p>{errorMessage(listError)}</p>
|
|
</div>
|
|
)}
|
|
{showEmptyState && (
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("license.machines.noMachines")}
|
|
</p>
|
|
)}
|
|
{/* The limit comes from the signed key: no count without one (an account
|
|
Premium has no stored key). An empty list says so on its own line. */}
|
|
{info &&
|
|
machines.listLoaded &&
|
|
machines.listError === null &&
|
|
machines.machines.length > 0 && (
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("license.machines.count", {
|
|
count: machines.machines.length,
|
|
limit: info.machine_limit,
|
|
})}
|
|
</p>
|
|
)}
|
|
{machines.machines.map((m) => {
|
|
const isThis = m.is_this_machine;
|
|
const deactivating = machines.deactivatingId === m.machine_id;
|
|
return (
|
|
<div
|
|
key={m.machine_id}
|
|
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
|
|
>
|
|
<div>
|
|
<span className="font-medium">
|
|
{m.machine_name || m.machine_id.slice(0, 12)}
|
|
</span>
|
|
{isThis && (
|
|
<span className="ml-2 text-xs text-[var(--positive)]">
|
|
({t("license.machines.thisMachine")})
|
|
</span>
|
|
)}
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{new Date(m.activated_at).toLocaleDateString(dateLocale)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void deactivate(m.machine_id)}
|
|
disabled={!canDeactivate}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
|
>
|
|
{deactivating && <Loader2 size={10} className="animate-spin" />}
|
|
{deactivating
|
|
? t("license.machines.deactivating")
|
|
: t("license.machines.deactivate")}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|