From 3f90635298cdc04fb88fd934e9507923809ce6b1 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 30 Sep 2026 19:58:22 -0400 Subject: [PATCH] fix(license): apply the review of #364 - store_license_in drops activation.token when the stored key changes. The token names the previous license to the price server (its sub is the license id, the key carries the email), so the card showed the machine as activated while prices were refused for the old license. The same key entered again keeps its token; a refused key changes nothing. - get_activation_status goes through activation_status_in(dir, machine_id, key), tested in a temporary directory: a missing, expired, foreign-machine, foreign-key or unreadable token reads as not activated. It alone decides "Not activated" and "Activate". - LicenseCard is keyed by licenseIdentity(info): another key remounts the body, which re-reads the activation and drops the previous key's machine list. - invalidSignature asks to check the copy first. jsonwebtoken checks the signature before decoding the payload, so a damaged copy often reads as a signature failure (characterization test). User guide and changelogs aligned. - Render tests: the other errors stay visible under Machines, and the edition's green check gives way to an alert for a revoked key. Refs #347 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.fr.md | 3 +- CHANGELOG.md | 3 +- docs/adr/0017-feature-gating-par-tier.md | 2 +- docs/architecture.md | 6 +- docs/guide-utilisateur.md | 4 +- src-tauri/src/commands/license_commands.rs | 159 ++++++++++++++++-- .../settings/LicenseCard.render.test.tsx | 42 ++++- src/components/settings/LicenseCard.tsx | 12 +- src/contexts/LicenseContext.tsx | 20 ++- src/i18n/locales/en.json | 4 +- src/i18n/locales/fr.json | 4 +- 11 files changed, 218 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7d1e84f..62af572 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -24,8 +24,9 @@ - Quand le nombre maximal de machines est atteint, la liste des machines s'ouvre d'elle-même pour en désactiver une, et le message reste affiché pendant son chargement. Désactiver une machine ne réinterroge plus le serveur, et désactiver une machine que le serveur considère déjà libérée compte désormais comme réussi (#270). - Le bouton « Acheter » de la carte Licence ouvre désormais directement la page produit, dans la langue de l'application, au lieu d'une ancienne adresse qui ne fonctionne que grâce à une redirection du site (#270). - 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, clé non émise par le serveur de licences, 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). +- 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). ### Sécurité diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c7cf8..d779901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,9 @@ - When the machine limit is reached, the machine list opens by itself so you can deactivate one, and the message stays visible while it loads. Deactivating a machine no longer queries the server a second time, and deactivating a machine the server already considers free now counts as done (#270). - The "Buy" button of the License card now opens the product page directly, in the app's language, instead of an old address that only works through a redirect on the website (#270). - 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, unreadable key, key not issued by the license server, expired key, 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). +- 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). ### Security diff --git a/docs/adr/0017-feature-gating-par-tier.md b/docs/adr/0017-feature-gating-par-tier.md index 85e8910..77dec49 100644 --- a/docs/adr/0017-feature-gating-par-tier.md +++ b/docs/adr/0017-feature-gating-par-tier.md @@ -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-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. 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, §5, Liens) se lisent à la lumière de cet amendement. ## Contexte diff --git a/docs/architecture.md b/docs/architecture.md index d4f144c..5e652e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -293,7 +293,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l | Brique | Fichier | Rôle | |---|---|---| | Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé résolue en `free` (invalide ou expirée) ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell | -| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#347) : un `license_revoked` du serveur, consigné pour toute la session parce que la carte Licence se démonte en quittant Paramètres → Utilisateurs ; jamais persisté, sans effet sur l'édition, et effacé seulement quand une autre clé est enregistrée | +| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#347) : un `license_revoked` du serveur, consigné pour toute la session parce que la carte Licence se démonte en quittant Paramètres → Utilisateurs ; jamais persisté, sans effet sur l'édition, et effacé seulement quand une autre clé est enregistrée. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation | | `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue | | `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (``) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé | | `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas | @@ -347,9 +347,9 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac - `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide - `get_activation_status` — État d'activation de la machine courante. Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer » -Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. +Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». -Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`. Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md). +Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`. Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md). Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153). diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index b5dd271..874618c 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -558,8 +558,8 @@ Les modules au-dessus de votre édition restent **visibles mais verrouillés** : La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machines : - **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance » -- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible (copiée en partie ou coupée par un retour à la ligne), elle n'a pas été émise par le serveur de licences, elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas -- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours +- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas +- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours - **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place - **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, la carte l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer » diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 6f26448..fd32a6b 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -194,16 +194,30 @@ fn store_license_impl(app: &tauri::AppHandle, key: &str) -> Result Result { let info = validate_with_key(key, decoding_key)?; + let key = key.trim(); fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?; - fs::write(dir.join(LICENSE_FILE), key.trim()).map_err(|_| LicenseError::Storage)?; + // A token belongs to the key it was issued for, and the client cannot tell which: its + // `sub` is the license id, the key carries the email. Kept across a key change, it would + // show this machine as activated while the price server authenticates the previous + // license (#347 review). The same key entered again keeps its token. + let previous = fs::read_to_string(dir.join(LICENSE_FILE)).ok(); + if previous.as_deref().map(str::trim) != Some(key) { + match fs::remove_file(dir.join(ACTIVATION_FILE)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(LicenseError::Storage), + } + } + fs::write(dir.join(LICENSE_FILE), key).map_err(|_| LicenseError::Storage)?; Ok(info) } @@ -685,28 +699,28 @@ async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result Result { let machine_id = machine_id_internal()?; - let act_path = activation_path(&app)?; - - let is_activated = if act_path.exists() { - if let Ok(token) = fs::read_to_string(&act_path) { - if let Ok(decoding_key) = embedded_decoding_key() { - validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok() - } else { - false - } - } else { - false - } - } else { - false + let dir = app_data_dir(&app)?; + let is_activated = match embedded_decoding_key() { + Ok(decoding_key) => activation_status_in(&dir, &machine_id, &decoding_key), + Err(_) => false, }; - Ok(ActivationStatus { is_activated, machine_id, }) } +/// Whether `dir/activation.token` activates this machine: signed by the license server, +/// issued for `local_machine_id` and not expired. A missing, unreadable, expired or foreign +/// token reads as not activated, which is what brings back "Activate" (#360). Kept apart +/// from `AppHandle` so tests run it in a temporary directory. +fn activation_status_in(dir: &Path, local_machine_id: &str, decoding_key: &DecodingKey) -> bool { + let Ok(token) = fs::read_to_string(dir.join(ACTIVATION_FILE)) else { + return false; + }; + validate_activation_with_key(&token, local_machine_id, decoding_key).is_ok() +} + // === Tests ==================================================================================== #[cfg(test)] @@ -892,6 +906,18 @@ mod tests { assert_eq!(result.unwrap_err(), LicenseError::Malformed); } + #[test] + fn a_key_damaged_while_copying_can_read_as_invalid_signature() { + // jsonwebtoken checks the signature over `header.payload` before it decodes the + // payload: a space slipped into the payload reads as a signature failure. The + // `invalidSignature` message therefore sends the user back to the copy first. + let (enc, dec) = default_keys(); + let key = base_license_with_features(&enc, vec![]); + let payload_start = key.find('.').unwrap() + 1; + let damaged = format!("{} {}", &key[..payload_start + 4], &key[payload_start + 4..]); + assert_eq!(validate_with_key(&damaged, &dec).unwrap_err(), LicenseError::InvalidSignature); + } + #[test] fn rejects_unknown_edition() { let (enc, dec) = default_keys(); @@ -1166,6 +1192,103 @@ mod tests { assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "an invalid key reads as none"); } + #[test] + fn store_drops_the_token_of_a_previous_key() { + // Rebuy after a refund, reissue after a revocation, Base to Premium: the old token + // would name the previous license to the price server (#347 review). + let (enc, dec) = default_keys(); + let dir = TestDir::new("store_new_key"); + dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); + dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); + let new_key = base_license_with_features(&enc, vec![]); + store_license_in(&dir.0, &new_key, &dec).unwrap(); + assert!(!dir.0.join(ACTIVATION_FILE).exists(), "the previous key's token must go"); + assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), new_key); + } + + #[test] + fn store_keeps_the_token_when_the_same_key_is_entered_again() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("store_same_key"); + let key = base_license_with_features(&enc, vec![]); + dir.write(LICENSE_FILE, &key); + dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); + store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap(); + assert!(dir.0.join(ACTIVATION_FILE).exists(), "the same key keeps its activation"); + } + + #[test] + fn store_drops_a_token_left_without_a_key() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("store_orphan_token"); + dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); + store_license_in(&dir.0, &base_license_with_features(&enc, vec![]), &dec).unwrap(); + assert!(!dir.0.join(ACTIVATION_FILE).exists()); + } + + #[test] + fn a_refused_key_leaves_the_stored_key_and_token_alone() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("store_refused_keeps_all"); + let key = base_license_with_features(&enc, vec![]); + dir.write(LICENSE_FILE, &key); + dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); + let result = store_license_in(&dir.0, "BASE-not-a-key", &dec); + assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix); + assert!(dir.0.join(ACTIVATION_FILE).exists()); + assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key); + } + + // === Activation status: the decision behind "Not activated" / "Activate" (#347, #360) ====== + + #[test] + fn activation_status_accepts_a_token_of_this_machine() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("status_this_machine"); + dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); + assert!(activation_status_in(&dir.0, "this-machine", &dec)); + } + + #[test] + fn activation_status_refuses_a_missing_token() { + let (_enc, dec) = default_keys(); + let dir = TestDir::new("status_missing"); + assert!(!activation_status_in(&dir.0, "this-machine", &dec)); + } + + #[test] + fn activation_status_refuses_an_expired_token() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("status_expired"); + dir.write(ACTIVATION_FILE, expired_activation_for(&enc, "this-machine")); + assert!(!activation_status_in(&dir.0, "this-machine", &dec)); + } + + #[test] + fn activation_status_refuses_a_token_of_another_machine() { + let (enc, dec) = default_keys(); + let dir = TestDir::new("status_other_machine"); + dir.write(ACTIVATION_FILE, activation_for(&enc, "another-machine")); + assert!(!activation_status_in(&dir.0, "this-machine", &dec)); + } + + #[test] + fn activation_status_refuses_a_token_signed_by_another_key() { + let (_enc, dec) = default_keys(); + let (other_enc, _other_dec) = test_keys([7u8; 32]); + let dir = TestDir::new("status_other_key"); + dir.write(ACTIVATION_FILE, activation_for(&other_enc, "this-machine")); + assert!(!activation_status_in(&dir.0, "this-machine", &dec)); + } + + #[test] + fn activation_status_refuses_an_unreadable_token() { + let (_enc, dec) = default_keys(); + let dir = TestDir::new("status_unreadable"); + dir.write(ACTIVATION_FILE, NOT_UTF8); + assert!(!activation_status_in(&dir.0, "this-machine", &dec)); + } + // === Dev override ========================================================================= /// The dev override must be dead code in normal builds: even with diff --git a/src/components/settings/LicenseCard.render.test.tsx b/src/components/settings/LicenseCard.render.test.tsx index 6e4603e..0e599e8 100644 --- a/src/components/settings/LicenseCard.render.test.tsx +++ b/src/components/settings/LicenseCard.render.test.tsx @@ -3,6 +3,7 @@ // hook are replaced by fakes so each test pins one visible state. The fake // hook computes its flags with the real selectors. import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); @@ -18,7 +19,10 @@ vi.mock("react-i18next", () => ({ })); let context: Record; -vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => context })); +vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({ + ...(await importOriginal()), + useLicenseContext: () => context, +})); let machines: Record; vi.mock("../../hooks/useLicenseMachines", async (importOriginal) => ({ @@ -27,6 +31,7 @@ vi.mock("../../hooks/useLicenseMachines", async (importOriginal) => ({ })); import LicenseCard from "./LicenseCard"; +import { licenseIdentity } from "../../contexts/LicenseContext"; import { initialLicenseMachinesState, selectCanActivate, @@ -34,7 +39,7 @@ import { selectShowEmptyState, type LicenseMachinesState, } from "../../hooks/useLicenseMachines"; -import { parseLicenseError, type MachineInfo } from "../../services/licenseService"; +import { parseLicenseError, type LicenseInfo, type MachineInfo } from "../../services/licenseService"; const DAY_S = 86400; const nowS = () => Math.floor(Date.now() / 1000); @@ -127,6 +132,15 @@ describe("LicenseCard revoked key", () => { expect(render()).not.toContain('disabled=""'); }); + it("swaps the edition's green check for an alert", () => { + // Machine not activated: the "Activated" badge carries the same check icon. + machines = hookView({ activation: notActivated }, true); + expect(render()).not.toContain("lucide-circle-check-big"); + + machines = hookView({ activation: notActivated }); + expect(render()).toContain("lucide-circle-check-big"); + }); + it("does not repeat the revocation under Machines", () => { const revokedError = parseLicenseError('{"code":"license_revoked"}'); machines = hookView( @@ -138,6 +152,19 @@ describe("LicenseCard revoked key", () => { }); describe("LicenseCard machines", () => { + it("keeps the other errors visible under Machines", () => { + // Only the revocation moves to card level: the machine-limit message of the D2 path + // (#270) and a failed list load still show where they happen. + machines = hookView({ + activation: notActivated, + activationError: parseLicenseError('{"code":"machine_limit_reached"}'), + }); + expect(render()).toContain("license.errors.machineLimitReached"); + + machines = hookView({ listOpen: true, listError: parseLicenseError('{"code":"network"}') }); + expect(render()).toContain("license.errors.network"); + }); + it("counts the activated machines against the key's limit", () => { machines = hookView(loadedList([machine("a"), machine("b")])); expect(render()).toContain("license.machines.count[count=2,limit=3]"); @@ -204,3 +231,14 @@ describe("LicenseCard dates", () => { } }); }); + +describe("LicenseCard key change", () => { + it("remounts for another key, so the previous key's activation and machines are dropped", () => { + const first = LicenseCard() as ReactElement; + expect(first.key).toBe(licenseIdentity(context.info as LicenseInfo)); + + context = { ...context, info: { ...(context.info as LicenseInfo), issued_at: 1 } }; + const second = LicenseCard() as ReactElement; + expect(second.key).not.toBe(first.key); + }); +}); diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index bd3983f..2131d51 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -2,7 +2,7 @@ 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 { useLicenseContext } from "../../contexts/LicenseContext"; +import { licenseIdentity, useLicenseContext } from "../../contexts/LicenseContext"; import { useLicenseMachines } from "../../hooks/useLicenseMachines"; import { parseLicenseError, type LicenseError } from "../../services/licenseService"; import { purchaseUrl } from "../../shared/purchaseUrl"; @@ -10,7 +10,17 @@ 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 ; +} + +function LicenseCardBody() { const { t, i18n } = useTranslation(); const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } = useLicenseContext(); diff --git a/src/contexts/LicenseContext.tsx b/src/contexts/LicenseContext.tsx index f093722..4b9a622 100644 --- a/src/contexts/LicenseContext.tsx +++ b/src/contexts/LicenseContext.tsx @@ -77,15 +77,19 @@ export const initialLicenseState: LicenseState = { revoked: false, }; -/** Same license, as far as the client can tell: a re-entered key, not a replacement. */ +/** + * Identity of the stored license, as far as the client can tell (#347): the same email, + * edition, issue and expiry dates mean the same key, entered again. `null` without a key. + */ +export function licenseIdentity(info: LicenseInfo | null): string | null { + return info === null + ? null + : `${info.email}|${info.edition}|${info.issued_at}|${info.expires_at}`; +} + +/** Same license: a re-entered key, not a replacement. */ function isSameLicense(a: LicenseInfo | null, b: LicenseInfo): boolean { - return ( - a !== null && - a.email === b.email && - a.edition === b.edition && - a.issued_at === b.issued_at && - a.expires_at === b.expires_at - ); + return a !== null && licenseIdentity(a) === licenseIdentity(b); } /** Exported for unit tests only — not part of the context API. */ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6d4026c..3b60e44 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1123,7 +1123,7 @@ "The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer", "On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free", "The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"", - "Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer or an expired activation, the card shows \"Not activated\": click \"Activate\"", + "Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"", "The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot", "If the license server reports that your key was revoked, the License card says so until the app is closed and no longer offers \"Activate\"" ] @@ -1217,7 +1217,7 @@ "noActiveActivation": "This machine is not activated for this license.", "invalidInput": "The request sent to the license server is invalid.", "invalidPrefix": "A license key starts with SR-BASE- or SR-PREMIUM-. Check that it was copied in full.", - "invalidSignature": "This license key was not issued by the Simpl'Result license server.", + "invalidSignature": "This license key could not be verified. Check that it was copied in full; if it was, it was not issued by the Simpl'Result license server.", "malformed": "This license key cannot be read. Check that it was copied in full, without spaces or line breaks.", "unknownEdition": "This license key is for an edition this version of the app does not know. Update the app, then enter the key again.", "rateLimit_one": "Too many attempts. Try again in {{count}} second.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index cea2cfa..931d318 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1123,7 +1123,7 @@ "La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste", "En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite", "La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »", - "Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »", + "Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »", "La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place", "Si le serveur de licences signale que votre clé a été révoquée, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »" ] @@ -1217,7 +1217,7 @@ "noActiveActivation": "Cette machine n'est pas activée pour cette licence.", "invalidInput": "La requête envoyée au serveur de licences est invalide.", "invalidPrefix": "Une clé de licence commence par SR-BASE- ou SR-PREMIUM-. Vérifiez qu'elle a été copiée en entier.", - "invalidSignature": "Cette clé de licence n'a pas été émise par le serveur de licences de Simpl'Résultat.", + "invalidSignature": "Cette clé de licence n'a pas pu être vérifiée. Vérifiez qu'elle a été copiée en entier ; si c'est le cas, elle n'a pas été émise par le serveur de licences de Simpl'Résultat.", "malformed": "Cette clé de licence est illisible. Vérifiez qu'elle a été copiée en entier, sans espace ni retour à la ligne.", "unknownEdition": "Cette clé de licence désigne une édition que cette version de l'application ne connaît pas. Mettez l'application à jour, puis entrez la clé de nouveau.", "rateLimit_one": "Trop de tentatives. Réessayez dans {{count}} seconde.",