feat(license): remove the stored license key from the License card (#370) #387

Merged
maximus merged 1 commit from issue-370-remove-license-key into main 2026-10-05 23:22:52 +00:00
15 changed files with 1214 additions and 41 deletions
Showing only changes of commit e2f0fb86da - Show all commits

View file

@ -8,6 +8,7 @@
- Une liste de démarrage sur le Tableau de bord, calculée depuis vos données : six étapes (créer un profil, choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget) se cochent d'elles-mêmes au fil de votre progression, et l'étape Budget est marquée « Base et plus » en édition Gratuite. La liste occupe le tableau de bord vide, se réduit à une barre compacte dès qu'il y a des transactions, disparaît quand toutes les étapes sont faites, et « Masquer » la retire pour le profil courant seulement (#351, #352). - Une liste de démarrage sur le Tableau de bord, calculée depuis vos données : six étapes (créer un profil, choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget) se cochent d'elles-mêmes au fil de votre progression, et l'étape Budget est marquée « Base et plus » en édition Gratuite. La liste occupe le tableau de bord vide, se réduit à une barre compacte dès qu'il y a des transactions, disparaît quand toutes les étapes sont faites, et « Masquer » la retire pour le profil courant seulement (#351, #352).
- « Créer un dossier d'exemple » sur la page Import quand le dossier d'import n'a aucun sous-dossier de banque : le bouton dépose deux sources fictives (« Exemple Desjardins », « Exemple Tangerine »), un relevé chacune couvrant les deux mois précédents, pour essayer l'assistant avant que vos vrais relevés soient en place. Il refuse d'écraser un dossier existant du même nom (#354, #356). - « Créer un dossier d'exemple » sur la page Import quand le dossier d'import n'a aucun sous-dossier de banque : le bouton dépose deux sources fictives (« Exemple Desjardins », « Exemple Tangerine »), un relevé chacune couvrant les deux mois précédents, pour essayer l'assistant avant que vos vrais relevés soient en place. Il refuse d'écraser un dossier existant du même nom (#354, #356).
- Une sous-section « Exporter un CSV depuis votre banque » dans le guide d'utilisation, avec une procédure par banque (Desjardins, Tangerine, RBC, Banque Nationale, BMO) et une ligne de statut indiquant si chacune a été confirmée sur un compte réel (#355). - Une sous-section « Exporter un CSV depuis votre banque » dans le guide d'utilisation, avec une procédure par banque (Desjardins, Tangerine, RBC, Banque Nationale, BMO) et une ligne de statut indiquant si chacune a été confirmée sur un compte réel (#355).
- « Retirer la clé » dans la carte Licence (Paramètres → Utilisateurs) retire la clé de licence enregistrée sur cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de son édition se verrouillent et l'application repasse en édition Gratuite, vos données étant conservées. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences, ce qu'elle faisait au plus une fois par 24 heures tant que la clé restait enregistrée. Quand cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas, l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis un autre ordinateur où la clé est enregistrée. Une clé révoquée retirée puis entrée de nouveau reste révoquée. Chaque retrait est inscrit dans les journaux (Paramètres → Système) (#370).
### Modifié ### Modifié

View file

@ -8,6 +8,7 @@
- A getting-started checklist on the Dashboard, computed from your data: six steps (create a profile, choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget) tick themselves as you go, and the Budget step is marked "Base and up" in the Free edition. The list fills the empty dashboard, shrinks to a compact bar once transactions exist, disappears when every step is done, and "Hide" dismisses it for the current profile only (#351, #352). - A getting-started checklist on the Dashboard, computed from your data: six steps (create a profile, choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget) tick themselves as you go, and the Budget step is marked "Base and up" in the Free edition. The list fills the empty dashboard, shrinks to a compact bar once transactions exist, disappears when every step is done, and "Hide" dismisses it for the current profile only (#351, #352).
- "Create a sample folder" on the Import page when the import folder has no bank sub-folder: it writes two fictitious sources ("Exemple Desjardins", "Exemple Tangerine"), one statement each covering the two previous months, so you can try the wizard before your real statements are in place. It refuses to overwrite an existing folder of the same name (#354, #356). - "Create a sample folder" on the Import page when the import folder has no bank sub-folder: it writes two fictitious sources ("Exemple Desjardins", "Exemple Tangerine"), one statement each covering the two previous months, so you can try the wizard before your real statements are in place. It refuses to overwrite an existing folder of the same name (#354, #356).
- An "Export a CSV from your bank" sub-section in the user guide, with a procedure per bank (Desjardins, Tangerine, RBC, National Bank, BMO) and a status line saying whether each one was confirmed on a real account (#355). - An "Export a CSV from your bank" sub-section in the user guide, with a procedure per bank (Desjardins, Tangerine, RBC, National Bank, BMO) and a status line saying whether each one was confirmed on a real account (#355).
- "Remove the key" on the License card (Settings → Users) removes the stored license key from this computer, after a confirmation that says what it changes. A valid key: the modules of its edition lock and the app goes back to the Free edition, with your data kept. A revoked key: the app stops sending it to the license server, which it did at most once every 24 hours for as long as the key stayed stored. When this machine is activated, its slot is freed on the license server first; if the server cannot be reached, the app asks whether to remove the key anyway, and the slot then stays occupied until you free it from another computer that has the key. A revoked key removed then entered again stays revoked. Each removal is written to the logs (Settings → System) (#370).
### Changed ### Changed

View file

@ -16,7 +16,9 @@
> >
> **La requête au démarrage et à la saisie d'une clé, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle part au démarrage et à la saisie d'une clé, sans minuteur en cours de session, toujours au plus une tentative par 24 h et par clé (`license.check` ne retient que la dernière clé contrôlée : revenir à une clé précédente en rend un nouveau dû). Le contrôle à la saisie a été ajouté par #373 (2026-10-04) : jusque-là, une clé saisie en cours de session n'était contrôlée qu'au démarrage suivant, et une clé révoquée jamais contrôlée sur ce poste gardait son édition d'ici là, puisqu'aucune activation n'est requise depuis #347. Il part en arrière-plan, après l'enregistrement de la clé : la saisie rend la main sans attendre le serveur, hors ligne comme en ligne, et une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite. Rust ne laisse passer qu'un contrôle à la fois, verrou tenu jusqu'au verdict (`check_revocation_in_turn`) : une clé saisie avant la fin du chargement initial, contrôlée par la saisie et par le démarrage, n'envoie qu'une requête, le second contrôle lisant la tentative du premier ; et le contrôle d'une clé saisie pendant celui d'une autre attend ce verdict au lieu de l'écraser. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app), un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition) et, pour #373, attendre la réponse dans la saisie (l'indicateur de validation tournerait jusqu'à 15 s hors ligne). > **La requête au démarrage et à la saisie d'une clé, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle part au démarrage et à la saisie d'une clé, sans minuteur en cours de session, toujours au plus une tentative par 24 h et par clé (`license.check` ne retient que la dernière clé contrôlée : revenir à une clé précédente en rend un nouveau dû). Le contrôle à la saisie a été ajouté par #373 (2026-10-04) : jusque-là, une clé saisie en cours de session n'était contrôlée qu'au démarrage suivant, et une clé révoquée jamais contrôlée sur ce poste gardait son édition d'ici là, puisqu'aucune activation n'est requise depuis #347. Il part en arrière-plan, après l'enregistrement de la clé : la saisie rend la main sans attendre le serveur, hors ligne comme en ligne, et une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite. Rust ne laisse passer qu'un contrôle à la fois, verrou tenu jusqu'au verdict (`check_revocation_in_turn`) : une clé saisie avant la fin du chargement initial, contrôlée par la saisie et par le démarrage, n'envoie qu'une requête, le second contrôle lisant la tentative du premier ; et le contrôle d'une clé saisie pendant celui d'une autre attend ce verdict au lieu de l'écraser. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app), un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition) et, pour #373, attendre la réponse dans la saisie (l'indicateur de validation tournerait jusqu'à 15 s hors ligne).
> >
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, et aucune interface ne retire une clé enregistrée, donc seul l'enregistrement d'une autre clé arrête cet envoi. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur. > **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, tant qu'elle reste enregistrée. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur.
>
> **Le retrait de la clé (#370, 2026-10-04).** Jusque-là, aucune interface ne retirait une clé enregistrée, et seul l'enregistrement d'une autre clé arrêtait cet envoi. « Retirer la clé », dans la carte Licence, l'arrête aussi : `remove_license` supprime `license.key` d'abord, puis le jeton d'activation, et une suppression qui échoue rend `storage` sans jamais laisser la clé sans son jeton. `license.check` est gardé : indexé par le condensé de la clé, il ne pèse sur aucune autre, et le supprimer rendrait son édition à une clé révoquée retirée puis ressaisie jusqu'au contrôle suivant, un contournement mis à la portée de tous par un bouton. Une telle clé reste donc en Gratuite, par cet état si aucune autre clé n'a été contrôlée entre-temps, sinon par le contrôle à la saisie (#373) ; hors ligne après le contrôle d'une autre clé, elle garde son édition jusqu'au contrôle suivant (fail-open, ci-dessus). Sur une machine activée avec une clé valide, le retrait libère d'abord sa place sur le serveur de licences (décision de Max du 2026-10-03) ; si le serveur ne répond pas, rien n'est supprimé et l'app demande s'il faut retirer la clé quand même, la place restant occupée.
> >
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367. > **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367.
> >
@ -142,6 +144,6 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium - `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé - `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création) - `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `dev_override_edition` (CWE-489) - `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur - [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #367 (jeton d'activation périmé laissé par la v0.15.0) - Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0)

View file

@ -186,7 +186,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » | | `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365) | | `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) | | `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) | | `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
@ -230,7 +230,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` | | `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») | | `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) | | `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition | | `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
### Hook transverse — `useCollapsibleGroups` ### Hook transverse — `useCollapsibleGroups`
@ -340,7 +340,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide. Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (37) ## Commandes Tauri (38)
### `fs_commands.rs` — Système de fichiers (7) ### `fs_commands.rs` — Système de fichiers (7)
@ -370,10 +370,11 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité) - `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
- `repair_migrations` — Réparation des checksums de migration (rusqlite) - `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence et activation machine (10) ### `license_commands.rs` — Licence et activation machine (11)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519) - `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data - `store_license` — Stockage de la clé dans le répertoire app data
- `remove_license` — Retrait de la clé enregistrée (#370) : `license.key` d'abord, puis `activation.token` ; `license.check` est gardé. Avec `release_slot`, libère d'abord la place de cette machine (`/v1/licenses/deactivate`, identifiant du jeton) quand le jeton active ce poste et que la clé n'est pas connue comme révoquée. `no_active_activation`, `license_not_found` et `license_revoked` (enregistré pour la clé envoyée) valent place libre ; toute autre erreur de la libération est rendue sans rien supprimer. Idempotente sans clé
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine - `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
- `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365) - `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365)
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365) - `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
@ -383,11 +384,11 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer » - `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé - `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
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 ». 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 ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in`, la désactivation et, ensuite, #367 l'emploient aussi.
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`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). 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). 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`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). 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).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03). La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md). Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR. Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.

View file

@ -637,11 +637,12 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau - **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
- **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 - **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, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition - **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente - **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Pour arrêter cette vérification, retirez la clé (« Retirer la clé », ci-dessous) ; l'enregistrement d'une autre clé arrête aussi l'envoi de la précédente
- **Retirer la clé** — « Retirer la clé » retire votre clé de cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de votre édition se verrouillent et l'application repasse en édition Gratuite ; vos données sont conservées et réapparaissent dès qu'une clé valide est entrée. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences. Si cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas (hors ligne, par exemple), l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis la liste des machines, sur un autre ordinateur où la clé est enregistrée. Chaque retrait est inscrit dans les journaux. Une clé révoquée retirée puis entrée de nouveau reste révoquée
### Astuces ### Astuces
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée - Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés - En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste - 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 - 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

View file

@ -22,6 +22,9 @@
// only when a key is stored (`check_license_revocation`); its answer is kept in // only when a key is stored (`check_license_revocation`); its answer is kept in
// license.check, next to the key, as a digest of the key, the time of the last attempt and // license.check, next to the key, as a digest of the key, the time of the last attempt and
// the verdict. Fail-open: an unreachable server changes nothing. // the verdict. Fail-open: an unreachable server changes nothing.
// - The stored key can be removed (#370, `remove_license`): license.key, then the activation
// token, after this machine's slot is freed on the server when asked. license.check stays,
// so a revoked key removed then entered again stays revoked.
// //
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it. // CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
@ -239,11 +242,7 @@ fn store_license_in(
// license (#347 review). The same key entered again keeps its token. // license (#347 review). The same key entered again keeps its token.
let previous = fs::read_to_string(dir.join(LICENSE_FILE)).ok(); let previous = fs::read_to_string(dir.join(LICENSE_FILE)).ok();
if previous.as_deref().map(str::trim) != Some(key) { if previous.as_deref().map(str::trim) != Some(key) {
match fs::remove_file(dir.join(ACTIVATION_FILE)) { remove_activation_token_in(dir)?;
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)?; fs::write(dir.join(LICENSE_FILE), key).map_err(|_| LicenseError::Storage)?;
Ok(LicenseInfo { Ok(LicenseInfo {
@ -252,6 +251,17 @@ fn store_license_in(
}) })
} }
/// Delete `dir/activation.token`. A missing token is not an error; any other failure is
/// `Storage`. Shared by the entry of another key, a deactivation that freed this machine's
/// slot and the removal of the key (#370); meant for the stale token of #367 next.
pub(crate) fn remove_activation_token_in(dir: &Path) -> Result<(), LicenseError> {
match fs::remove_file(dir.join(ACTIVATION_FILE)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(LicenseError::Storage),
}
}
/// Persist a server-issued activation token (machine binding). The token is opaque to the /// Persist a server-issued activation token (machine binding). The token is opaque to the
/// caller — it must have been issued for this machine's derived identifier to be stored. /// caller — it must have been issued for this machine's derived identifier to be stored.
/// Errors cross the boundary as a serialized [`LicenseError`]. /// Errors cross the boundary as a serialized [`LicenseError`].
@ -1140,7 +1150,7 @@ fn drop_token_of_deactivated_machine_in(
_ => token_id.as_deref() == Some(deactivated), _ => token_id.as_deref() == Some(deactivated),
}; };
if stood_for_it { if stood_for_it {
let _ = fs::remove_file(dir.join(ACTIVATION_FILE)); let _ = remove_activation_token_in(dir);
} }
} }
@ -1190,6 +1200,123 @@ fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey
validate_activation_with_key(&token, ids, decoding_key).is_ok() validate_activation_with_key(&token, ids, decoding_key).is_ok()
} }
// === Removal of the stored key (#370) ==========================================================
/// Remove the stored license key from this computer (#370). `license.key` goes first, which is
/// what stops the revocation check from sending it, then the activation token; `license.check`
/// stays. With `release_slot`, this machine's slot is freed on the license server first (D2).
/// Errors cross the boundary as a serialized [`LicenseError`]: a release that failed removes
/// nothing, and when the server could not be reached or answered badly the front asks whether
/// to remove the key anyway, then calls again without `release_slot`.
#[tauri::command]
pub async fn remove_license(app: tauri::AppHandle, release_slot: bool) -> Result<(), String> {
remove_license_impl(&app, release_slot)
.await
.map_err(|e| license_error_to_string(&e))
}
async fn remove_license_impl(app: &tauri::AppHandle, release_slot: bool) -> Result<(), LicenseError> {
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
let decoding_key = embedded_decoding_key()?;
// Best effort on the OS id: unreadable, no token can stand for this machine, and the
// removal stays local (the card reads "Not activated" then, too).
let ids = local_machine_ids().ok();
remove_license_in(
&dir,
&api_base_url(),
release_slot,
ids.as_ref(),
&decoding_key,
unix_now(),
)
.await
}
/// The removal, without `AppHandle` so tests run it in a temporary directory and against a
/// mock server.
///
/// - With `release_slot`, [`release_this_machine_slot`] runs first. Its error is returned as
/// is and nothing is removed.
/// - `license.key`, then `activation.token`, each through a deletion that tolerates a missing
/// file. The first failure stops there with `Storage`: the token never goes while the key
/// stays.
/// - `license.check` stays. It is indexed by the key's digest, so it weighs on no other key,
/// and dropping it would give a revoked key, removed then entered again, its edition back
/// until the next check (decision 1 of #370).
/// - Without a key nothing is sent, nothing is written, and the call succeeds: a second
/// removal is a no-op, apart from a token a first one could not delete.
async fn remove_license_in(
dir: &Path,
api_base: &str,
release_slot: bool,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
if release_slot {
release_this_machine_slot(dir, api_base, ids, decoding_key, now).await?;
}
match fs::remove_file(dir.join(LICENSE_FILE)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err(LicenseError::Storage),
}
remove_activation_token_in(dir)
}
/// Free this machine's slot on the license server before its key is removed (#370, D2).
///
/// Nothing is sent without a stored key, for a key known as revoked (the server freed its
/// slots when it revoked it), nor without a token that activates this machine
/// ([`this_machine_token_id`]): as far as the client knows, there is no slot of this machine
/// to free then. The deactivation carries the token's own identifier, the raw one for an
/// activation made before #350, and the stored key.
///
/// `no_active_activation`, `license_not_found` and `license_revoked` (recorded for the key
/// sent, #365 D4) mean the slot is free: the removal goes on. Any other error is returned,
/// `network`, `server`, `rate_limit` and `invalid_response` among them. A key file that exists
/// but cannot be read is `Storage`: the release was asked and cannot be made.
async fn release_this_machine_slot(
dir: &Path,
api_base: &str,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
let key = match fs::read_to_string(dir.join(LICENSE_FILE)) {
Ok(key) => key,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err(LicenseError::Storage),
};
if is_revoked_in(dir, &key) {
return Ok(());
}
let Some(machine_id) = this_machine_token_id(dir, ids, decoding_key) else {
return Ok(());
};
// Not `deactivate_in`: it drops the token as soon as the slot is freed, and a key that
// then failed to go would stay without its token.
let answer = deactivate_idempotent(api_base, &key, &machine_id).await;
match note_revocation(dir, &key, answer, now) {
Ok(()) | Err(LicenseError::LicenseNotFound | LicenseError::LicenseRevoked) => Ok(()),
Err(e) => Err(e),
}
}
/// Identifier of the stored activation token when that token activates this machine: signed
/// by the license server, unexpired, and issued under one of this machine's identifiers, the
/// rule of [`activation_status_in`] that gives the card its "Activated" state. `None` for a
/// missing, unreadable, expired or forged token, for another machine's token, and without a
/// readable OS id.
fn this_machine_token_id(
dir: &Path,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
) -> Option<String> {
let ids = ids?;
stored_token_machine_id(dir, decoding_key).filter(|id| ids.names_this_machine(id))
}
/// Ask the license server whether the stored key was revoked (#365). The front calls it at /// Ask the license server whether the stored key was revoked (#365). The front calls it at
/// app start and after a key is entered (#373); the checks run one at a time /// app start and after a key is entered (#373); the checks run one at a time
/// ([`check_revocation_in_turn`]), and the 24 h guard and the fail-open rules live in /// ([`check_revocation_in_turn`]), and the 24 h guard and the fail-open rules live in
@ -2890,6 +3017,408 @@ mod tests {
assert!(!dir.0.join(CHECK_FILE).exists()); assert!(!dir.0.join(CHECK_FILE).exists());
} }
// === Removal of the stored key (#370) ======================================================
/// A data directory as a removal finds it: a stored key, this machine's activation token,
/// a state file about the key, and a file that is none of them.
fn removal_dir(name: &str, enc: &EncodingKey, key: &str) -> TestDir {
let dir = TestDir::new(name);
dir.write(LICENSE_FILE, key);
dir.write(ACTIVATION_FILE, activation_for(enc, THIS_DERIVED));
dir.write(CHECK_FILE, check_state(key, NOW - 60, false));
dir.write("profiles.json", "{}");
dir
}
/// Every entry of the directory with its contents (empty for a sub-directory), by name.
fn snapshot(dir: &TestDir) -> Vec<(String, Vec<u8>)> {
let mut entries: Vec<(String, Vec<u8>)> = fs::read_dir(&dir.0)
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
let contents = fs::read(entry.path()).unwrap_or_default();
(entry.file_name().into_string().unwrap(), contents)
})
.collect();
entries.sort();
entries
}
/// A server that answers nothing: any request it receives fails the test.
async fn no_request_expected(server: &mut mockito::ServerGuard) -> mockito::Mock {
server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await
}
/// A deactivation route answering `status` with `body`, expecting one request.
async fn deactivate_route(server: &mut mockito::ServerGuard, status: usize, body: &str) -> mockito::Mock {
server
.mock("POST", "/v1/licenses/deactivate")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await
}
#[test]
fn the_token_removal_tolerates_a_missing_token_and_names_any_other_failure() {
let dir = TestDir::new("remove_token_helper");
assert_eq!(remove_activation_token_in(&dir.0), Ok(()), "no token to remove");
assert_eq!(remove_activation_token_in(&dir.0.join("missing")), Ok(()), "no directory");
dir.write(ACTIVATION_FILE, "a token");
assert_eq!(remove_activation_token_in(&dir.0), Ok(()));
assert!(!dir.0.join(ACTIVATION_FILE).exists());
// A directory in place of the token: it cannot be deleted as a file.
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
assert_eq!(remove_activation_token_in(&dir.0), Err(LicenseError::Storage));
}
#[tokio::test]
async fn a_removal_drops_the_key_and_its_token_and_leaves_everything_else() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_files", &enc, &key);
let state = fs::read(dir.0.join(CHECK_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()));
any_request.assert_async().await;
assert_eq!(
snapshot(&dir),
vec![(CHECK_FILE.to_string(), state), ("profiles.json".to_string(), b"{}".to_vec())]
);
}
#[tokio::test]
async fn after_a_removal_the_edition_is_free_and_the_check_sends_nothing() {
// The last attempt is a day old: a key left on disk would be sent right away.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_then_check", &enc, &key);
dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, false));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW)
.await
.unwrap();
assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new()));
assert_eq!(
check_revocation_in(&dir.0, &server.url(), &dec, NOW).await,
RevocationCheck { outcome: RevocationOutcome::NoLicense, revoked: false }
);
any_request.assert_async().await;
}
#[tokio::test]
async fn a_revoked_key_removed_then_entered_again_stays_free() {
// Decision 1: `license.check` stays. Dropped, it would give the key its edition back
// until the next check, a way around the revocation open to anyone through a button.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_revoked_then_enter", &enc, &key);
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW)
.await
.unwrap();
assert!(store_license_in(&dir.0, &key, &dec).unwrap().revoked);
assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new()));
any_request.assert_async().await;
}
#[tokio::test]
async fn a_removal_without_a_key_succeeds_and_writes_nothing() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
for release_slot in [false, true] {
// The state of a key removed earlier, and nothing else.
let dir = TestDir::new("remove_no_key");
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let before = snapshot(&dir);
let result =
remove_license_in(&dir.0, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "release_slot {}", release_slot);
assert_eq!(snapshot(&dir), before, "release_slot {}", release_slot);
// A data directory that does not exist is not created.
let missing = dir.0.join("app-data");
let result =
remove_license_in(&missing, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "release_slot {}", release_slot);
assert!(!missing.exists(), "release_slot {}", release_slot);
}
any_request.assert_async().await;
}
#[tokio::test]
async fn a_removal_without_a_key_still_drops_a_token_left_behind() {
// What a removal whose token could not be deleted leaves: the next one completes it.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_orphan_token");
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()));
assert!(!dir.0.join(ACTIVATION_FILE).exists());
any_request.assert_async().await;
}
#[tokio::test]
async fn a_key_that_cannot_be_deleted_is_storage_and_its_token_stays() {
// Never the token gone while the key stays: the card would read "Not activated" next
// to a key the user still has.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_key_blocked");
// A directory in place of the key file: it cannot be deleted as a file.
fs::create_dir(dir.0.join(LICENSE_FILE)).unwrap();
let token = activation_for(&enc, THIS_DERIVED);
dir.write(ACTIVATION_FILE, &token);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
any_request.assert_async().await;
}
#[tokio::test]
async fn a_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone() {
// The key goes first: it is what the revocation check sends.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_token_blocked");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec![]));
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
assert!(!dir.0.join(LICENSE_FILE).exists());
any_request.assert_async().await;
}
#[tokio::test]
async fn the_release_carries_the_token_s_identifier_before_anything_is_removed() {
// D2: this machine's slot is freed first, under the identifier its token was issued
// for, the raw one for an activation made before #350. Read from the server's side,
// while the request is in flight: the key and the token are still on disk.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
for token_id in [THIS_DERIVED, THIS_RAW] {
let dir = removal_dir("remove_release", &enc, &key);
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
let (key_path, token_path) = (dir.0.join(LICENSE_FILE), dir.0.join(ACTIVATION_FILE));
let seen = std::sync::Arc::new(std::sync::Mutex::new(None::<(bool, bool)>));
let seen_by_server = seen.clone();
let sent_key = key.clone();
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/deactivate")
.match_request(move |request| {
*seen_by_server.lock().unwrap() = Some((key_path.exists(), token_path.exists()));
let Ok(raw) = request.body() else {
return false;
};
serde_json::from_slice::<serde_json::Value>(raw).ok()
== Some(serde_json::json!({
"product": "simpl-resultat",
"license_key": sent_key,
"machine_id": token_id,
}))
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.expect(1)
.create_async()
.await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "token {}", token_id);
route.assert_async().await;
assert_eq!(*seen.lock().unwrap(), Some((true, true)), "token {}", token_id);
assert!(!dir.0.join(LICENSE_FILE).exists(), "token {}", token_id);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "token {}", token_id);
}
}
#[tokio::test]
async fn a_key_that_cannot_be_deleted_after_the_release_keeps_its_token() {
// The release must not drop the token itself, as a deactivation from the machine list
// does: the key that then fails to go would stay without it.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = removal_dir("remove_released_key_blocked", &enc, &key);
let token = fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap();
let key_path = dir.0.join(LICENSE_FILE);
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/deactivate")
.match_request(move |_request| {
// The key has been read by now: a directory in its place cannot be deleted.
let _ = fs::remove_file(&key_path);
let _ = fs::create_dir(&key_path);
true
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.expect(1)
.create_async()
.await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
route.assert_async().await;
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
}
#[tokio::test]
async fn a_slot_already_free_lets_the_removal_go_on() {
// Error table, first row: the server no longer counts this machine, whatever the
// reason, and a revocation is recorded for the key that was sent (#365, D4).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let rows: Vec<(usize, &str, bool)> = vec![
(404, r#"{"error":{"code":"no_active_activation","message":"x"}}"#, false),
(404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, false),
(403, REVOKED_ENVELOPE, true),
];
for (status, body, revoked) in rows {
let dir = removal_dir("remove_slot_free", &enc, &key);
let mut server = mockito::Server::new_async().await;
let route = deactivate_route(&mut server, status, body).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "{} {}", status, body);
route.assert_async().await;
assert!(!dir.0.join(LICENSE_FILE).exists(), "{} {}", status, body);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{} {}", status, body);
let expected_state = if revoked {
serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": true })
} else {
serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW - 60, "revoked": false })
};
assert_eq!(stored_state(&dir), Some(expected_state), "{} {}", status, body);
}
}
#[tokio::test]
async fn a_release_that_fails_removes_nothing() {
// Error table, second row: the front asks whether to remove the key anyway. Any code
// outside the table is returned the same way (decision log of #370).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let rows: Vec<(usize, &str, LicenseError)> = vec![
(502, "<html>Bad Gateway</html>", LicenseError::Server { status: 502 }),
(
429,
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
LicenseError::RateLimit { retry_after_s: 42 },
),
(200, "<html>OK</html>", LicenseError::InvalidResponse),
(400, r#"{"error":{"code":"invalid_input","message":"x"}}"#, LicenseError::InvalidInput),
];
for (status, body, expected) in rows {
let dir = removal_dir("remove_release_failed", &enc, &key);
let before = snapshot(&dir);
let mut server = mockito::Server::new_async().await;
let route = deactivate_route(&mut server, status, body).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(expected), "{} {}", status, body);
route.assert_async().await;
assert_eq!(snapshot(&dir), before, "{} {}", status, body);
}
// Offline. Port 1 on loopback: nothing listens.
let dir = removal_dir("remove_release_offline", &enc, &key);
let before = snapshot(&dir);
let result = remove_license_in(&dir.0, "http://127.0.0.1:1", true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Network));
assert_eq!(snapshot(&dir), before);
}
#[tokio::test]
async fn the_release_sends_nothing_without_a_slot_of_this_machine_to_free() {
// Error table, third row: `release_slot` is ignored, the removal stays local.
let (enc, dec) = default_keys();
let (other_enc, _other_dec) = test_keys([7u8; 32]);
let key = base_license_with_features(&enc, vec![]);
let machine = this_machine();
// (case, release_slot, token file, key known as revoked, OS id readable)
let cases: Vec<(&str, bool, Option<String>, bool, bool)> = vec![
("release not asked", false, Some(activation_for(&enc, THIS_DERIVED)), false, true),
("no token", true, None, false, true),
("expired token of this machine", true, Some(expired_activation_for(&enc, THIS_DERIVED)), false, true),
("token of another machine", true, Some(activation_for(&enc, OTHER_DERIVED)), false, true),
("token signed by another key", true, Some(activation_for(&other_enc, THIS_DERIVED)), false, true),
("unreadable OS id", true, Some(activation_for(&enc, THIS_DERIVED)), false, false),
("key known as revoked", true, Some(activation_for(&enc, THIS_DERIVED)), true, true),
];
for (case, release_slot, token, revoked, readable_os_id) in cases {
let dir = TestDir::new("remove_nothing_sent");
dir.write(LICENSE_FILE, &key);
if let Some(token) = &token {
dir.write(ACTIVATION_FILE, token);
}
dir.write(CHECK_FILE, check_state(&key, NOW - 60, revoked));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let ids = readable_os_id.then_some(&machine);
let result = remove_license_in(&dir.0, &server.url(), release_slot, ids, &dec, NOW).await;
assert_eq!(result, Ok(()), "{}", case);
any_request.assert_async().await;
assert!(!dir.0.join(LICENSE_FILE).exists(), "{}", case);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}", case);
}
}
#[tokio::test]
async fn a_release_with_an_unreadable_key_is_storage_and_removes_nothing() {
// The release was asked and cannot be made: nothing goes. Without it, the key is not
// read at all, and it goes.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_unreadable_key");
dir.write(LICENSE_FILE, NOT_UTF8);
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
let before = snapshot(&dir);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let released = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(released, Err(LicenseError::Storage));
assert_eq!(snapshot(&dir), before);
let local = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(local, Ok(()));
assert_eq!(snapshot(&dir), Vec::new());
any_request.assert_async().await;
}
// === Dev override ========================================================================= // === Dev override =========================================================================
/// The dev override must be dead code in normal builds: even with /// The dev override must be dead code in normal builds: even with

View file

@ -454,6 +454,7 @@ pub fn run() {
commands::repair_migrations, commands::repair_migrations,
commands::validate_license_key, commands::validate_license_key,
commands::store_license, commands::store_license,
commands::remove_license,
commands::store_activation_token, commands::store_activation_token,
commands::read_license, commands::read_license,
commands::get_edition, commands::get_edition,

View file

@ -36,6 +36,7 @@ import {
initialLicenseMachinesState, initialLicenseMachinesState,
selectCanActivate, selectCanActivate,
selectCanDeactivate, selectCanDeactivate,
selectCanRemoveKey,
selectShowEmptyState, selectShowEmptyState,
type LicenseMachinesState, type LicenseMachinesState,
} from "../../hooks/useLicenseMachines"; } from "../../hooks/useLicenseMachines";
@ -63,9 +64,11 @@ function hookView(partial: Partial<LicenseMachinesState>, revoked = false) {
activate: vi.fn(), activate: vi.fn(),
deactivate: vi.fn(), deactivate: vi.fn(),
toggleList: vi.fn(), toggleList: vi.fn(),
removeKey: vi.fn(),
showEmptyState: selectShowEmptyState(state), showEmptyState: selectShowEmptyState(state),
canDeactivate: selectCanDeactivate(state), canDeactivate: selectCanDeactivate(state),
canActivate: selectCanActivate(state, revoked), canActivate: selectCanActivate(state, revoked),
canRemoveKey: selectCanRemoveKey(state),
}; };
} }
@ -322,6 +325,82 @@ describe("LicenseCard machines", () => {
}); });
}); });
// "Remove the key" shows for any stored key, valid or revoked (#370): `hasLicense` (a paid
// edition) would hide it for a revoked key, whose daily check is what the removal stops.
describe("LicenseCard key removal (#370)", () => {
const REMOVE = "license.removeKey.button";
const removeDisabled = /<button[^>]*disabled=""[^>]*>license\.removeKey\.button<\/button>/;
const enterKeyDisabled = /<button[^>]*disabled=""[^>]*>license\.enterKey<\/button>/;
const activateDisabled = /<button[^>]*disabled=""[^>]*>license\.activate<\/button>/;
const deactivateDisabled = /<button[^>]*disabled=""[^>]*>license\.machines\.deactivate<\/button>/;
it("is offered for a valid key", () => {
const html = render();
expect(html).toContain(REMOVE);
expect(html).not.toMatch(removeDisabled);
});
it("is offered for a revoked key in the Free edition", () => {
context = {
...context,
edition: "free",
revoked: true,
info: { ...(context.info as LicenseInfo), revoked: true },
};
machines = hookView({}, true);
const html = render();
expect(html).toContain(REMOVE);
expect(html).not.toMatch(removeDisabled);
});
it("is not offered without a stored key, account Premium included", () => {
context = { ...context, edition: "free", info: null };
expect(render()).not.toContain("license.removeKey");
context = { ...context, edition: "premium", info: null };
expect(render()).not.toContain("license.removeKey");
});
it("waits for a key being stored, an activation or a deactivation", () => {
context = { ...context, validating: true };
expect(render()).toMatch(removeDisabled);
context = { ...context, validating: false };
machines = hookView({ activation: notActivated, activating: true });
expect(render()).toMatch(removeDisabled);
machines = hookView({ ...loadedList([machine("a")]), deactivatingId: "a" });
expect(render()).toMatch(removeDisabled);
});
it("while it runs, says so, and key entry, Activate and Deactivate wait", () => {
machines = hookView({ activation: notActivated, removing: true });
let html = render();
expect(html).toContain("license.removeKey.removing");
expect(html).not.toContain(REMOVE);
expect(html).toMatch(enterKeyDisabled);
expect(html).toMatch(activateDisabled);
machines = hookView({ ...loadedList([machine("a")]), removing: true });
html = render();
expect(html).toMatch(deactivateDisabled);
// Idle again: nothing waits on it.
machines = hookView({ ...loadedList([machine("a")]), activation: notActivated });
html = render();
expect(html).not.toMatch(enterKeyDisabled);
expect(html).not.toMatch(activateDisabled);
expect(html).not.toMatch(deactivateDisabled);
});
it("names a failed removal with its reason", () => {
machines = hookView({ removeError: parseLicenseError('{"code":"storage"}') });
const html = render();
expect(html).toContain("license.removeKey.failed[reason=license.errors.storage]");
expect(html.split("license.removeKey.failed")).toHaveLength(2);
});
});
describe("LicenseCard dates", () => { describe("LicenseCard dates", () => {
it("shows no expiry date for a 100-year key", () => { it("shows no expiry date for a 100-year key", () => {
context = { context = {

View file

@ -34,9 +34,11 @@ function LicenseCardBody() {
activate, activate,
deactivate, deactivate,
toggleList, toggleList,
removeKey,
showEmptyState, showEmptyState,
canDeactivate, canDeactivate,
canActivate, canActivate,
canRemoveKey,
} = useLicenseMachines(hasLicense); } = useLicenseMachines(hasLicense);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA"; const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
@ -134,8 +136,9 @@ function LicenseCardBody() {
type="button" type="button"
onClick={() => setShowInput(true)} onClick={() => setShowInput(true)}
// An activation in flight writes the token of the key it read: a key stored // 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). // meanwhile would end up next to the previous key's token (#364 review). A removal
disabled={machines.activating} // 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" 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")}
@ -150,6 +153,27 @@ function LicenseCardBody() {
{t("license.purchase")} {t("license.purchase")}
</button> </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> </div>
)} )}

View file

@ -8,15 +8,22 @@ import {
selectShowEmptyState, selectShowEmptyState,
selectCanDeactivate, selectCanDeactivate,
selectCanActivate, selectCanActivate,
selectCanRemoveKey,
selectRemoveLicenseState,
releasesSlot,
removeLicenseConfirmKey,
shouldLoadOnOpen, shouldLoadOnOpen,
performActivate, performActivate,
performDeactivate, performDeactivate,
performRemoveLicense,
loadMachines, loadMachines,
loadActivationStatus, loadActivationStatus,
useLicenseMachines, useLicenseMachines,
type LicenseMachinesAction, type LicenseMachinesAction,
type LicenseMachinesDeps, type LicenseMachinesDeps,
type LicenseMachinesState, type LicenseMachinesState,
type RemoveLicenseDeps,
type RemoveLicenseState,
} from "./useLicenseMachines"; } from "./useLicenseMachines";
import { import {
parseLicenseError, parseLicenseError,
@ -31,6 +38,9 @@ import {
const context = { refresh: vi.fn(async () => {}), revoked: false }; const context = { refresh: vi.fn(async () => {}), revoked: false };
vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context })); vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
// What the hook writes to the in-app log (the removal of the key, #370).
const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn() }));
vi.mock("../services/logService", () => log);
const err = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseError => const err = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseError =>
parseLicenseError(JSON.stringify({ code, ...extra })); parseLicenseError(JSON.stringify({ code, ...extra }));
@ -515,4 +525,295 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
await view.activate(); await view.activate();
expect(context.refresh).toHaveBeenCalledTimes(1); expect(context.refresh).toHaveBeenCalledTimes(1);
}); });
it("removes the key through remove_license, with the context's revocation and refresh", async () => {
// The activation status is never loaded here (no effects): not activated, so no release.
vi.mocked(invoke).mockResolvedValue(null);
log.logInfo.mockClear();
context.revoked = true;
const confirm = vi.fn(() => true);
const view = renderHook();
expect(view.canRemoveKey).toBe(true);
await view.removeKey({ confirm, t: echo });
expect(confirm).toHaveBeenCalledWith("license.removeKey.confirmRevoked");
expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false });
expect(context.refresh).toHaveBeenCalledTimes(1);
expect(log.logInfo).toHaveBeenCalledWith("License: the stored key was removed");
});
it("takes a key that is not revoked as such", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const confirm = vi.fn(() => false);
const view = renderHook();
await view.removeKey({ confirm, t: echo });
expect(confirm).toHaveBeenCalledWith("license.removeKey.confirmValid");
expect(invoke).not.toHaveBeenCalled();
});
});
// The removal of the stored key (#370). The fake `t` echoes the key and its options, so each
// assertion names the message the user would read.
function echo(key: string, options?: Record<string, unknown>): string {
return options
? `${key}[${Object.entries(options).map(([name, value]) => `${name}=${String(value)}`).join(",")}]`
: key;
}
describe("licenseMachinesReducer — removal of the key", () => {
it("marks the removal running, clearing the previous failure", () => {
const failed = reduce([{ type: "REMOVE_START" }, { type: "REMOVE_ERROR", error: err("storage") }]);
expect(failed.removing).toBe(false);
expect(failed.removeError?.code).toBe("storage");
const again = reduce([{ type: "REMOVE_START" }], failed);
expect(again.removing).toBe(true);
expect(again.removeError).toBeNull();
});
it("ends without an error", () => {
const ended = reduce([{ type: "REMOVE_START" }, { type: "REMOVE_END" }]);
expect(ended.removing).toBe(false);
expect(ended.removeError).toBeNull();
});
});
describe("selectors — removal of the key", () => {
const removing = reduce([{ type: "REMOVE_START" }]);
it("lets the removal start only when nothing else runs", () => {
expect(selectCanRemoveKey(initialLicenseMachinesState)).toBe(true);
expect(selectCanRemoveKey(removing)).toBe(false);
expect(selectCanRemoveKey(reduce([{ type: "ACTIVATE_START" }]))).toBe(false);
expect(selectCanRemoveKey(reduce([{ type: "DEACTIVATE_START", machineId: "a" }]))).toBe(false);
});
it("makes Activate and Deactivate wait for a removal", () => {
// An activation would write a token next to no key; a deactivation may race the release.
expect(selectCanActivate(removing, false)).toBe(false);
expect(selectCanDeactivate(removing)).toBe(false);
expect(selectCanActivate(reduce([{ type: "REMOVE_END" }], removing), false)).toBe(true);
expect(selectCanDeactivate(reduce([{ type: "REMOVE_END" }], removing))).toBe(true);
});
it("reads the activation loaded last, none before it is loaded", () => {
const activated = (is_activated: boolean) =>
reduce([{ type: "ACTIVATION_STATUS", activation: { is_activated } }]);
expect(selectRemoveLicenseState(initialLicenseMachinesState, false)).toEqual({
isActivated: false,
revoked: false,
});
expect(selectRemoveLicenseState(activated(false), false)).toEqual({ isActivated: false, revoked: false });
expect(selectRemoveLicenseState(activated(true), false)).toEqual({ isActivated: true, revoked: false });
expect(selectRemoveLicenseState(activated(true), true)).toEqual({ isActivated: true, revoked: true });
});
});
describe("what a removal asks for, by state (D2, D3)", () => {
// (state, confirmation, slot released first)
const rows: [RemoveLicenseState, string, boolean][] = [
[{ isActivated: false, revoked: false }, "license.removeKey.confirmValid", false],
[{ isActivated: true, revoked: false }, "license.removeKey.confirmActivated", true],
[{ isActivated: false, revoked: true }, "license.removeKey.confirmRevoked", false],
// Under an account Premium, a revoked key next to a token of this machine: the server
// freed its slots when it revoked it.
[{ isActivated: true, revoked: true }, "license.removeKey.confirmRevoked", false],
];
it.each(rows)("%o asks %s", (state, confirmation, release) => {
expect(removeLicenseConfirmKey(state)).toBe(confirmation);
expect(releasesSlot(state)).toBe(release);
});
});
describe("performRemoveLicense", () => {
const removed: LicenseResult<void> = { ok: true, value: undefined };
const failure = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseResult<void> => ({
ok: false,
error: err(code, extra),
});
/** Answers the confirmations in order, then the calls of remove_license in order. */
function removalHarness(answers: boolean[], results: LicenseResult<void>[]) {
let state = initialLicenseMachinesState;
// Confirmations, calls and reloads in the order they happen.
const events: string[] = [];
const confirmations = [...answers];
const outcomes = [...results];
const deps: RemoveLicenseDeps = {
confirm: vi.fn((message: string) => {
events.push(`confirm ${message}`);
return confirmations.shift() ?? false;
}),
t: echo,
removeLicense: vi.fn(async (releaseSlot: boolean) => {
events.push(`remove(${releaseSlot})`);
return outcomes.shift() ?? removed;
}),
refresh: vi.fn(async () => {
events.push("refresh");
}),
logInfo: vi.fn(),
logWarn: vi.fn(),
dispatch: (action) => {
state = licenseMachinesReducer(state, action);
events.push(action.type);
},
};
return { deps, events, getState: () => state };
}
const valid: RemoveLicenseState = { isActivated: false, revoked: false };
const activated: RemoveLicenseState = { isActivated: true, revoked: false };
const revoked: RemoveLicenseState = { isActivated: false, revoked: true };
it("calls nothing when the confirmation is refused", async () => {
const { deps, events, getState } = removalHarness([false], []);
await performRemoveLicense(deps, activated);
expect(events).toEqual(["confirm license.removeKey.confirmActivated"]);
expect(deps.removeLicense).not.toHaveBeenCalled();
expect(deps.refresh).not.toHaveBeenCalled();
expect(deps.logInfo).not.toHaveBeenCalled();
expect(deps.logWarn).not.toHaveBeenCalled();
expect(getState()).toEqual(initialLicenseMachinesState);
});
it("removes a valid key, logs it, then reloads the license context", async () => {
const { deps, events, getState } = removalHarness([true], [removed]);
await performRemoveLicense(deps, valid);
expect(events).toEqual([
"confirm license.removeKey.confirmValid",
"REMOVE_START",
"remove(false)",
"refresh",
"REMOVE_END",
]);
expect(deps.logInfo).toHaveBeenCalledTimes(1);
expect(deps.logInfo).toHaveBeenCalledWith("License: the stored key was removed");
expect(deps.logWarn).not.toHaveBeenCalled();
expect(getState().removing).toBe(false);
expect(getState().removeError).toBeNull();
});
it("frees the slot of an activated machine first", async () => {
const { deps, events } = removalHarness([true], [removed]);
await performRemoveLicense(deps, activated);
expect(events).toEqual([
"confirm license.removeKey.confirmActivated",
"REMOVE_START",
"remove(true)",
"refresh",
"REMOVE_END",
]);
expect(deps.logInfo).toHaveBeenCalledWith(
"License: the stored key was removed, after this machine's slot was freed on the license server",
);
});
it("removes a revoked key without a release", async () => {
const { deps, events } = removalHarness([true], [removed]);
await performRemoveLicense(deps, revoked);
expect(events.slice(0, 3)).toEqual([
"confirm license.removeKey.confirmRevoked",
"REMOVE_START",
"remove(false)",
]);
});
// Error table, second row: the server could not be reached or answered badly. Rust removed
// nothing; the second confirmation names the reason and warns that the slot stays occupied.
const retryable: [LicenseErrorCode, Record<string, unknown>, string][] = [
["network", {}, "license.errors.network"],
["server", { status: 502 }, "license.errors.server"],
["rate_limit", { retry_after_s: 42 }, "license.errors.rateLimit[count=42]"],
["invalid_response", {}, "license.errors.invalidResponse"],
];
it.each(retryable)(
"after a release that failed with %s, asks again and removes the key without it",
async (code, extra, reason) => {
const { deps, events, getState } = removalHarness([true, true], [failure(code, extra), removed]);
await performRemoveLicense(deps, activated);
expect(events).toEqual([
"confirm license.removeKey.confirmActivated",
"REMOVE_START",
"remove(true)",
`confirm license.removeKey.confirmWithoutRelease[reason=${reason}]`,
"remove(false)",
"refresh",
"REMOVE_END",
]);
expect(deps.logWarn).toHaveBeenCalledTimes(1);
expect(deps.logWarn).toHaveBeenCalledWith(
"License: this machine's slot could not be freed on the license server",
code,
);
expect(deps.logInfo).toHaveBeenCalledTimes(1);
expect(deps.logInfo).toHaveBeenCalledWith(
"License: the stored key was removed; this machine's slot stays occupied on the license server",
);
expect(getState().removeError).toBeNull();
},
);
it.each(retryable)("after a release that failed with %s, keeps the key when asked to", async (code, extra) => {
const { deps, events, getState } = removalHarness([true, false], [failure(code, extra)]);
await performRemoveLicense(deps, activated);
expect(events.slice(-2)).toEqual([expect.stringContaining("confirmWithoutRelease"), "REMOVE_END"]);
expect(deps.removeLicense).toHaveBeenCalledTimes(1);
expect(deps.refresh).not.toHaveBeenCalled();
expect(deps.logInfo).not.toHaveBeenCalled();
expect(getState().removing).toBe(false);
expect(getState().removeError).toBeNull();
});
// Every other failure is named on the card, with no "remove anyway": nothing to drop.
const named: LicenseErrorCode[] = [
"storage",
"invalid_input",
"license_expired",
"machine_limit_reached",
"no_license_stored",
"internal",
"unknown",
];
it.each(named)("names a failure with %s, without asking again", async (code) => {
const { deps, events, getState } = removalHarness([true], [failure(code)]);
await performRemoveLicense(deps, activated);
expect(events).toEqual([
"confirm license.removeKey.confirmActivated",
"REMOVE_START",
"remove(true)",
"REMOVE_ERROR",
]);
expect(getState().removeError?.code).toBe(code);
expect(getState().removing).toBe(false);
expect(deps.refresh).not.toHaveBeenCalled();
expect(deps.logInfo).not.toHaveBeenCalled();
expect(deps.logWarn).toHaveBeenCalledWith("License: the stored key could not be removed", code);
});
it("never asks again when no release was asked", async () => {
// Without a release Rust sends nothing; a network code then means nothing to drop.
const { deps, events, getState } = removalHarness([true, true], [failure("network")]);
await performRemoveLicense(deps, valid);
expect(events).toEqual([
"confirm license.removeKey.confirmValid",
"REMOVE_START",
"remove(false)",
"REMOVE_ERROR",
]);
expect(getState().removeError?.code).toBe("network");
});
it("names a removal that fails after the slot was given up", async () => {
const { deps, events, getState } = removalHarness([true, true], [failure("network"), failure("storage")]);
await performRemoveLicense(deps, activated);
expect(events.slice(-2)).toEqual(["remove(false)", "REMOVE_ERROR"]);
expect(getState().removeError?.code).toBe("storage");
expect(deps.refresh).not.toHaveBeenCalled();
expect(deps.logWarn).toHaveBeenLastCalledWith("License: the stored key could not be removed", "storage");
});
}); });

View file

@ -4,14 +4,18 @@ import {
deactivateMachine, deactivateMachine,
listActivatedMachines, listActivatedMachines,
getActivationStatus, getActivationStatus,
removeLicense,
type ActivationStatus, type ActivationStatus,
type LicenseError, type LicenseError,
type LicenseErrorCode,
type LicenseResult, type LicenseResult,
type MachineInfo, type MachineInfo,
} from "../services/licenseService"; } from "../services/licenseService";
import { logInfo, logWarn } from "../services/logService";
import { useLicenseContext } from "../contexts/LicenseContext"; import { useLicenseContext } from "../contexts/LicenseContext";
// Machine activation state of the License card (issue #270). // Machine activation state of the License card (issue #270), and the removal of the
// stored key (#370), which frees this machine's slot first when it is activated.
// //
// The license server allows 5 requests per minute and per IP, shared by // The license server allows 5 requests per minute and per IP, shared by
// activate, verify and deactivate. The machine-limit path (activate → 409 → // activate, verify and deactivate. The machine-limit path (activate → 409 →
@ -24,7 +28,8 @@ import { useLicenseContext } from "../contexts/LicenseContext";
// command that received it (#365): this hook only reloads the LicenseContext, so the // command that received it (#365): this hook only reloads the LicenseContext, so the
// edition (Free) and the card follow. The revocation check, made at app start and after // edition (Free) and the card follow. The revocation check, made at app start and after
// a key is entered (#373), shares that budget: with it, the machine-limit path reaches // a key is entered (#373), shares that budget: with it, the machine-limit path reaches
// the 5 requests of the first minute. // the 5 requests of the first minute. Removing the key of an activated machine spends one
// deactivate call (#370).
export interface LicenseMachinesState { export interface LicenseMachinesState {
activation: ActivationStatus | null; activation: ActivationStatus | null;
@ -39,6 +44,13 @@ export interface LicenseMachinesState {
activationError: LicenseError | null; activationError: LicenseError | null;
/** Machine being deactivated; every Deactivate button is disabled meanwhile. */ /** Machine being deactivated; every Deactivate button is disabled meanwhile. */
deactivatingId: string | null; deactivatingId: string | null;
/**
* The stored key is being removed (#370), its slot freed first when asked: the card's
* other actions wait, since each reads or writes the key or the token it removes.
*/
removing: boolean;
/** Error of the last removal of the key, named on the card. */
removeError: LicenseError | null;
} }
export type LicenseMachinesAction = export type LicenseMachinesAction =
@ -52,7 +64,10 @@ export type LicenseMachinesAction =
| { type: "ACTIVATE_ERROR"; error: LicenseError } | { type: "ACTIVATE_ERROR"; error: LicenseError }
| { type: "DEACTIVATE_START"; machineId: string } | { type: "DEACTIVATE_START"; machineId: string }
| { type: "DEACTIVATE_DONE"; machineId: string } | { type: "DEACTIVATE_DONE"; machineId: string }
| { type: "DEACTIVATE_ERROR"; error: LicenseError }; | { type: "DEACTIVATE_ERROR"; error: LicenseError }
| { type: "REMOVE_START" }
| { type: "REMOVE_ERROR"; error: LicenseError }
| { type: "REMOVE_END" };
export const initialLicenseMachinesState: LicenseMachinesState = { export const initialLicenseMachinesState: LicenseMachinesState = {
activation: null, activation: null,
@ -64,6 +79,8 @@ export const initialLicenseMachinesState: LicenseMachinesState = {
activating: false, activating: false,
activationError: null, activationError: null,
deactivatingId: null, deactivatingId: null,
removing: false,
removeError: null,
}; };
export function licenseMachinesReducer( export function licenseMachinesReducer(
@ -122,6 +139,12 @@ export function licenseMachinesReducer(
}; };
case "DEACTIVATE_ERROR": case "DEACTIVATE_ERROR":
return { ...state, deactivatingId: null, listError: action.error }; return { ...state, deactivatingId: null, listError: action.error };
case "REMOVE_START":
return { ...state, removing: true, removeError: null };
case "REMOVE_ERROR":
return { ...state, removing: false, removeError: action.error };
case "REMOVE_END":
return { ...state, removing: false };
} }
} }
@ -133,24 +156,40 @@ export function selectShowEmptyState(state: LicenseMachinesState): boolean {
} }
/** /**
* Deactivate buttons are live only when no deactivation and no list reload is running. A * Deactivate buttons are live only when no deactivation, no list reload and no removal of
* reload (e.g. the one a 409 triggers over an already loaded list) returns the server's * the key is running. A reload (e.g. the one a 409 triggers over an already loaded list)
* list as of its request: a machine deactivated meanwhile would reappear in it. * returns the server's list as of its request: a machine deactivated meanwhile would
* reappear in it. A removal may be freeing this machine's slot itself (#370).
*/ */
export function selectCanDeactivate(state: LicenseMachinesState): boolean { export function selectCanDeactivate(state: LicenseMachinesState): boolean {
return state.deactivatingId === null && !state.listLoading; return state.deactivatingId === null && !state.listLoading && !state.removing;
} }
/** /**
* The Activate button is live only when the key is not revoked and no activation, no * The Activate button is live only when the key is not revoked and no activation, no
* list reload and no deactivation is running. A revoked key gets a 403 on every attempt, * list reload, no deactivation and no removal of the key is running. A revoked key gets a
* each one spending the shared rate budget. A verify answer landing after a successful * 403 on every attempt, each one spending the shared rate budget. A verify answer landing
* activation would mark as fresh a list that no longer includes this machine; an * after a successful activation would mark as fresh a list that no longer includes this
* activation racing a deactivation could clear the machine-limit message and spend the * machine; an activation racing a deactivation could clear the machine-limit message and
* rate budget. * spend the rate budget; one racing a removal would write a token next to no key (#370).
*/ */
export function selectCanActivate(state: LicenseMachinesState, revoked: boolean): boolean { export function selectCanActivate(state: LicenseMachinesState, revoked: boolean): boolean {
return !revoked && !state.activating && !state.listLoading && state.deactivatingId === null; return (
!revoked &&
!state.activating &&
!state.listLoading &&
state.deactivatingId === null &&
!state.removing
);
}
/**
* "Remove the key" is live only when no removal, no activation and no deactivation is
* running (#370): an activation writes the token of the key it read, and a deactivation
* drops the token of the slot it freed. The card also waits for a key being stored.
*/
export function selectCanRemoveKey(state: LicenseMachinesState): boolean {
return !state.removing && !state.activating && state.deactivatingId === null;
} }
/** Whether opening the list (from `state`, before the toggle) should load it. */ /** Whether opening the list (from `state`, before the toggle) should load it. */
@ -229,9 +268,128 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
await deps.refresh(); await deps.refresh();
} }
// -----------------------------------------------------------------------------
// Removal of the stored key (#370)
// -----------------------------------------------------------------------------
/** What a removal needs to know about the stored key. */
export interface RemoveLicenseState {
/** This machine is activated for the stored key, as `get_activation_status` last said. */
isActivated: boolean;
/** The license server revoked the stored key (#365). */
revoked: boolean;
}
/** /**
* Machine list and activation of the License card. `enabled` is false in the * What a removal started now knows: the activation status loaded last, none (not activated)
* free edition, where the card shows no machine section. * before it is loaded, and the revocation the license context holds.
*/
export function selectRemoveLicenseState(
state: LicenseMachinesState,
revoked: boolean,
): RemoveLicenseState {
return { isActivated: state.activation?.is_activated === true, revoked };
}
/**
* Whether the removal asks Rust to free this machine's slot first (D2): an activated machine
* whose key is not revoked. The server freed every slot of a revoked key when it revoked it.
* Rust checks the token and the revocation again before it sends anything.
*/
export function releasesSlot(state: RemoveLicenseState): boolean {
return state.isActivated && !state.revoked;
}
/**
* The first confirmation says what the removal does in this state (D3): a revoked key stops
* being sent to the license server; a valid key locks the modules of its edition, the data
* staying; an activated machine frees its slot first.
*/
export function removeLicenseConfirmKey(state: RemoveLicenseState): string {
if (state.revoked) return "license.removeKey.confirmRevoked";
return state.isActivated ? "license.removeKey.confirmActivated" : "license.removeKey.confirmValid";
}
/**
* Failures of the slot release after which the key may still be removed, the slot staying
* occupied: the license server could not be reached, refused for now, or answered badly
* (error table of #370). Rust returns them with nothing removed. Any other failure is named
* on the card, without "remove anyway".
*/
export const RELEASE_RETRY_CODES: readonly LicenseErrorCode[] = [
"network",
"server",
"rate_limit",
"invalid_response",
];
/** The in-app log lines of a removal: one per removal, one per release or removal that failed. */
export const REMOVE_LOG = {
removed: "License: the stored key was removed",
released: "License: the stored key was removed, after this machine's slot was freed on the license server",
slotKept: "License: the stored key was removed; this machine's slot stays occupied on the license server",
releaseFailed: "License: this machine's slot could not be freed on the license server",
failed: "License: the stored key could not be removed",
} as const;
/** The card's side of a removal: its confirmation dialog and its translations. */
export interface RemoveLicenseUi {
/** `window.confirm` in the card: true to go on. */
confirm: (message: string) => boolean;
/** `t` of react-i18next. */
t: (key: string, options?: Record<string, unknown>) => string;
}
export interface RemoveLicenseDeps extends RemoveLicenseUi {
removeLicense: (releaseSlot: boolean) => Promise<LicenseResult<void>>;
refresh: () => Promise<void>;
logInfo: (...args: unknown[]) => void;
logWarn: (...args: unknown[]) => void;
dispatch: (action: LicenseMachinesAction) => void;
}
/**
* Remove the stored key, after a confirmation that says what that does in this state (#370).
* A refused confirmation calls nothing. With this machine activated, Rust frees its slot
* first; when the license server cannot be reached or answers badly, nothing is removed and a
* second confirmation names the reason and asks whether to remove the key anyway, the slot
* staying occupied. Any other failure is named on the card. Each removal, and each release or
* removal that failed, is written to the in-app log. After a removal the license context is
* reloaded: the card remounts without a key, and the revocation check has nothing to send.
*/
export async function performRemoveLicense(
deps: RemoveLicenseDeps,
state: RemoveLicenseState,
): Promise<void> {
if (!deps.confirm(deps.t(removeLicenseConfirmKey(state)))) return;
deps.dispatch({ type: "REMOVE_START" });
const releaseSlot = releasesSlot(state);
let result = await deps.removeLicense(releaseSlot);
let removedLog: string = releaseSlot ? REMOVE_LOG.released : REMOVE_LOG.removed;
if (!result.ok && releaseSlot && RELEASE_RETRY_CODES.includes(result.error.code)) {
deps.logWarn(REMOVE_LOG.releaseFailed, result.error.code);
const reason = deps.t(result.error.i18nKey, result.error.params);
if (!deps.confirm(deps.t("license.removeKey.confirmWithoutRelease", { reason }))) {
deps.dispatch({ type: "REMOVE_END" });
return;
}
result = await deps.removeLicense(false);
removedLog = REMOVE_LOG.slotKept;
}
if (!result.ok) {
deps.logWarn(REMOVE_LOG.failed, result.error.code);
deps.dispatch({ type: "REMOVE_ERROR", error: result.error });
return;
}
deps.logInfo(removedLog);
await deps.refresh();
deps.dispatch({ type: "REMOVE_END" });
}
/**
* Machine list, activation and removal of the key of the License card. `enabled` is false
* in the free edition, where the card shows no machine section and the activation status is
* not loaded; the removal stays available (a revoked key resolves to Free).
*/ */
export function useLicenseMachines(enabled: boolean) { export function useLicenseMachines(enabled: boolean) {
const { refresh, revoked } = useLicenseContext(); const { refresh, revoked } = useLicenseContext();
@ -262,6 +420,15 @@ export function useLicenseMachines(enabled: boolean) {
dispatch({ type: "TOGGLE_LIST" }); dispatch({ type: "TOGGLE_LIST" });
if (load) void loadMachines(deps); if (load) void loadMachines(deps);
}, [state, deps]); }, [state, deps]);
// The card passes its confirmation dialog and its translations (#370).
const removeKey = useCallback(
(ui: RemoveLicenseUi) =>
performRemoveLicense(
{ ...ui, removeLicense, refresh: deps.refresh, logInfo, logWarn, dispatch: deps.dispatch },
selectRemoveLicenseState(state, revoked),
),
[state, revoked, deps],
);
return { return {
state, state,
@ -270,8 +437,10 @@ export function useLicenseMachines(enabled: boolean) {
activate, activate,
deactivate, deactivate,
toggleList, toggleList,
removeKey,
showEmptyState: selectShowEmptyState(state), showEmptyState: selectShowEmptyState(state),
canDeactivate: selectCanDeactivate(state), canDeactivate: selectCanDeactivate(state),
canActivate: selectCanActivate(state, revoked), canActivate: selectCanActivate(state, revoked),
canRemoveKey: selectCanRemoveKey(state),
}; };
} }

View file

@ -1251,7 +1251,7 @@
"Modules unlock immediately — no reinstall or restart needed" "Modules unlock immediately — no reinstall or restart needed"
], ],
"tips": [ "tips": [
"Locking is never destructive: if your edition goes down (expired, invalid or revoked key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered", "Locking is never destructive: if your edition goes down (expired, invalid, revoked or removed key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked", "On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer", "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", "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",
@ -1260,7 +1260,8 @@
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier", "Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot", "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, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back", "If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back",
"When a key is stored, the app asks the license server whether it was revoked: at startup and as soon as you enter a key, at most once every 24 hours for the same key, sending the product and the key, nothing else. The check of an entered key runs in the background: the License card answers right away, and a revoked key shows its edition for a second or two before the app goes back to Free. Without a stored key no request is sent; if the server does not answer, nothing changes. Each check is written to the logs (Settings → System). The check goes on after a revocation, as long as the key stays stored: a correction made by the license server is thus picked up at the next start. Only storing another key stops the previous one from being sent" "When a key is stored, the app asks the license server whether it was revoked: at startup and as soon as you enter a key, at most once every 24 hours for the same key, sending the product and the key, nothing else. The check of an entered key runs in the background: the License card answers right away, and a revoked key shows its edition for a second or two before the app goes back to Free. Without a stored key no request is sent; if the server does not answer, nothing changes. Each check is written to the logs (Settings → System). The check goes on after a revocation, as long as the key stays stored: a correction made by the license server is thus picked up at the next start. To stop this check, remove the key (\"Remove the key\" in the License card); storing another key also stops the previous one from being sent",
"\"Remove the key\" (License card) removes your key from this computer, after a confirmation: the modules of your edition lock and your data is kept. If this machine is activated, its slot is freed on the license server first; offline, the app asks whether to remove the key anyway, and the slot then stays occupied until you free it from another computer where the key is stored. A revoked key removed then entered again stays revoked"
] ]
} }
}, },
@ -1328,6 +1329,15 @@
"keyPlaceholder": "SR-BASE-...", "keyPlaceholder": "SR-BASE-...",
"activate": "Activate", "activate": "Activate",
"purchase": "Buy Simpl'Result", "purchase": "Buy Simpl'Result",
"removeKey": {
"button": "Remove the key",
"removing": "Removing...",
"confirmValid": "Remove the license key from this computer?\n\nThe modules of your edition lock and the app goes back to the Free edition. Your data is kept and reappears as soon as a valid key is entered.",
"confirmActivated": "Remove the license key from this computer?\n\nThis machine's slot is freed on the license server first. The modules of your edition lock and the app goes back to the Free edition; your data is kept and reappears as soon as a valid key is entered.",
"confirmRevoked": "Remove the revoked key from this computer?\n\nThe app stops sending it to the license server to be checked (at most once every 24 hours). Your data is kept.",
"confirmWithoutRelease": "This machine's slot could not be freed. {{reason}}\n\nRemove the key anyway? The slot stays occupied on the license server; you can free it from the machine list on another computer where the key is stored.",
"failed": "The key was not removed. {{reason}}"
},
"editions": { "editions": {
"free": "Free", "free": "Free",
"base": "Base", "base": "Base",

View file

@ -1251,7 +1251,7 @@
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire" "Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire"
], ],
"tips": [ "tips": [
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée", "Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés", "En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste", "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", "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",
@ -1260,7 +1260,8 @@
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système", "L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
"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", "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, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition", "Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
"Quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente" "Quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Pour arrêter cette vérification, retirez la clé (« Retirer la clé » dans la carte Licence) ; l'enregistrement d'une autre clé arrête aussi l'envoi de la précédente",
"« Retirer la clé » (carte Licence) retire votre clé de cet ordinateur, après confirmation : les modules de votre édition se verrouillent et vos données sont conservées. Si cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; hors ligne, l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis un autre ordinateur où la clé est enregistrée. Une clé révoquée retirée puis entrée de nouveau reste révoquée"
] ]
} }
}, },
@ -1328,6 +1329,15 @@
"keyPlaceholder": "SR-BASE-...", "keyPlaceholder": "SR-BASE-...",
"activate": "Activer", "activate": "Activer",
"purchase": "Acheter Simpl'Résultat", "purchase": "Acheter Simpl'Résultat",
"removeKey": {
"button": "Retirer la clé",
"removing": "Retrait...",
"confirmValid": "Retirer la clé de licence de cet ordinateur ?\n\nLes modules de votre édition se verrouillent et l'application repasse en édition Gratuite. Vos données sont conservées et réapparaissent dès qu'une clé valide est entrée.",
"confirmActivated": "Retirer la clé de licence de cet ordinateur ?\n\nLa place de cette machine est d'abord libérée sur le serveur de licences. Les modules de votre édition se verrouillent et l'application repasse en édition Gratuite ; vos données sont conservées et réapparaissent dès qu'une clé valide est entrée.",
"confirmRevoked": "Retirer la clé révoquée de cet ordinateur ?\n\nL'application cesse de l'envoyer au serveur de licences pour vérification (au plus une fois par 24 heures). Vos données sont conservées.",
"confirmWithoutRelease": "La place de cette machine n'a pas pu être libérée. {{reason}}\n\nRetirer la clé quand même ? La place restera occupée sur le serveur de licences ; vous pourrez la libérer depuis la liste des machines, sur un autre ordinateur où la clé est enregistrée.",
"failed": "La clé n'a pas été retirée. {{reason}}"
},
"editions": { "editions": {
"free": "Gratuite", "free": "Gratuite",
"base": "Base", "base": "Base",

View file

@ -7,6 +7,7 @@ import {
checkLicenseRevocation, checkLicenseRevocation,
deactivateMachine, deactivateMachine,
listActivatedMachines, listActivatedMachines,
removeLicense,
type LicenseErrorCode, type LicenseErrorCode,
} from "./licenseService"; } from "./licenseService";
import fr from "../i18n/locales/fr.json"; import fr from "../i18n/locales/fr.json";
@ -139,6 +140,23 @@ describe("license command wrappers", () => {
await expect(checkLicenseRevocation()).resolves.toEqual({ outcome: "revoked", revoked: true }); await expect(checkLicenseRevocation()).resolves.toEqual({ outcome: "revoked", revoked: true });
expect(mockInvoke).toHaveBeenCalledWith("check_license_revocation"); expect(mockInvoke).toHaveBeenCalledWith("check_license_revocation");
}); });
it("passes the release of the slot to remove_license, and nothing else (#370)", async () => {
// Rust reads the key and the token itself: the webview only says whether to free the slot.
for (const releaseSlot of [true, false]) {
mockInvoke.mockResolvedValueOnce(null);
await expect(removeLicense(releaseSlot)).resolves.toEqual({ ok: true, value: null });
expect(mockInvoke).toHaveBeenLastCalledWith("remove_license", { releaseSlot });
}
});
it("returns a typed error when the removal fails", async () => {
mockInvoke.mockRejectedValueOnce('{"code":"network"}');
await expect(removeLicense(true)).resolves.toEqual({
ok: false,
error: { code: "network", i18nKey: "license.errors.network" },
});
});
}); });
// The render tests echo i18n keys instead of translating them: a key missing // The render tests echo i18n keys instead of translating them: a key missing
@ -182,13 +200,20 @@ describe("license i18n coverage (fr + en)", () => {
} }
}); });
// Keys of the License card outside the error map (#347). // Keys of the License card outside the error map (#347), and of the removal of the key (#370).
const CARD_KEYS = [ const CARD_KEYS = [
"license.noExpiry", "license.noExpiry",
"license.machines.count", "license.machines.count",
"license.machines.activating", "license.machines.activating",
"license.machines.deactivating", "license.machines.deactivating",
"license.machines.notActivated", "license.machines.notActivated",
"license.removeKey.button",
"license.removeKey.removing",
"license.removeKey.confirmValid",
"license.removeKey.confirmActivated",
"license.removeKey.confirmRevoked",
"license.removeKey.confirmWithoutRelease",
"license.removeKey.failed",
]; ];
it("resolves every License card key to a non-empty string in both locales", () => { it("resolves every License card key to a non-empty string in both locales", () => {
@ -209,6 +234,14 @@ describe("license i18n coverage (fr + en)", () => {
} }
}); });
it("names the reason in the second confirmation and in a failed removal (#370)", () => {
for (const [lng, messages] of Object.entries(locales)) {
for (const key of ["license.removeKey.confirmWithoutRelease", "license.removeKey.failed"]) {
expect(resolve(messages, key), `${key} in ${lng}`).toContain("{{reason}}");
}
}
});
it("no longer carries the keys #347 removed", () => { it("no longer carries the keys #347 removed", () => {
for (const [lng, messages] of Object.entries(locales)) { for (const [lng, messages] of Object.entries(locales)) {
for (const key of ["license.removeLicense", "license.machines.activateError"]) { for (const key of ["license.removeLicense", "license.machines.activateError"]) {

View file

@ -25,6 +25,16 @@ export async function storeLicense(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("store_license", { key }); return invoke<LicenseInfo>("store_license", { key });
} }
/**
* Remove the stored license key from this computer (#370): the key, then its activation
* token; the revocation state stays. With `releaseSlot`, Rust first frees this machine's
* slot on the license server, when its token activates this machine and the key is not
* known as revoked. A release that fails removes nothing.
*/
export function removeLicense(releaseSlot: boolean): Promise<LicenseResult<void>> {
return callLicenseCommand<void>("remove_license", { releaseSlot });
}
export async function readLicense(): Promise<LicenseInfo | null> { export async function readLicense(): Promise<LicenseInfo | null> {
return invoke<LicenseInfo | null>("read_license"); return invoke<LicenseInfo | null>("read_license");
} }
@ -93,8 +103,9 @@ export interface ActivationStatus {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// //
// The network commands (activate_machine, deactivate_machine, // The network commands (activate_machine, deactivate_machine,
// list_activated_machines) and the key commands (store_license, read_license, // list_activated_machines), the key commands (store_license, read_license,
// validate_license_key) reject with a JSON string serialized from the Rust // validate_license_key) and the removal of the key (remove_license, #370)
// reject with a JSON string serialized from the Rust
// `LicenseError` enum (license_commands.rs), same pattern as fetch_price: // `LicenseError` enum (license_commands.rs), same pattern as fetch_price:
// {"code":"license_revoked"} | {"code":"rate_limit","retry_after_s":42} // {"code":"license_revoked"} | {"code":"rate_limit","retry_after_s":42}
// | {"code":"server","status":502} | {"code":"invalid_prefix"} | ... // | {"code":"server","status":502} | {"code":"invalid_prefix"} | ...