fix(license): apply the second review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m15s

- Key entry and machine activation can no longer overlap. An activation
  in flight writes the token of the key it read: a key stored meanwhile
  ended up next to the previous key's token, the dead end 3f90635
  closes. "Enter a key" and the form's submit are disabled while an
  activation runs (LicenseKeyForm gains a busy prop), and "Activate"
  while a key is being stored.
- The "Activated" badge is hidden for a revoked key: the server
  deactivated every machine of the license.
- ADR 0017: the #347 amendment also points at section 1, which still
  mentions the machine-binding downgrade.

Refs #347

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-09-30 20:44:51 -04:00
parent 3f90635298
commit cdf4df85f7
7 changed files with 57 additions and 10 deletions

View file

@ -12,7 +12,7 @@
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346). - La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346). - L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346). - En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
- La carte Licence signale une clé révoquée jusqu'à la fermeture de l'application et ne propose plus « Activer » pour elle ; le message disparaissait auparavant avec la liste des machines (#347). - La carte Licence signale une clé révoquée jusqu'à la fermeture de l'application, n'affiche plus sa machine comme activée et ne propose plus « Activer » pour elle ; le message disparaissait auparavant avec la liste des machines (#347).
- La liste des machines indique combien sont activées sur le nombre permis par votre clé (#347). - La liste des machines indique combien sont activées sur le nombre permis par votre clé (#347).
- Une clé de licence valide pour plus de 50 ans affiche « Sans échéance » au lieu d'une date (#347). - Une clé de licence valide pour plus de 50 ans affiche « Sans échéance » au lieu d'une date (#347).
- L'erreur d'authentification des cours du marché indique désormais d'activer cette machine dans Paramètres → Utilisateurs (#347). - L'erreur d'authentification des cours du marché indique désormais d'activer cette machine dans Paramètres → Utilisateurs (#347).
@ -26,7 +26,7 @@
- Une clé de licence valide garde désormais son édition quelle que soit l'activation de la machine : une activation expirée (au bout de deux ans), ou reprise d'un autre ordinateur quand vous restaurez vos données après une réinstallation ou un changement de poste, ne fait plus retomber l'application en édition Gratuite. La carte Licence indique que la machine n'est pas activée, et « Activer » rétablit les cours du marché (#347, #360). - Une clé de licence valide garde désormais son édition quelle que soit l'activation de la machine : une activation expirée (au bout de deux ans), ou reprise d'un autre ordinateur quand vous restaurez vos données après une réinstallation ou un changement de poste, ne fait plus retomber l'application en édition Gratuite. La carte Licence indique que la machine n'est pas activée, et « Activer » rétablit les cours du marché (#347, #360).
- Une clé de licence refusée est désormais expliquée dans votre langue (préfixe incorrect, clé illisible ou qui n'a pas pu être vérifiée, le plus souvent parce qu'elle a été copiée en partie, clé expirée, édition inconnue de cette version) au lieu d'un message anglais brut, et un fichier de licence illisible est nommé lui aussi (#347). - Une clé de licence refusée est désormais expliquée dans votre langue (préfixe incorrect, clé illisible ou qui n'a pas pu être vérifiée, le plus souvent parce qu'elle a été copiée en partie, clé expirée, édition inconnue de cette version) au lieu d'un message anglais brut, et un fichier de licence illisible est nommé lui aussi (#347).
- Les dates de la carte Licence suivent la langue de l'application plutôt que celle du système (#347). - Les dates de la carte Licence suivent la langue de l'application plutôt que celle du système (#347).
- Enregistrer une autre clé de licence efface désormais l'activation de la clé précédente : la carte Licence propose « Activer » pour la nouvelle clé. Elle affichait auparavant la machine comme activée alors que les cours du marché étaient refusés au nom de l'ancienne licence, par exemple après un rachat suivant un remboursement, ou lors du passage de Base à Premium (#347). - Enregistrer une autre clé de licence efface désormais l'activation de la clé précédente : la carte Licence propose « Activer » pour la nouvelle clé. Elle affichait auparavant la machine comme activée alors que les cours du marché étaient refusés au nom de l'ancienne licence, par exemple après un rachat suivant un remboursement, ou lors du passage de Base à Premium. La saisie d'une clé et l'activation d'une machine ne peuvent plus se chevaucher, donc plus se croiser (#347).
### Sécurité ### Sécurité

View file

@ -12,7 +12,7 @@
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346). - The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346). - The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346). - In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
- The License card keeps a revoked key flagged until the app is closed, and no longer offers "Activate" for it; the message used to disappear with the machine list (#347). - The License card keeps a revoked key flagged until the app is closed, no longer shows its machine as activated, and no longer offers "Activate" for it; the message used to disappear with the machine list (#347).
- The machine list shows how many machines are activated out of the number your key allows (#347). - The machine list shows how many machines are activated out of the number your key allows (#347).
- A license key valid for more than 50 years shows "No expiry date" instead of a date (#347). - A license key valid for more than 50 years shows "No expiry date" instead of a date (#347).
- The market price authentication error now tells you to activate this machine in Settings → Users (#347). - The market price authentication error now tells you to activate this machine in Settings → Users (#347).
@ -26,7 +26,7 @@
- A valid license key now keeps its edition whatever the machine activation: an activation that has expired (after two years), or one carried over from another computer when you restore your data after a reinstall or a move, no longer drops the app to the Free edition. The License card shows the machine as not activated, and "Activate" brings back the market prices (#347, #360). - A valid license key now keeps its edition whatever the machine activation: an activation that has expired (after two years), or one carried over from another computer when you restore your data after a reinstall or a move, no longer drops the app to the Free edition. The License card shows the machine as not activated, and "Activate" brings back the market prices (#347, #360).
- A refused license key is now explained in your language (wrong prefix, a key that cannot be read or verified, most often because it was not copied in full, an expired key, an edition this version does not know) instead of a raw English message, and a license file that cannot be read is named too (#347). - A refused license key is now explained in your language (wrong prefix, a key that cannot be read or verified, most often because it was not copied in full, an expired key, an edition this version does not know) instead of a raw English message, and a license file that cannot be read is named too (#347).
- Dates on the License card follow the app's language instead of the system's (#347). - Dates on the License card follow the app's language instead of the system's (#347).
- Entering a different license key now clears the previous key's machine activation, so the License card offers "Activate" for the new key. It used to show the machine as activated while market prices were refused for the previous license, for instance after a new purchase following a refund, or when moving from Base to Premium (#347). - Entering a different license key now clears the previous key's machine activation, so the License card offers "Activate" for the new key. It used to show the machine as activated while market prices were refused for the previous license, for instance after a new purchase following a refund, or when moving from Base to Premium. Entering a key and activating a machine can no longer overlap, so they cannot cross either (#347).
### Security ### Security

View file

@ -8,7 +8,7 @@
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé. > **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
> **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. Enregistrer une autre clé supprime le jeton de la précédente : il désignerait l'ancienne licence auprès du serveur des cours. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §5, Liens) se lisent à la lumière de cet amendement. > **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. Enregistrer une autre clé supprime le jeton de la précédente : il désignerait l'ancienne licence auprès du serveur des cours. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §1, §5, Liens) se lisent à la lumière de cet amendement.
## Contexte ## Contexte

View file

@ -141,6 +141,15 @@ describe("LicenseCard revoked key", () => {
expect(render()).toContain("lucide-circle-check-big"); expect(render()).toContain("lucide-circle-check-big");
}); });
it("hides the Activated badge: the server deactivated every machine of a revoked key", () => {
const activated = { is_activated: true, machine_id: "this" };
machines = hookView({ activation: activated }, true);
expect(render()).not.toContain("license.machines.activated");
machines = hookView({ activation: activated });
expect(render()).toContain("license.machines.activated");
});
it("does not repeat the revocation under Machines", () => { it("does not repeat the revocation under Machines", () => {
const revokedError = parseLicenseError('{"code":"license_revoked"}'); const revokedError = parseLicenseError('{"code":"license_revoked"}');
machines = hookView( machines = hookView(
@ -190,6 +199,24 @@ describe("LicenseCard machines", () => {
expect(html).toContain("license.activate"); expect(html).toContain("license.activate");
}); });
it("locks key entry while an activation runs, and Activate while a key is stored", () => {
// Either order would leave the previous key's token next to the new key (#364 review).
const enterKeyDisabled = /<button[^>]*disabled=""[^>]*>license\.enterKey<\/button>/;
const activateDisabled = /<button[^>]*disabled=""[^>]*>license\.activate<\/button>/;
machines = hookView({ activation: notActivated, activating: true });
expect(render()).toMatch(enterKeyDisabled);
machines = hookView({ activation: notActivated });
context = { ...context, validating: true };
expect(render()).toMatch(activateDisabled);
context = { ...context, validating: false };
const idle = render();
expect(idle).not.toMatch(enterKeyDisabled);
expect(idle).not.toMatch(activateDisabled);
});
it("labels the buttons while an activation or a deactivation runs", () => { it("labels the buttons while an activation or a deactivation runs", () => {
machines = hookView({ activation: notActivated, activating: true }); machines = hookView({ activation: notActivated, activating: true });
expect(render()).toContain("license.machines.activating"); expect(render()).toContain("license.machines.activating");

View file

@ -129,7 +129,10 @@ function LicenseCardBody() {
<button <button
type="button" type="button"
onClick={() => setShowInput(true)} onClick={() => setShowInput(true)}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm" // 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")} {t("license.enterKey")}
</button> </button>
@ -156,6 +159,7 @@ function LicenseCardBody() {
setKeyInput(""); setKeyInput("");
}} }}
validating={validating} validating={validating}
busy={machines.activating}
validationError={validationError} validationError={validationError}
/> />
)} )}
@ -176,7 +180,9 @@ function LicenseCardBody() {
<button <button
type="button" type="button"
onClick={() => void activate()} onClick={() => void activate()}
disabled={!canActivate} // 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" 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 && <Loader2 size={12} className="animate-spin" />}
@ -184,7 +190,8 @@ function LicenseCardBody() {
</button> </button>
</> </>
)} )}
{machines.activation?.is_activated && ( {/* 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)]"> <span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} /> <CheckCircle size={12} />
{t("license.machines.activated")} {t("license.machines.activated")}

View file

@ -13,7 +13,7 @@ vi.mock("react-i18next", () => ({
import LicenseKeyForm from "./LicenseKeyForm"; import LicenseKeyForm from "./LicenseKeyForm";
function render(validationError: string | null): string { function render(validationError: string | null, busy = false): string {
return renderToStaticMarkup( return renderToStaticMarkup(
<LicenseKeyForm <LicenseKeyForm
value="SR-BASE-x" value="SR-BASE-x"
@ -21,6 +21,7 @@ function render(validationError: string | null): string {
onSubmit={() => {}} onSubmit={() => {}}
onCancel={() => {}} onCancel={() => {}}
validating={false} validating={false}
busy={busy}
validationError={validationError} validationError={validationError}
/>, />,
); );
@ -42,4 +43,10 @@ describe("LicenseKeyForm", () => {
it("shows no error line before a submission is refused", () => { it("shows no error line before a submission is refused", () => {
expect(render(null)).not.toContain("license.errors."); expect(render(null)).not.toContain("license.errors.");
}); });
it("blocks the submission while a machine activation runs", () => {
// The activation writes the token of the key it read (#364 review).
expect(render(null, true)).toMatch(/<button type="submit" disabled=""/);
expect(render(null, false)).not.toMatch(/<button type="submit" disabled=""/);
});
}); });

View file

@ -8,6 +8,11 @@ interface LicenseKeyFormProps {
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
onCancel: () => void; onCancel: () => void;
validating: boolean; validating: boolean;
/**
* A machine activation is running: the stored key must not change before it writes the
* token of the key it read (#364 review).
*/
busy: boolean;
/** /**
* Raw rejection of the last key submission, as LicenseContext keeps it, or null. * Raw rejection of the last key submission, as LicenseContext keeps it, or null.
* Required so the card cannot drop it (#347). * Required so the card cannot drop it (#347).
@ -26,6 +31,7 @@ export default function LicenseKeyForm({
onSubmit, onSubmit,
onCancel, onCancel,
validating, validating,
busy,
validationError, validationError,
}: LicenseKeyFormProps) { }: LicenseKeyFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -50,7 +56,7 @@ export default function LicenseKeyForm({
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={validating || !value.trim()} disabled={validating || busy || !value.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
> >
{validating && <Loader2 size={14} className="animate-spin" />} {validating && <Loader2 size={14} className="animate-spin" />}