Simpl-Resultat/src/components/settings/LicenseCard.tsx
le king fu e2f0fb86da
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
feat(license): remove the stored license key from the License card
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.

When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.

- Rust: remove_license / remove_license_in, release_this_machine_slot,
  this_machine_token_id, and the shared remove_activation_token_in
  helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
  useLicenseMachines (confirmations by state, error table, in-app log
  lines); removing and removeError in the machines reducer, which lock
  key entry, Activate and Deactivate; the button for any stored key,
  valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
  changelogs.

Resolves #370

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

318 lines
13 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,
removeKey,
showEmptyState,
canDeactivate,
canActivate,
canRemoveKey,
} = 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). A removal
// in flight would delete a key stored meanwhile (#370).
disabled={machines.activating || machines.removing}
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>
)}
{/* Any stored key, valid or revoked (#370): `hasLicense` would hide it for a
revoked key, the one whose daily check the removal stops. */}
{info !== null && (
<button
type="button"
onClick={() => void removeKey({ confirm: (message) => window.confirm(message), t })}
// Not while a key is being stored: the removal would delete it.
disabled={!canRemoveKey || validating}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm text-[var(--negative)] disabled:opacity-50"
>
{machines.removing && <Loader2 size={14} className="animate-spin" />}
{machines.removing ? t("license.removeKey.removing") : t("license.removeKey.button")}
</button>
)}
</div>
)}
{!showInput && machines.removeError && (
<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.removeKey.failed", { reason: errorMessage(machines.removeError) })}</p>
</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>
);
}