fix(license): named key-entry errors, edition independent of the activation token, revoked key flagged (#347) #364
26 changed files with 1215 additions and 291 deletions
|
|
@ -12,6 +12,10 @@
|
||||||
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
|
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
|
||||||
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
|
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
|
||||||
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
|
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
|
||||||
|
- La carte Licence signale une clé révoquée jusqu'à la fermeture de l'application et ne propose plus « Activer » pour elle ; le message disparaissait auparavant avec la liste des machines (#347).
|
||||||
|
- La liste des machines indique combien sont activées sur le nombre permis par votre clé (#347).
|
||||||
|
- Une clé de licence valide pour plus de 50 ans affiche « Sans échéance » au lieu d'une date (#347).
|
||||||
|
- L'erreur d'authentification des cours du marché indique désormais d'activer cette machine dans Paramètres → Utilisateurs (#347).
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
|
|
@ -19,6 +23,9 @@
|
||||||
- Un refus du serveur de licences est désormais expliqué dans votre langue : clé révoquée, expirée ou introuvable, nombre maximal de machines atteint, trop de tentatives (avec le délai à attendre), serveur injoignable. Il s'affichait auparavant en anglais sous un « Activation failed » générique, et une clé révoquée apparaissait comme « Aucune machine activée » (#270).
|
- Un refus du serveur de licences est désormais expliqué dans votre langue : clé révoquée, expirée ou introuvable, nombre maximal de machines atteint, trop de tentatives (avec le délai à attendre), serveur injoignable. Il s'affichait auparavant en anglais sous un « Activation failed » générique, et une clé révoquée apparaissait comme « Aucune machine activée » (#270).
|
||||||
- Quand le nombre maximal de machines est atteint, la liste des machines s'ouvre d'elle-même pour en désactiver une, et le message reste affiché pendant son chargement. Désactiver une machine ne réinterroge plus le serveur, et désactiver une machine que le serveur considère déjà libérée compte désormais comme réussi (#270).
|
- Quand le nombre maximal de machines est atteint, la liste des machines s'ouvre d'elle-même pour en désactiver une, et le message reste affiché pendant son chargement. Désactiver une machine ne réinterroge plus le serveur, et désactiver une machine que le serveur considère déjà libérée compte désormais comme réussi (#270).
|
||||||
- Le bouton « Acheter » de la carte Licence ouvre désormais directement la page produit, dans la langue de l'application, au lieu d'une ancienne adresse qui ne fonctionne que grâce à une redirection du site (#270).
|
- Le bouton « Acheter » de la carte Licence ouvre désormais directement la page produit, dans la langue de l'application, au lieu d'une ancienne adresse qui ne fonctionne que grâce à une redirection du site (#270).
|
||||||
|
- Une clé de licence valide garde désormais son édition quelle que soit l'activation de la machine : une activation expirée (au bout de deux ans), ou reprise d'un autre ordinateur quand vous restaurez vos données après une réinstallation ou un changement de poste, ne fait plus retomber l'application en édition Gratuite. La carte Licence indique que la machine n'est pas activée, et « Activer » rétablit les cours du marché (#347, #360).
|
||||||
|
- Une clé de licence refusée est désormais expliquée dans votre langue (préfixe incorrect, clé illisible, clé non émise par le serveur de licences, clé expirée, édition inconnue de cette version) au lieu d'un message anglais brut, et un fichier de licence illisible est nommé lui aussi (#347).
|
||||||
|
- Les dates de la carte Licence suivent la langue de l'application plutôt que celle du système (#347).
|
||||||
|
|
||||||
### Sécurité
|
### Sécurité
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
|
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
|
||||||
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
|
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
|
||||||
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
|
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
|
||||||
|
- The License card keeps a revoked key flagged until the app is closed, and no longer offers "Activate" for it; the message used to disappear with the machine list (#347).
|
||||||
|
- The machine list shows how many machines are activated out of the number your key allows (#347).
|
||||||
|
- A license key valid for more than 50 years shows "No expiry date" instead of a date (#347).
|
||||||
|
- The market price authentication error now tells you to activate this machine in Settings → Users (#347).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
@ -19,6 +23,9 @@
|
||||||
- A refusal from the license server is now explained in your language: revoked, expired or unknown key, machine limit reached, too many attempts (with the wait time), server unreachable. It used to show a generic "Activation failed" in English, and a revoked key showed up as "No machines activated" (#270).
|
- A refusal from the license server is now explained in your language: revoked, expired or unknown key, machine limit reached, too many attempts (with the wait time), server unreachable. It used to show a generic "Activation failed" in English, and a revoked key showed up as "No machines activated" (#270).
|
||||||
- When the machine limit is reached, the machine list opens by itself so you can deactivate one, and the message stays visible while it loads. Deactivating a machine no longer queries the server a second time, and deactivating a machine the server already considers free now counts as done (#270).
|
- When the machine limit is reached, the machine list opens by itself so you can deactivate one, and the message stays visible while it loads. Deactivating a machine no longer queries the server a second time, and deactivating a machine the server already considers free now counts as done (#270).
|
||||||
- The "Buy" button of the License card now opens the product page directly, in the app's language, instead of an old address that only works through a redirect on the website (#270).
|
- The "Buy" button of the License card now opens the product page directly, in the app's language, instead of an old address that only works through a redirect on the website (#270).
|
||||||
|
- A valid license key now keeps its edition whatever the machine activation: an activation that has expired (after two years), or one carried over from another computer when you restore your data after a reinstall or a move, no longer drops the app to the Free edition. The License card shows the machine as not activated, and "Activate" brings back the market prices (#347, #360).
|
||||||
|
- A refused license key is now explained in your language (wrong prefix, unreadable key, key not issued by the license server, expired key, edition this version does not know) instead of a raw English message, and a license file that cannot be read is named too (#347).
|
||||||
|
- Dates on the License card follow the app's language instead of the system's (#347).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@
|
||||||
- Date: 2026-07-20
|
- Date: 2026-07-20
|
||||||
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
|
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
|
||||||
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
|
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
|
||||||
- S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
|
- S'appuie sur la licence JWT Ed25519 (Phase 3b monétisation ; liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
|
||||||
|
|
||||||
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
|
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
|
||||||
|
|
||||||
|
> **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §5, Liens) se lisent à la lumière de cet amendement.
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
||||||
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
||||||
|
|
||||||
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
|
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
|
||||||
|
|
||||||
|
|
@ -32,7 +34,7 @@ Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium.
|
||||||
Trois contraintes structurent la solution :
|
Trois contraintes structurent la solution :
|
||||||
|
|
||||||
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
|
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
|
||||||
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863).
|
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863). *Inexact avant même #347 : une clé copiée sans son jeton gardait son édition ; depuis #347 le jeton ne déclasse plus rien, seule une clé invalide ou expirée retombe en `free` (voir l'amendement en tête).*
|
||||||
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
|
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
|
||||||
|
|
||||||
## Décision
|
## Décision
|
||||||
|
|
@ -69,7 +71,7 @@ Les modules non inclus dans l'édition restent **visibles et cliquables** : cade
|
||||||
|
|
||||||
### 5. Durcissement sec, non destructif
|
### 5. Durcissement sec, non destructif
|
||||||
|
|
||||||
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée ; un changement de machine ne la fait plus baisser depuis #347, voir l'amendement en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
||||||
|
|
||||||
### 6. Dev-override compilé hors des builds normaux
|
### 6. Dev-override compilé hors des builds normaux
|
||||||
|
|
||||||
|
|
@ -126,6 +128,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` (machine-binding), `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), `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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -538,7 +538,7 @@ Retry-After: 42
|
||||||
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
|
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
|
||||||
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
|
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
|
||||||
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
|
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
|
||||||
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Activation requise » | "Activation required" |
|
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs » | "Authentication failed — activate this machine in Settings → Users" |
|
||||||
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
|
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
|
||||||
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
|
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
|
||||||
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
|
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,14 @@ simpl-resultat/
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||||
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard)
|
||||||
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton)
|
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton)
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
|
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
|
||||||
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||||
│ ├── services/ # 14 services métier
|
│ ├── services/ # 14 services métier
|
||||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts)
|
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||||
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
|
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
│ ├── App.tsx # Router principal
|
│ ├── App.tsx # Router principal
|
||||||
|
|
@ -184,7 +184,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
|
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
| `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) |
|
| `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 |
|
||||||
| `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) |
|
||||||
|
|
||||||
|
|
@ -227,7 +227,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. `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie |
|
| `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 est consigné dans le `LicenseContext` (`markRevoked`, #347) ; le hook en relit `revoked` et `selectCanActivate` bloque alors « 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 |
|
||||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||||
|
|
||||||
### Hook transverse — `useCollapsibleGroups`
|
### Hook transverse — `useCollapsibleGroups`
|
||||||
|
|
@ -292,8 +292,8 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
|
||||||
|
|
||||||
| Brique | Fichier | Rôle |
|
| Brique | Fichier | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé copiée déclassée par le machine-binding ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé résolue en `free` (invalide ou expirée) ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
|
||||||
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
|
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#347) : un `license_revoked` du serveur, consigné pour toute la session parce que la carte Licence se démonte en quittant Paramètres → Utilisateurs ; jamais persisté, sans effet sur l'édition, et effacé seulement quand une autre clé est enregistrée |
|
||||||
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
|
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
|
||||||
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
|
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
|
||||||
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas |
|
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas |
|
||||||
|
|
@ -340,14 +340,16 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
||||||
- `store_license` — Stockage de la clé dans le répertoire app data
|
- `store_license` — Stockage de la clé dans le répertoire app data
|
||||||
- `store_activation_token` — Stockage du token d'activation
|
- `store_activation_token` — Stockage du token d'activation
|
||||||
- `read_license` — Lecture de la licence stockée
|
- `read_license` — Lecture de la licence stockée
|
||||||
- `get_edition` — Détection de l'édition active (free/base/premium)
|
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347)
|
||||||
- `get_machine_id` — Génération d'un identifiant machine unique
|
- `get_machine_id` — Génération d'un identifiant machine unique
|
||||||
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270)
|
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270)
|
||||||
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès
|
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès
|
||||||
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide
|
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide
|
||||||
- `get_activation_status` — État d'activation de la machine courante
|
- `get_activation_status` — État d'activation de la machine courante. Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
||||||
|
|
||||||
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. #347 étend l'enum aux erreurs de saisie de clé.
|
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.
|
||||||
|
|
||||||
|
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`. Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||||
|
|
||||||
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
||||||
|
|
||||||
|
|
@ -377,9 +379,9 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
|
||||||
|
|
||||||
### `entitlements.rs` — Entitlements (1)
|
### `entitlements.rs` — Entitlements (1)
|
||||||
|
|
||||||
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé copiée, déclassée `free` par le machine-binding, ne récupère jamais ses features signées)
|
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé résolue en `free`, invalide ou expirée, ne récupère jamais ses features signées)
|
||||||
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update` → `[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
|
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update` → `[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
|
||||||
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin machine-binding que `get_edition`, tout échec de validation retourne `("free", [])`
|
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347), tout échec de validation retourne `("free", [])`
|
||||||
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
|
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
|
||||||
|
|
||||||
### `balance_commands.rs` — Bilan (1)
|
### `balance_commands.rs` — Bilan (1)
|
||||||
|
|
|
||||||
|
|
@ -553,9 +553,19 @@ Les modules au-dessus de votre édition restent **visibles mais verrouillés** :
|
||||||
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
||||||
5. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
|
5. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
|
||||||
|
|
||||||
|
### La carte Licence
|
||||||
|
|
||||||
|
La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machines :
|
||||||
|
|
||||||
|
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
||||||
|
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible (copiée en partie ou coupée par un retour à la ligne), elle n'a pas été émise par le serveur de licences, elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
||||||
|
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
|
||||||
|
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
||||||
|
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, la carte l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »
|
||||||
|
|
||||||
### Astuces
|
### Astuces
|
||||||
|
|
||||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, changement de machine), 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 ou invalide), 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
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode
|
||||||
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
|
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
|
||||||
|
|
||||||
- [ ] **Une clé de licence Base ou Premium valide sur chaque machine.** La mise à jour automatique est verrouillée par édition : `useUpdater` appelle `check_entitlement("auto-update")` et bascule en `notEntitled` **avant** même d'appeler `check()`. Une installation Gratuite ne télécharge jamais rien, et les builds de release n'ont pas de `dev-override`.
|
- [ ] **Une clé de licence Base ou Premium valide sur chaque machine.** La mise à jour automatique est verrouillée par édition : `useUpdater` appelle `check_entitlement("auto-update")` et bascule en `notEntitled` **avant** même d'appeler `check()`. Une installation Gratuite ne télécharge jamais rien, et les builds de release n'ont pas de `dev-override`.
|
||||||
- [ ] Sur chaque machine, **supprimer tout `activation.token` provenant d'une autre machine** (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une `license.key` seule fonctionne — l'absence de token est un état de pré-activation toléré — mais un token émis pour un autre `machine_id` fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec.
|
- [ ] Sur chaque machine, **supprimer tout `activation.token` provenant d'une autre machine** (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une `license.key` seule fonctionne — l'absence de token est un état de pré-activation toléré. Dans les versions antérieures à #347 (v0.15.0 et avant), un token émis pour un autre `machine_id` fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec ; depuis #347, il ne touche plus l'édition et la carte Licence affiche seulement « Non activée ». La suppression reste nécessaire tant que la version de départ du cycle est antérieure à #347.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
||||||
|
|
||||||
/// Static matrix check OR signed per-license `features[]` override.
|
/// Static matrix check OR signed per-license `features[]` override.
|
||||||
///
|
///
|
||||||
/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
|
/// Fail-closed in Free (CWE-863): a key that no longer validates (invalid or
|
||||||
/// resolves to "free" through the machine-binding path, which already drops the
|
/// expired) resolves to "free" with its signed features already dropped (see
|
||||||
/// signed features (see `license_commands::current_entitlements`). As defense
|
/// `license_commands::current_entitlements`). As defense in depth we also
|
||||||
/// in depth we also refuse the override here whenever the edition is free, so
|
/// refuse the override here whenever the edition is free, so signed features
|
||||||
/// signed features can never rescue a downgraded license.
|
/// can never rescue a license that resolves to free.
|
||||||
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
||||||
if edition == EDITION_FREE {
|
if edition == EDITION_FREE {
|
||||||
return is_feature_allowed(feature, edition);
|
return is_feature_allowed(feature, edition);
|
||||||
|
|
@ -45,8 +45,8 @@ pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tauri command: is `feature` available right now? Edition AND signed
|
/// Tauri command: is `feature` available right now? Edition AND signed
|
||||||
/// per-license feature overrides are resolved through the same machine-binding
|
/// per-license feature overrides are resolved together from the license key
|
||||||
/// path (`license_commands::current_entitlements`), then combined by
|
/// (`license_commands::current_entitlements`), then combined by
|
||||||
/// [`is_entitled`].
|
/// [`is_entitled`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||||
|
|
@ -101,8 +101,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn override_ignored_in_free() {
|
fn override_ignored_in_free() {
|
||||||
// CWE-863: signed features[] must never rescue a license downgraded to
|
// CWE-863: signed features[] must never rescue a license that resolves
|
||||||
// free (copied key / machine mismatch) — not even for a feature that a
|
// to free (invalid or expired key) — not even for a feature that a
|
||||||
// paid edition would get from the static matrix.
|
// paid edition would get from the static matrix.
|
||||||
let features = vec!["balance".to_string(), "auto-update".to_string()];
|
let features = vec!["balance".to_string(), "auto-update".to_string()];
|
||||||
assert!(!is_entitled("balance", EDITION_FREE, &features));
|
assert!(!is_entitled("balance", EDITION_FREE, &features));
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,25 @@
|
||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
|
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
|
||||||
// - Activation token = separate JWT, also signed by the server, binds the license to a machine
|
// - Activation token = separate JWT, also signed by the server, that binds the license to a
|
||||||
// (machine_id claim must match the local machine_id). Without it, a copied license.key would
|
// machine ON THE SERVER (machine_id claim, machine limit). Issued by `/v1/licenses/activate`
|
||||||
// work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
|
// (Issue #49); the market-price route requires it.
|
||||||
// - Both files live in app_data_dir/ — license.key and activation.token
|
// - Both files live in app_data_dir/ — license.key and activation.token
|
||||||
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
|
// - The edition comes from the license key alone: get_edition() returns "free" unless the
|
||||||
// either there is no activation token (graceful pre-activation state) OR the activation token
|
// license JWT is valid (signature + exp). The activation token plays no part (#347, D12).
|
||||||
// matches the local machine_id.
|
// Until #347 a token that did not validate for this machine dropped the edition to free:
|
||||||
|
// that protected nothing (a key copied without its token kept its edition) and locked out
|
||||||
|
// users whose 2-year token had expired or came from a restored backup (#360). The token now
|
||||||
|
// only drives the "Activated" state of the License card and the price calls, which the
|
||||||
|
// server verifies (ADR 0017, amendment for #347).
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
|
|
||||||
|
use jsonwebtoken::errors::ErrorKind;
|
||||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
||||||
|
|
@ -83,7 +88,7 @@ fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the human-readable prefix and return the bare JWT.
|
/// Strip the human-readable prefix and return the bare JWT.
|
||||||
fn strip_prefix(key: &str) -> Result<&str, String> {
|
fn strip_prefix(key: &str) -> Result<&str, LicenseError> {
|
||||||
let trimmed = key.trim();
|
let trimmed = key.trim();
|
||||||
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
|
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
|
||||||
return Ok(jwt);
|
return Ok(jwt);
|
||||||
|
|
@ -91,7 +96,7 @@ fn strip_prefix(key: &str) -> Result<&str, String> {
|
||||||
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
|
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
|
||||||
return Ok(jwt);
|
return Ok(jwt);
|
||||||
}
|
}
|
||||||
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
|
Err(LicenseError::InvalidPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
|
/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
|
||||||
|
|
@ -105,25 +110,35 @@ fn strict_validation() -> Validation {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the production `DecodingKey` from the embedded PEM constant.
|
/// Build the production `DecodingKey` from the embedded PEM constant.
|
||||||
fn embedded_decoding_key() -> Result<DecodingKey, String> {
|
fn embedded_decoding_key() -> Result<DecodingKey, LicenseError> {
|
||||||
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes())
|
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes()).map_err(|_| LicenseError::Internal)
|
||||||
.map_err(|e| format!("Invalid public key: {}", e))
|
}
|
||||||
|
|
||||||
|
/// Name a `jsonwebtoken` failure for the user. An expired key and a key signed by another
|
||||||
|
/// key get their own message; anything else (unreadable JWT, missing claim, unexpected
|
||||||
|
/// algorithm) reads as a malformed key.
|
||||||
|
fn license_error_from_jwt(err: &jsonwebtoken::errors::Error) -> LicenseError {
|
||||||
|
match err.kind() {
|
||||||
|
ErrorKind::ExpiredSignature => LicenseError::LicenseExpired,
|
||||||
|
ErrorKind::InvalidSignature => LicenseError::InvalidSignature,
|
||||||
|
_ => LicenseError::Malformed,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
|
/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
|
||||||
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
|
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
|
||||||
///
|
///
|
||||||
/// Separated from the Tauri command so tests can pass their own key.
|
/// Separated from the Tauri command so tests can pass their own key.
|
||||||
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, String> {
|
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, LicenseError> {
|
||||||
let jwt = strip_prefix(key)?;
|
let jwt = strip_prefix(key)?;
|
||||||
let validation = strict_validation();
|
let validation = strict_validation();
|
||||||
|
|
||||||
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
|
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
|
||||||
.map_err(|e| format!("Invalid license: {}", e))?;
|
.map_err(|e| license_error_from_jwt(&e))?;
|
||||||
|
|
||||||
let claims = data.claims;
|
let claims = data.claims;
|
||||||
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
|
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
|
||||||
return Err(format!("Unknown edition '{}'", claims.edition));
|
return Err(LicenseError::UnknownEdition);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(LicenseInfo {
|
Ok(LicenseInfo {
|
||||||
|
|
@ -156,25 +171,39 @@ fn validate_activation_with_key(
|
||||||
|
|
||||||
// === Tauri commands ===========================================================================
|
// === Tauri commands ===========================================================================
|
||||||
|
|
||||||
/// Validate a license key without persisting it. Used by the UI to give immediate feedback
|
/// Validate a license key without persisting it. Errors cross the boundary as a serialized
|
||||||
/// before the user confirms storage.
|
/// [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
||||||
let decoding_key = embedded_decoding_key()?;
|
embedded_decoding_key()
|
||||||
validate_with_key(&key, &decoding_key)
|
.and_then(|decoding_key| validate_with_key(&key, &decoding_key))
|
||||||
|
.map_err(|e| license_error_to_string(&e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a previously-validated license key to disk. The activation token (machine binding)
|
/// Validate then persist a license key. The activation token (machine binding on the server)
|
||||||
/// is stored separately by [`store_activation_token`] once the server has issued one.
|
/// is stored separately by [`store_activation_token`] once the server has issued one. Errors
|
||||||
|
/// cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
||||||
let decoding_key = embedded_decoding_key()?;
|
store_license_impl(&app, &key).map_err(|e| license_error_to_string(&e))
|
||||||
let info = validate_with_key(&key, &decoding_key)?;
|
|
||||||
let path = license_path(&app)?;
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
|
||||||
}
|
}
|
||||||
fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
|
|
||||||
|
fn store_license_impl(app: &tauri::AppHandle, key: &str) -> Result<LicenseInfo, LicenseError> {
|
||||||
|
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
||||||
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
store_license_in(&dir, key, &decoding_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate `key`, then write it to `dir/license.key`. A refused key writes nothing. Kept
|
||||||
|
/// apart from the Tauri command, without `AppHandle`, so tests run it in a temporary directory.
|
||||||
|
fn store_license_in(
|
||||||
|
dir: &Path,
|
||||||
|
key: &str,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Result<LicenseInfo, LicenseError> {
|
||||||
|
let info = validate_with_key(key, decoding_key)?;
|
||||||
|
fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?;
|
||||||
|
fs::write(dir.join(LICENSE_FILE), key.trim()).map_err(|_| LicenseError::Storage)?;
|
||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +219,7 @@ pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<()
|
||||||
/// the server, hence `InvalidResponse`; a failed write is `Storage`.
|
/// the server, hence `InvalidResponse`; a failed write is `Storage`.
|
||||||
fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> {
|
fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> {
|
||||||
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
||||||
let decoding_key = embedded_decoding_key().map_err(|_| LicenseError::Internal)?;
|
let decoding_key = embedded_decoding_key()?;
|
||||||
validate_activation_with_key(token, &local_id, &decoding_key)
|
validate_activation_with_key(token, &local_id, &decoding_key)
|
||||||
.map_err(|_| LicenseError::InvalidResponse)?;
|
.map_err(|_| LicenseError::InvalidResponse)?;
|
||||||
let path = activation_path(app).map_err(|_| LicenseError::Storage)?;
|
let path = activation_path(app).map_err(|_| LicenseError::Storage)?;
|
||||||
|
|
@ -200,30 +229,34 @@ fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), L
|
||||||
fs::write(&path, token.trim()).map_err(|_| LicenseError::Storage)
|
fs::write(&path, token.trim()).map_err(|_| LicenseError::Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the stored license without revalidating. Returns `None` when no license is present.
|
/// Read and validate the stored license. Returns `None` when no license is stored or when the
|
||||||
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
|
/// stored key no longer validates (the edition then resolves to free). Errors cross the
|
||||||
|
/// boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
|
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
|
||||||
let path = license_path(&app)?;
|
let dir = app_data_dir(&app).map_err(|_| license_error_to_string(&LicenseError::Storage))?;
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
|
||||||
let Ok(decoding_key) = embedded_decoding_key() else {
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(validate_with_key(&key, &decoding_key).ok())
|
read_license_in(&dir, &decoding_key).map_err(|e| license_error_to_string(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `dir/license.key`: `None` when the file is missing or the key does not validate,
|
||||||
|
/// `Storage` when the file exists but cannot be read.
|
||||||
|
fn read_license_in(dir: &Path, decoding_key: &DecodingKey) -> Result<Option<LicenseInfo>, LicenseError> {
|
||||||
|
let path = dir.join(LICENSE_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let key = fs::read_to_string(&path).map_err(|_| LicenseError::Storage)?;
|
||||||
|
Ok(validate_with_key(&key, decoding_key).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
|
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
|
||||||
///
|
///
|
||||||
/// Returns "free" when:
|
/// Returns "free" when no license is stored, or when the stored license JWT is invalid or
|
||||||
/// - no license is stored,
|
/// expired. The activation token plays no part (#347, D12): it binds the key to a machine on
|
||||||
/// - the license JWT is invalid or expired,
|
/// the server, and only the price calls require it.
|
||||||
/// - an activation token exists but does not match this machine.
|
|
||||||
///
|
|
||||||
/// Note: a missing activation token is treated as a graceful pre-activation state and does
|
|
||||||
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
Ok(current_edition(&app))
|
Ok(current_edition(&app))
|
||||||
|
|
@ -248,39 +281,39 @@ fn dev_override_edition() -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure resolution of `(edition, signed features)` from license material.
|
/// Pure resolution of `(edition, signed features)` from the license key alone.
|
||||||
///
|
///
|
||||||
/// Single choke point for the machine-binding rule: every downgrade path
|
/// Single choke point of the local gating: a valid, unexpired key gives its edition and its
|
||||||
/// returns `("free", [])`, so the signed `features[]` of a copied license can
|
/// signed `features[]`; any validation failure gives `("free", [])`, features included, so a
|
||||||
/// never be honored once the edition is downgraded (CWE-863). Separated from
|
/// key that no longer validates never keeps its overrides (CWE-863). The activation token is
|
||||||
/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys.
|
/// deliberately not an input (#347, D12): it used to downgrade a valid key whenever it did not
|
||||||
fn resolve_license_entitlements(
|
/// validate for this machine, which protected nothing (a key copied without its token kept its
|
||||||
license_key: &str,
|
/// edition) and locked out users whose token had expired or came from a restored backup.
|
||||||
activation_token: Option<&str>,
|
fn resolve_license_entitlements(license_key: &str, decoding_key: &DecodingKey) -> (String, Vec<String>) {
|
||||||
local_machine_id: &str,
|
match validate_with_key(license_key, decoding_key) {
|
||||||
decoding_key: &DecodingKey,
|
Ok(info) => (info.edition, info.features),
|
||||||
) -> (String, Vec<String>) {
|
Err(_) => (EDITION_FREE.to_string(), Vec::new()),
|
||||||
let Ok(info) = validate_with_key(license_key, decoding_key) else {
|
|
||||||
return (EDITION_FREE.to_string(), Vec::new());
|
|
||||||
};
|
|
||||||
// If an activation token exists, it must match the local machine. A missing
|
|
||||||
// token is accepted (graceful pre-activation state).
|
|
||||||
if let Some(token) = activation_token {
|
|
||||||
if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() {
|
|
||||||
return (EDITION_FREE.to_string(), Vec::new());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(info.edition, info.features)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal helper used by `entitlements::check_entitlement`. Resolves the
|
/// Resolve the entitlements from the files of `dir` (the app data directory in production).
|
||||||
/// effective edition AND the signed per-license feature overrides through the
|
/// Reads `license.key` only: `activation.token` is never read here, whatever it contains. A
|
||||||
/// SAME machine-binding path. Never returns an error — any failure resolves to
|
/// missing or unreadable key file resolves to `("free", [])`. Kept apart from `AppHandle` so
|
||||||
/// `("free", [])` so feature gates fail closed, features included (CWE-863: a
|
/// tests exercise the decision on real files in a temporary directory.
|
||||||
/// copied `license.key` must not keep its signed `features[]` once downgraded).
|
fn entitlements_from_dir(dir: &Path, decoding_key: &DecodingKey) -> (String, Vec<String>) {
|
||||||
|
let Ok(key) = fs::read_to_string(dir.join(LICENSE_FILE)) else {
|
||||||
|
return (EDITION_FREE.to_string(), Vec::new());
|
||||||
|
};
|
||||||
|
resolve_license_entitlements(&key, decoding_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal helper used by `entitlements::check_entitlement`. Resolves the effective edition
|
||||||
|
/// AND the signed per-license feature overrides together. Never returns an error — any failure
|
||||||
|
/// resolves to `("free", [])` so feature gates fail closed, features included (CWE-863).
|
||||||
///
|
///
|
||||||
/// Priority: dev override (dev-override builds only) > Premium (via Compte
|
/// Priority: dev override (dev-override builds only) > Premium (via Compte Maximus with active
|
||||||
/// Maximus with active subscription) > Base (offline license) > Free.
|
/// subscription) > the license key (offline) > Free. The activation token is not consulted
|
||||||
|
/// (#347, D12).
|
||||||
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
|
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
|
||||||
// Dev-only tier testing — compiled out of normal builds (see the helper).
|
// Dev-only tier testing — compiled out of normal builds (see the helper).
|
||||||
if let Some(edition) = dev_override_edition() {
|
if let Some(edition) = dev_override_edition() {
|
||||||
|
|
@ -297,43 +330,13 @@ pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<Strin
|
||||||
|
|
||||||
let free = || (EDITION_FREE.to_string(), Vec::new());
|
let free = || (EDITION_FREE.to_string(), Vec::new());
|
||||||
|
|
||||||
let Ok(path) = license_path(app) else {
|
let Ok(dir) = app_data_dir(app) else {
|
||||||
return free();
|
|
||||||
};
|
|
||||||
if !path.exists() {
|
|
||||||
return free();
|
|
||||||
}
|
|
||||||
let Ok(key) = fs::read_to_string(&path) else {
|
|
||||||
return free();
|
return free();
|
||||||
};
|
};
|
||||||
let Ok(decoding_key) = embedded_decoding_key() else {
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
||||||
return free();
|
return free();
|
||||||
};
|
};
|
||||||
|
entitlements_from_dir(&dir, &decoding_key)
|
||||||
// Read the activation token when present. An unreadable token or machine
|
|
||||||
// id resolves to free, matching the strict posture of `current_edition`
|
|
||||||
// before this refactor.
|
|
||||||
let mut activation_token: Option<String> = None;
|
|
||||||
let mut local_machine_id = String::new();
|
|
||||||
if let Ok(act_path) = activation_path(app) {
|
|
||||||
if act_path.exists() {
|
|
||||||
let Ok(token) = fs::read_to_string(&act_path) else {
|
|
||||||
return free();
|
|
||||||
};
|
|
||||||
let Ok(local_id) = machine_id_internal() else {
|
|
||||||
return free();
|
|
||||||
};
|
|
||||||
activation_token = Some(token);
|
|
||||||
local_machine_id = local_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_license_entitlements(
|
|
||||||
&key,
|
|
||||||
activation_token.as_deref(),
|
|
||||||
&local_machine_id,
|
|
||||||
&decoding_key,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
|
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
|
||||||
|
|
@ -359,7 +362,9 @@ fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
|
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
|
||||||
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
|
/// or hardware migration. The edition does not depend on it (#347, D12): after such a change
|
||||||
|
/// the License card shows this machine as not activated, and "Activate" binds it again on the
|
||||||
|
/// server, which the price calls require.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_machine_id() -> Result<String, String> {
|
pub fn get_machine_id() -> Result<String, String> {
|
||||||
machine_id_internal()
|
machine_id_internal()
|
||||||
|
|
@ -386,17 +391,26 @@ const DEFAULT_RETRY_AFTER_S: u64 = 60;
|
||||||
/// same pattern as `FetchPriceError` in `balance_commands.rs`: `{"code":"license_revoked"}`,
|
/// same pattern as `FetchPriceError` in `balance_commands.rs`: `{"code":"license_revoked"}`,
|
||||||
/// `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. The front
|
/// `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. The front
|
||||||
/// parses it with `parseLicenseError` (`licenseService.ts`) and maps each code to a
|
/// parses it with `parseLicenseError` (`licenseService.ts`) and maps each code to a
|
||||||
/// `license.errors.*` message. Issue #270 (server-side refusals); #347 extends it to the
|
/// `license.errors.*` message. Issue #270 (server-side refusals); #347 (local key-entry
|
||||||
/// local key-entry errors.
|
/// errors, which reuse `LicenseExpired` and `Storage`).
|
||||||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
#[serde(tag = "code", rename_all = "snake_case")]
|
#[serde(tag = "code", rename_all = "snake_case")]
|
||||||
pub enum LicenseError {
|
pub enum LicenseError {
|
||||||
LicenseNotFound,
|
LicenseNotFound,
|
||||||
LicenseRevoked,
|
LicenseRevoked,
|
||||||
|
/// Refused by the server, or the key's own `exp` has passed.
|
||||||
LicenseExpired,
|
LicenseExpired,
|
||||||
MachineLimitReached,
|
MachineLimitReached,
|
||||||
NoActiveActivation,
|
NoActiveActivation,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
|
/// The key does not start with `SR-BASE-` or `SR-PREMIUM-`.
|
||||||
|
InvalidPrefix,
|
||||||
|
/// The key was not signed by the license server.
|
||||||
|
InvalidSignature,
|
||||||
|
/// The key's JWT cannot be read: bad encoding, missing claim, unexpected algorithm.
|
||||||
|
Malformed,
|
||||||
|
/// The key names an edition this version does not know.
|
||||||
|
UnknownEdition,
|
||||||
RateLimit { retry_after_s: u64 },
|
RateLimit { retry_after_s: u64 },
|
||||||
/// The request could not be sent or timed out.
|
/// The request could not be sent or timed out.
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -664,7 +678,10 @@ async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<Vec<Mach
|
||||||
verify_inner(&api_base_url(), &license_key).await
|
verify_inner(&api_base_url(), &license_key).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the local activation status without contacting the server.
|
/// Check the local activation status without contacting the server. Strict on purpose: an
|
||||||
|
/// expired token, or one issued for another machine, reads as not activated, so the License
|
||||||
|
/// card offers "Activate" (the price calls need a token the server accepts). The edition does
|
||||||
|
/// not depend on this status (#347, D12).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
|
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
|
||||||
let machine_id = machine_id_internal()?;
|
let machine_id = machine_id_internal()?;
|
||||||
|
|
@ -758,7 +775,7 @@ mod tests {
|
||||||
fn rejects_key_without_prefix() {
|
fn rejects_key_without_prefix() {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let result = validate_with_key("nonsense", &dec);
|
let result = validate_with_key("nonsense", &dec);
|
||||||
assert!(result.is_err());
|
assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -781,6 +798,26 @@ mod tests {
|
||||||
assert_eq!(info.machine_limit, 3);
|
assert_eq!(info.machine_limit, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_hundred_year_license() {
|
||||||
|
// Keys issued since maximus-api#63 last 36525 days: their `exp` crosses 2^32 (#363).
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let exp = now() + 36525 * 86400;
|
||||||
|
assert!(exp > u32::MAX as i64, "a 100-year key must cross 2^32 to test the boundary");
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec![],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims));
|
||||||
|
let info = validate_with_key(&key, &dec).unwrap();
|
||||||
|
assert_eq!(info.expires_at, exp);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_expired_license() {
|
fn rejects_expired_license() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
|
|
@ -796,7 +833,32 @@ mod tests {
|
||||||
let jwt = make_token(&enc, &claims);
|
let jwt = make_token(&enc, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, &dec);
|
||||||
assert!(result.is_err(), "expired license must be rejected");
|
assert_eq!(result.unwrap_err(), LicenseError::LicenseExpired, "expired license must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_license_without_exp() {
|
||||||
|
// CWE-613: a key without `exp` never validates, however well it is signed.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ClaimsWithoutExp {
|
||||||
|
sub: String,
|
||||||
|
iss: String,
|
||||||
|
iat: i64,
|
||||||
|
edition: String,
|
||||||
|
features: Vec<String>,
|
||||||
|
machine_limit: u32,
|
||||||
|
}
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let claims = ClaimsWithoutExp {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec![],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims));
|
||||||
|
assert_eq!(validate_with_key(&key, &dec).unwrap_err(), LicenseError::Malformed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -815,7 +877,11 @@ mod tests {
|
||||||
let jwt = make_token(&enc_signer, &claims);
|
let jwt = make_token(&enc_signer, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec_other);
|
let result = validate_with_key(&key, &dec_other);
|
||||||
assert!(result.is_err(), "wrong-key signature must be rejected");
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
LicenseError::InvalidSignature,
|
||||||
|
"wrong-key signature must be rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -823,7 +889,7 @@ mod tests {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, &dec);
|
||||||
assert!(result.is_err());
|
assert_eq!(result.unwrap_err(), LicenseError::Malformed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -841,7 +907,7 @@ mod tests {
|
||||||
let jwt = make_token(&enc, &claims);
|
let jwt = make_token(&enc, &claims);
|
||||||
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
let result = validate_with_key(&key, &dec);
|
let result = validate_with_key(&key, &dec);
|
||||||
assert!(result.is_err());
|
assert_eq!(result.unwrap_err(), LicenseError::UnknownEdition);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -871,13 +937,25 @@ mod tests {
|
||||||
assert!(result.is_err(), "copied activation token must be rejected");
|
assert!(result.is_err(), "copied activation token must be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_token_rejects_expired() {
|
||||||
|
// The token stays strictly validated where it decides something: the "Activated"
|
||||||
|
// state of the card and the token written after an activation (#347, D12).
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let token = expired_activation_for(&enc, "this-machine");
|
||||||
|
assert!(
|
||||||
|
validate_activation_with_key(&token, "this-machine", &dec).is_err(),
|
||||||
|
"an expired token must not read as activated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_public_key_pem_parses() {
|
fn embedded_public_key_pem_parses() {
|
||||||
// Sanity check that the production PEM constant is well-formed.
|
// Sanity check that the production PEM constant is well-formed.
|
||||||
assert!(embedded_decoding_key().is_ok());
|
assert!(embedded_decoding_key().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Entitlements resolution (edition + signed features, machine binding) =================
|
// === Entitlements resolution: the license key alone (#347, D12) ============================
|
||||||
|
|
||||||
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
|
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
|
||||||
let claims = LicenseClaims {
|
let claims = LicenseClaims {
|
||||||
|
|
@ -893,6 +971,19 @@ mod tests {
|
||||||
format!("{}{}", KEY_PREFIX_BASE, jwt)
|
format!("{}{}", KEY_PREFIX_BASE, jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expired_base_license(enc: &EncodingKey) -> String {
|
||||||
|
let claims = LicenseClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: "lacompagniemaximus.com".to_string(),
|
||||||
|
iat: now() - 1000,
|
||||||
|
exp: now() - 100,
|
||||||
|
edition: EDITION_BASE.to_string(),
|
||||||
|
features: vec!["balance".to_string()],
|
||||||
|
machine_limit: 3,
|
||||||
|
};
|
||||||
|
format!("{}{}", KEY_PREFIX_BASE, make_token(enc, &claims))
|
||||||
|
}
|
||||||
|
|
||||||
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
|
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
|
||||||
let claims = ActivationClaims {
|
let claims = ActivationClaims {
|
||||||
sub: "license-id".to_string(),
|
sub: "license-id".to_string(),
|
||||||
|
|
@ -903,52 +994,178 @@ mod tests {
|
||||||
make_token(enc, &claims)
|
make_token(enc, &claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// A token as the server signs it (2 years), issued long enough ago to have expired.
|
||||||
fn machine_match_keeps_edition_and_features() {
|
fn expired_activation_for(enc: &EncodingKey, machine_id: &str) -> String {
|
||||||
let (enc, dec) = default_keys();
|
let claims = ActivationClaims {
|
||||||
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
sub: "license-id".to_string(),
|
||||||
let token = activation_for(&enc, "machine-A");
|
iat: now() - 3 * 365 * 86400,
|
||||||
let (edition, features) =
|
exp: now() - 100,
|
||||||
resolve_license_entitlements(&key, Some(&token), "machine-A", &dec);
|
machine_id: machine_id.to_string(),
|
||||||
assert_eq!(edition, EDITION_BASE);
|
};
|
||||||
assert_eq!(features, vec!["balance".to_string()]);
|
make_token(enc, &claims)
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn machine_mismatch_downgrades_to_free_and_drops_features() {
|
|
||||||
// CWE-863: a copied license.key + activation.token still carries its
|
|
||||||
// signed features[] — they must vanish together with the downgrade.
|
|
||||||
let (enc, dec) = default_keys();
|
|
||||||
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
|
||||||
let token = activation_for(&enc, "machine-A");
|
|
||||||
let (edition, features) =
|
|
||||||
resolve_license_entitlements(&key, Some(&token), "machine-B", &dec);
|
|
||||||
assert_eq!(edition, EDITION_FREE);
|
|
||||||
assert!(
|
|
||||||
features.is_empty(),
|
|
||||||
"signed features must not survive the machine-binding downgrade"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_activation_token_keeps_edition_and_features() {
|
|
||||||
// Graceful pre-activation state: no token yet, the license still counts.
|
|
||||||
let (enc, dec) = default_keys();
|
|
||||||
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
|
|
||||||
let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec);
|
|
||||||
assert_eq!(edition, EDITION_BASE);
|
|
||||||
assert_eq!(features, vec!["balance".to_string()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_license_resolves_free_without_features() {
|
fn invalid_license_resolves_free_without_features() {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let (edition, features) =
|
let (edition, features) = resolve_license_entitlements("SR-BASE-not.a.jwt", &dec);
|
||||||
resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec);
|
|
||||||
assert_eq!(edition, EDITION_FREE);
|
assert_eq!(edition, EDITION_FREE);
|
||||||
assert!(features.is_empty());
|
assert!(features.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_license_resolves_free_without_features() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let (edition, features) = resolve_license_entitlements(&expired_base_license(&enc), &dec);
|
||||||
|
assert_eq!(edition, EDITION_FREE);
|
||||||
|
assert!(features.is_empty(), "signed features must not survive an expired key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A data directory of its own for each test (test name + pid: cargo runs tests on
|
||||||
|
/// parallel threads), removed when the test ends, failed assertion included.
|
||||||
|
struct TestDir(PathBuf);
|
||||||
|
|
||||||
|
impl TestDir {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"simpl-resultat-license-test-{}-{}",
|
||||||
|
name,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
TestDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, file: &str, contents: impl AsRef<[u8]>) {
|
||||||
|
fs::write(self.0.join(file), contents).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes that are not UTF-8: `read_to_string` fails on them.
|
||||||
|
const NOT_UTF8: [u8; 4] = [0xff, 0xfe, 0x00, 0x80];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_alone_keeps_edition_and_features() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("key_alone");
|
||||||
|
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_token_of_this_machine_keeps_edition_and_features() {
|
||||||
|
// #347: the server signs the token for 2 years, the key lasts longer.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("expired_token");
|
||||||
|
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
|
||||||
|
dir.write(ACTIVATION_FILE, expired_activation_for(&enc, "this-machine"));
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_of_another_machine_keeps_edition_and_features() {
|
||||||
|
// #360: data restored after a reinstall, or copied to a new computer.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("other_machine_token");
|
||||||
|
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, "another-machine"));
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_token_keeps_edition_and_features() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("unreadable_token");
|
||||||
|
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
|
||||||
|
dir.write(ACTIVATION_FILE, NOT_UTF8);
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_BASE);
|
||||||
|
assert_eq!(features, vec!["balance".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_key_resolves_free_even_with_a_valid_token() {
|
||||||
|
// A token never upgrades an edition: the key alone decides.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("expired_key_valid_token");
|
||||||
|
dir.write(LICENSE_FILE, expired_base_license(&enc));
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_FREE);
|
||||||
|
assert!(features.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_key_file_resolves_free() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("missing_key");
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
|
||||||
|
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
|
||||||
|
assert_eq!(edition, EDITION_FREE);
|
||||||
|
assert!(features.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Key storage and reading: errors named at the source (#347) =============================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_refuses_a_bad_prefix_without_writing() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("store_bad_prefix");
|
||||||
|
let result = store_license_in(&dir.0, "BASE-not-a-key", &dec);
|
||||||
|
assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix);
|
||||||
|
assert!(!dir.0.join(LICENSE_FILE).exists(), "a refused key must not be written");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_writes_the_trimmed_key() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("store_trimmed");
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let info = store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap();
|
||||||
|
assert_eq!(info.edition, EDITION_BASE);
|
||||||
|
assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_reports_storage_when_the_directory_cannot_be_created() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("store_blocked");
|
||||||
|
dir.write("blocker", "a file where the data directory should go");
|
||||||
|
let target = dir.0.join("blocker").join("app-data");
|
||||||
|
let result = store_license_in(&target, &base_license_with_features(&enc, vec![]), &dec);
|
||||||
|
assert_eq!(result.unwrap_err(), LicenseError::Storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_reports_storage_for_an_unreadable_key_file() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("read_unreadable");
|
||||||
|
dir.write(LICENSE_FILE, NOT_UTF8);
|
||||||
|
assert_eq!(read_license_in(&dir.0, &dec).unwrap_err(), LicenseError::Storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_returns_none_for_a_missing_or_invalid_key() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("read_none");
|
||||||
|
assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "no file, no license");
|
||||||
|
dir.write(LICENSE_FILE, "SR-BASE-not.a.jwt");
|
||||||
|
assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "an invalid key reads as none");
|
||||||
|
}
|
||||||
|
|
||||||
// === 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
|
||||||
|
|
@ -1021,6 +1238,11 @@ mod license_server_tests {
|
||||||
json!({"code": "server", "status": 502})
|
json!({"code": "server", "status": 502})
|
||||||
);
|
);
|
||||||
assert_eq!(wire(&LicenseError::NoLicenseStored), json!({"code": "no_license_stored"}));
|
assert_eq!(wire(&LicenseError::NoLicenseStored), json!({"code": "no_license_stored"}));
|
||||||
|
// Local key-entry errors (#347), read by the same `parseLicenseError`.
|
||||||
|
assert_eq!(wire(&LicenseError::InvalidPrefix), json!({"code": "invalid_prefix"}));
|
||||||
|
assert_eq!(wire(&LicenseError::InvalidSignature), json!({"code": "invalid_signature"}));
|
||||||
|
assert_eq!(wire(&LicenseError::Malformed), json!({"code": "malformed"}));
|
||||||
|
assert_eq!(wire(&LicenseError::UnknownEdition), json!({"code": "unknown_edition"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
206
src/components/settings/LicenseCard.render.test.tsx
Normal file
206
src/components/settings/LicenseCard.render.test.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
// Render tests for the License card (#347). No jsdom needed: react-dom/server
|
||||||
|
// renders in vitest's node environment, and the license context and machine
|
||||||
|
// hook are replaced by fakes so each test pins one visible state. The fake
|
||||||
|
// hook computes its flags with the real selectors.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||||
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
||||||
|
|
||||||
|
let language = "fr";
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
||||||
|
i18n: { language },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let context: Record<string, unknown>;
|
||||||
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => context }));
|
||||||
|
|
||||||
|
let machines: Record<string, unknown>;
|
||||||
|
vi.mock("../../hooks/useLicenseMachines", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../hooks/useLicenseMachines")>()),
|
||||||
|
useLicenseMachines: () => machines,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LicenseCard from "./LicenseCard";
|
||||||
|
import {
|
||||||
|
initialLicenseMachinesState,
|
||||||
|
selectCanActivate,
|
||||||
|
selectCanDeactivate,
|
||||||
|
selectShowEmptyState,
|
||||||
|
type LicenseMachinesState,
|
||||||
|
} from "../../hooks/useLicenseMachines";
|
||||||
|
import { parseLicenseError, type MachineInfo } from "../../services/licenseService";
|
||||||
|
|
||||||
|
const DAY_S = 86400;
|
||||||
|
const nowS = () => Math.floor(Date.now() / 1000);
|
||||||
|
// Noon UTC, so the calendar day is the same in UTC (CI) and America/Toronto.
|
||||||
|
const TEN_YEAR_EXPIRY_S = Date.UTC(2036, 8, 26, 12) / 1000;
|
||||||
|
|
||||||
|
const machine = (id: string): MachineInfo => ({
|
||||||
|
machine_id: id,
|
||||||
|
machine_name: `host-${id}`,
|
||||||
|
activated_at: "2026-09-01T12:00:00Z",
|
||||||
|
last_seen_at: "2026-09-20T12:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What the real hook returns for `state`, with its real selectors. */
|
||||||
|
function hookView(partial: Partial<LicenseMachinesState>, revoked = false) {
|
||||||
|
const state: LicenseMachinesState = { ...initialLicenseMachinesState, ...partial };
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
revoked,
|
||||||
|
activate: vi.fn(),
|
||||||
|
deactivate: vi.fn(),
|
||||||
|
toggleList: vi.fn(),
|
||||||
|
showEmptyState: selectShowEmptyState(state),
|
||||||
|
canDeactivate: selectCanDeactivate(state),
|
||||||
|
canActivate: selectCanActivate(state, revoked),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notActivated = { is_activated: false, machine_id: "this" };
|
||||||
|
const loadedList = (list: MachineInfo[]) => ({
|
||||||
|
listOpen: true,
|
||||||
|
listLoaded: true,
|
||||||
|
machines: list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const render = () => renderToStaticMarkup(<LicenseCard />);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
language = "fr";
|
||||||
|
context = {
|
||||||
|
status: "ready",
|
||||||
|
edition: "base",
|
||||||
|
features: [],
|
||||||
|
info: {
|
||||||
|
edition: "base",
|
||||||
|
email: "user@example.com",
|
||||||
|
features: [],
|
||||||
|
machine_limit: 3,
|
||||||
|
issued_at: nowS(),
|
||||||
|
expires_at: TEN_YEAR_EXPIRY_S,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
validating: false,
|
||||||
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
submitKey: vi.fn(),
|
||||||
|
markRevoked: vi.fn(),
|
||||||
|
};
|
||||||
|
machines = hookView({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LicenseCard load error", () => {
|
||||||
|
it("names a serialized LicenseError instead of printing it", () => {
|
||||||
|
context = { ...context, status: "error", error: '{"code":"storage"}' };
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.errors.storage");
|
||||||
|
expect(html).not.toContain("{"code"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never prints a raw backend string, even one that is not a LicenseError", () => {
|
||||||
|
context = { ...context, status: "error", error: "Cannot read license file: permission denied" };
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.errors.unknown");
|
||||||
|
expect(html).not.toContain("Cannot read license file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LicenseCard revoked key", () => {
|
||||||
|
it("names the revocation at card level with the list closed, and blocks Activate", () => {
|
||||||
|
machines = hookView({ activation: notActivated }, true);
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.errors.licenseRevoked");
|
||||||
|
expect(html).toContain("license.activate");
|
||||||
|
expect(html).toContain('disabled=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Activate live without a revocation", () => {
|
||||||
|
machines = hookView({ activation: notActivated });
|
||||||
|
expect(render()).not.toContain('disabled=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not repeat the revocation under Machines", () => {
|
||||||
|
const revokedError = parseLicenseError('{"code":"license_revoked"}');
|
||||||
|
machines = hookView(
|
||||||
|
{ activation: notActivated, activationError: revokedError, listOpen: true, listError: revokedError },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(render().split("license.errors.licenseRevoked")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LicenseCard machines", () => {
|
||||||
|
it("counts the activated machines against the key's limit", () => {
|
||||||
|
machines = hookView(loadedList([machine("a"), machine("b")]));
|
||||||
|
expect(render()).toContain("license.machines.count[count=2,limit=3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no count without a stored key (account Premium)", () => {
|
||||||
|
context = { ...context, edition: "premium", info: null };
|
||||||
|
machines = hookView(loadedList([machine("a")]));
|
||||||
|
expect(render()).not.toContain("license.machines.count");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an empty list say so on its own line", () => {
|
||||||
|
machines = hookView(loadedList([]));
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.machines.noMachines");
|
||||||
|
expect(html).not.toContain("license.machines.count");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks this machine as not activated next to Activate", () => {
|
||||||
|
machines = hookView({ activation: notActivated });
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.machines.notActivated");
|
||||||
|
expect(html).toContain("license.activate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the buttons while an activation or a deactivation runs", () => {
|
||||||
|
machines = hookView({ activation: notActivated, activating: true });
|
||||||
|
expect(render()).toContain("license.machines.activating");
|
||||||
|
|
||||||
|
machines = hookView({ ...loadedList([machine("a")]), deactivatingId: "a" });
|
||||||
|
expect(render()).toContain("license.machines.deactivating");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LicenseCard dates", () => {
|
||||||
|
it("shows no expiry date for a 100-year key", () => {
|
||||||
|
context = {
|
||||||
|
...context,
|
||||||
|
info: { ...(context.info as object), expires_at: nowS() + 36525 * DAY_S },
|
||||||
|
};
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("license.noExpiry");
|
||||||
|
expect(html).not.toContain("license.expiresAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["fr", "fr-CA"],
|
||||||
|
["en", "en-CA"],
|
||||||
|
])("formats every date for the app language (%s → %s)", (lng, locale) => {
|
||||||
|
language = lng;
|
||||||
|
machines = hookView(loadedList([machine("a")]));
|
||||||
|
const spy = vi.spyOn(Date.prototype, "toLocaleDateString");
|
||||||
|
try {
|
||||||
|
const html = render();
|
||||||
|
const locales = spy.mock.calls.map((call) => call[0]);
|
||||||
|
// The expiry date and the machine's activation date.
|
||||||
|
expect(locales).toHaveLength(2);
|
||||||
|
expect(locales).toEqual([locale, locale]);
|
||||||
|
expect(html).toContain("license.expiresAt");
|
||||||
|
expect(html).toContain("2036-09-26");
|
||||||
|
expect(html).toContain("2026-09-01");
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,9 +4,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useLicenseContext } from "../../contexts/LicenseContext";
|
import { useLicenseContext } from "../../contexts/LicenseContext";
|
||||||
import { useLicenseMachines } from "../../hooks/useLicenseMachines";
|
import { useLicenseMachines } from "../../hooks/useLicenseMachines";
|
||||||
import type { LicenseError } from "../../services/licenseService";
|
import { parseLicenseError, type LicenseError } from "../../services/licenseService";
|
||||||
import { purchaseUrl } from "../../shared/purchaseUrl";
|
import { purchaseUrl } from "../../shared/purchaseUrl";
|
||||||
|
import { licenseExpiryDisplay } from "../../shared/licenseExpiry";
|
||||||
import { logError } from "../../services/logService";
|
import { logError } from "../../services/logService";
|
||||||
|
import LicenseKeyForm from "./LicenseKeyForm";
|
||||||
|
|
||||||
export default function LicenseCard() {
|
export default function LicenseCard() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -18,6 +20,7 @@ export default function LicenseCard() {
|
||||||
const hasLicense = edition !== "free";
|
const hasLicense = edition !== "free";
|
||||||
const {
|
const {
|
||||||
state: machines,
|
state: machines,
|
||||||
|
revoked,
|
||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
toggleList,
|
toggleList,
|
||||||
|
|
@ -26,6 +29,8 @@ export default function LicenseCard() {
|
||||||
canActivate,
|
canActivate,
|
||||||
} = useLicenseMachines(hasLicense);
|
} = useLicenseMachines(hasLicense);
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = keyInput.trim();
|
const trimmed = keyInput.trim();
|
||||||
|
|
@ -43,13 +48,19 @@ export default function LicenseCard() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiry = (timestamp: number) => {
|
|
||||||
return new Date(timestamp * 1000).toLocaleDateString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
|
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
|
||||||
|
|
||||||
|
// A revoked key is named once, at card level (#347): not repeated under Machines.
|
||||||
|
const shownUnderMachines = (err: LicenseError | null) =>
|
||||||
|
err && err.code !== "license_revoked" ? err : null;
|
||||||
|
const activationError = shownUnderMachines(machines.activationError);
|
||||||
|
const listError = shownUnderMachines(machines.listError);
|
||||||
|
|
||||||
const editionLabel = t(`license.editions.${edition}`);
|
const editionLabel = t(`license.editions.${edition}`);
|
||||||
|
const expiry =
|
||||||
|
info && info.expires_at > 0
|
||||||
|
? licenseExpiryDisplay(info.expires_at, Math.floor(Date.now() / 1000), dateLocale)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
|
@ -65,25 +76,41 @@ export default function LicenseCard() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-medium">
|
<p className="text-base font-medium">
|
||||||
{editionLabel}
|
{editionLabel}
|
||||||
{edition !== "free" && (
|
{edition !== "free" &&
|
||||||
|
(revoked ? (
|
||||||
|
<AlertCircle size={16} className="inline ml-2 text-[var(--negative)]" />
|
||||||
|
) : (
|
||||||
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
||||||
)}
|
))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{info && info.expires_at > 0 && (
|
{expiry && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
{expiry.kind === "date" ? (
|
||||||
|
<>
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{t("license.expiresAt")}
|
{t("license.expiresAt")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{formatExpiry(info.expires_at)}</p>
|
<p className="text-sm">{expiry.text}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{t("license.noExpiry")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{revoked && (
|
||||||
|
<div role="alert" className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t("license.errors.licenseRevoked")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{licenseStatus === "error" && error && (
|
{licenseStatus === "error" && error && (
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
<p>{error}</p>
|
<p>{errorMessage(parseLicenseError(error))}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -110,42 +137,17 @@ export default function LicenseCard() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<LicenseKeyForm
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={keyInput}
|
value={keyInput}
|
||||||
onChange={(e) => setKeyInput(e.target.value)}
|
onChange={setKeyInput}
|
||||||
placeholder={t("license.keyPlaceholder")}
|
onSubmit={handleSubmit}
|
||||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
onCancel={() => {
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{validationError && (
|
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
||||||
<p>{validationError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={validating || !keyInput.trim()}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{validating && <Loader2 size={14} className="animate-spin" />}
|
|
||||||
{t("license.activate")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowInput(false);
|
setShowInput(false);
|
||||||
setKeyInput("");
|
setKeyInput("");
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
|
validating={validating}
|
||||||
>
|
validationError={validationError}
|
||||||
{t("common.cancel")}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLicense && (
|
{hasLicense && (
|
||||||
|
|
@ -157,6 +159,10 @@ export default function LicenseCard() {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{machines.activation && !machines.activation.is_activated && (
|
{machines.activation && !machines.activation.is_activated && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("license.machines.notActivated")}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void activate()}
|
onClick={() => void activate()}
|
||||||
|
|
@ -164,8 +170,9 @@ export default function LicenseCard() {
|
||||||
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
|
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{machines.activating && <Loader2 size={12} className="animate-spin" />}
|
{machines.activating && <Loader2 size={12} className="animate-spin" />}
|
||||||
{t("license.activate")}
|
{machines.activating ? t("license.machines.activating") : t("license.activate")}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{machines.activation?.is_activated && (
|
{machines.activation?.is_activated && (
|
||||||
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
||||||
|
|
@ -183,10 +190,10 @@ export default function LicenseCard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{machines.activationError && (
|
{activationError && (
|
||||||
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
||||||
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||||
<p>{errorMessage(machines.activationError)}</p>
|
<p>{errorMessage(activationError)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -198,10 +205,10 @@ export default function LicenseCard() {
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{machines.listError && (
|
{listError && (
|
||||||
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
||||||
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||||
<p>{errorMessage(machines.listError)}</p>
|
<p>{errorMessage(listError)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showEmptyState && (
|
{showEmptyState && (
|
||||||
|
|
@ -209,8 +216,22 @@ export default function LicenseCard() {
|
||||||
{t("license.machines.noMachines")}
|
{t("license.machines.noMachines")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{/* The limit comes from the signed key: no count without one (an account
|
||||||
|
Premium has no stored key). An empty list says so on its own line. */}
|
||||||
|
{info &&
|
||||||
|
machines.listLoaded &&
|
||||||
|
machines.listError === null &&
|
||||||
|
machines.machines.length > 0 && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("license.machines.count", {
|
||||||
|
count: machines.machines.length,
|
||||||
|
limit: info.machine_limit,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{machines.machines.map((m) => {
|
{machines.machines.map((m) => {
|
||||||
const isThis = machines.activation?.machine_id === m.machine_id;
|
const isThis = machines.activation?.machine_id === m.machine_id;
|
||||||
|
const deactivating = machines.deactivatingId === m.machine_id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.machine_id}
|
key={m.machine_id}
|
||||||
|
|
@ -226,7 +247,7 @@ export default function LicenseCard() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{new Date(m.activated_at).toLocaleDateString()}
|
{new Date(m.activated_at).toLocaleDateString(dateLocale)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -235,10 +256,10 @@ export default function LicenseCard() {
|
||||||
disabled={!canDeactivate}
|
disabled={!canDeactivate}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{machines.deactivatingId === m.machine_id && (
|
{deactivating && <Loader2 size={10} className="animate-spin" />}
|
||||||
<Loader2 size={10} className="animate-spin" />
|
{deactivating
|
||||||
)}
|
? t("license.machines.deactivating")
|
||||||
{t("license.machines.deactivate")}
|
: t("license.machines.deactivate")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
45
src/components/settings/LicenseKeyForm.render.test.tsx
Normal file
45
src/components/settings/LicenseKeyForm.render.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Render tests for the key entry form of the License card (#347). No jsdom needed:
|
||||||
|
// react-dom/server renders in vitest's node environment, and `t` echoes its key.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LicenseKeyForm from "./LicenseKeyForm";
|
||||||
|
|
||||||
|
function render(validationError: string | null): string {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<LicenseKeyForm
|
||||||
|
value="SR-BASE-x"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
validating={false}
|
||||||
|
validationError={validationError}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LicenseKeyForm", () => {
|
||||||
|
it("names a refused key in the app language", () => {
|
||||||
|
const html = render('{"code":"invalid_prefix"}');
|
||||||
|
expect(html).toContain("license.errors.invalidPrefix");
|
||||||
|
expect(html).not.toContain("{"code"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never prints a raw backend string", () => {
|
||||||
|
const html = render("License key must start with SR-BASE- or SR-PREMIUM-");
|
||||||
|
expect(html).toContain("license.errors.unknown");
|
||||||
|
expect(html).not.toContain("License key must start");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no error line before a submission is refused", () => {
|
||||||
|
expect(render(null)).not.toContain("license.errors.");
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/components/settings/LicenseKeyForm.tsx
Normal file
69
src/components/settings/LicenseKeyForm.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { parseLicenseError } from "../../services/licenseService";
|
||||||
|
|
||||||
|
interface LicenseKeyFormProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
validating: boolean;
|
||||||
|
/**
|
||||||
|
* Raw rejection of the last key submission, as LicenseContext keeps it, or null.
|
||||||
|
* Required so the card cannot drop it (#347).
|
||||||
|
*/
|
||||||
|
validationError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key entry form of the License card (#347). Display only: the card owns the input
|
||||||
|
* and the submission. A refused key is named in the app language through
|
||||||
|
* `parseLicenseError`, never shown as the raw string the command returned.
|
||||||
|
*/
|
||||||
|
export default function LicenseKeyForm({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
validating,
|
||||||
|
validationError,
|
||||||
|
}: LicenseKeyFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const error = validationError === null ? null : parseLicenseError(validationError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={t("license.keyPlaceholder")}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t(error.i18nKey, error.params)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={validating || !value.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{validating && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t("license.activate")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,58 @@ describe("licenseReducer — key validation is orthogonal to the load lifecycle"
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A `license_revoked` answer stays known for the whole session (#347, D13): the card
|
||||||
|
// that receives it unmounts when the user leaves Settings → Users or switches profile,
|
||||||
|
// this state does not. Only a different key clears it.
|
||||||
|
describe("licenseReducer — revocation lasts the session", () => {
|
||||||
|
const revoked = licenseReducer(premiumReady, { type: "LICENSE_REVOKED" });
|
||||||
|
|
||||||
|
it("LICENSE_REVOKED flags the stored key without touching the edition", () => {
|
||||||
|
expect(revoked.revoked).toBe(true);
|
||||||
|
expect(revoked.edition).toBe("premium");
|
||||||
|
expect(revoked.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a reload of the license and a load error", () => {
|
||||||
|
const reloaded = licenseReducer(
|
||||||
|
licenseReducer(revoked, { type: "LOAD_START" }),
|
||||||
|
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
|
||||||
|
);
|
||||||
|
expect(reloaded.revoked).toBe(true);
|
||||||
|
const failed = licenseReducer(licenseReducer(revoked, { type: "LOAD_START" }), {
|
||||||
|
type: "LOAD_ERROR",
|
||||||
|
error: "boot fail",
|
||||||
|
});
|
||||||
|
expect(failed.revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a refused key", () => {
|
||||||
|
const refused = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||||
|
type: "VALIDATE_ERROR",
|
||||||
|
error: '{"code":"invalid_prefix"}',
|
||||||
|
});
|
||||||
|
expect(refused.revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays when the same key is entered again", () => {
|
||||||
|
const same = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||||
|
type: "VALIDATE_DONE",
|
||||||
|
info: makeInfo("premium"),
|
||||||
|
});
|
||||||
|
expect(same.revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears when a different key is stored", () => {
|
||||||
|
const other: LicenseInfo = { ...makeInfo("premium"), issued_at: 2 };
|
||||||
|
const replaced = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||||
|
type: "VALIDATE_DONE",
|
||||||
|
info: other,
|
||||||
|
});
|
||||||
|
expect(replaced.revoked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ import {
|
||||||
* `validationError` instead. The retry loop keys off `status === "error"`, so
|
* `validationError` instead. The retry loop keys off `status === "error"`, so
|
||||||
* it can never arm on a bad key and auto-clear the message the user is reading,
|
* it can never arm on a bad key and auto-clear the message the user is reading,
|
||||||
* and `ready` consumers never regress on a typo'd key.
|
* and `ready` consumers never regress on a typo'd key.
|
||||||
|
*
|
||||||
|
* Revocation (#347, D13): a `license_revoked` answer from the license server is
|
||||||
|
* recorded here for the rest of the session. The License card that receives it
|
||||||
|
* unmounts when the user leaves Settings → Users or switches profile; this
|
||||||
|
* provider does not. Session only — never persisted — and it never touches the
|
||||||
|
* edition, which keeps following the local validation of the key (ADR 0017).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type LicenseStatus = "idle" | "loading" | "ready" | "error";
|
type LicenseStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
@ -47,6 +53,8 @@ interface LicenseState {
|
||||||
validating: boolean;
|
validating: boolean;
|
||||||
/** Key-submission error — never retried, cleared on the next submit. */
|
/** Key-submission error — never retried, cleared on the next submit. */
|
||||||
validationError: string | null;
|
validationError: string | null;
|
||||||
|
/** The server answered `license_revoked` for the stored key during this session. */
|
||||||
|
revoked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LicenseAction =
|
type LicenseAction =
|
||||||
|
|
@ -55,7 +63,8 @@ type LicenseAction =
|
||||||
| { type: "LOAD_ERROR"; error: string }
|
| { type: "LOAD_ERROR"; error: string }
|
||||||
| { type: "VALIDATE_START" }
|
| { type: "VALIDATE_START" }
|
||||||
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
||||||
| { type: "VALIDATE_ERROR"; error: string };
|
| { type: "VALIDATE_ERROR"; error: string }
|
||||||
|
| { type: "LICENSE_REVOKED" };
|
||||||
|
|
||||||
/** Exported for unit tests only — not part of the context API. */
|
/** Exported for unit tests only — not part of the context API. */
|
||||||
export const initialLicenseState: LicenseState = {
|
export const initialLicenseState: LicenseState = {
|
||||||
|
|
@ -65,8 +74,20 @@ export const initialLicenseState: LicenseState = {
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Same license, as far as the client can tell: a re-entered key, not a replacement. */
|
||||||
|
function isSameLicense(a: LicenseInfo | null, b: LicenseInfo): boolean {
|
||||||
|
return (
|
||||||
|
a !== null &&
|
||||||
|
a.email === b.email &&
|
||||||
|
a.edition === b.edition &&
|
||||||
|
a.issued_at === b.issued_at &&
|
||||||
|
a.expires_at === b.expires_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Exported for unit tests only — not part of the context API. */
|
/** Exported for unit tests only — not part of the context API. */
|
||||||
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
|
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
|
@ -88,11 +109,15 @@ export function licenseReducer(state: LicenseState, action: LicenseAction): Lice
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
// A replacement key starts clean; the same key re-entered stays revoked.
|
||||||
|
revoked: state.revoked && isSameLicense(state.info, action.info),
|
||||||
};
|
};
|
||||||
case "VALIDATE_ERROR":
|
case "VALIDATE_ERROR":
|
||||||
// Status untouched: "ready" stays ready (no gating flash on a typo'd
|
// Status untouched: "ready" stays ready (no gating flash on a typo'd
|
||||||
// key), and a boot-error retry loop keeps running through the failure.
|
// key), and a boot-error retry loop keeps running through the failure.
|
||||||
return { ...state, validating: false, validationError: action.error };
|
return { ...state, validating: false, validationError: action.error };
|
||||||
|
case "LICENSE_REVOKED":
|
||||||
|
return { ...state, revoked: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,8 +133,12 @@ interface LicenseContextValue {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
validating: boolean;
|
validating: boolean;
|
||||||
validationError: string | null;
|
validationError: string | null;
|
||||||
|
/** The server answered `license_revoked` during this session (#347). */
|
||||||
|
revoked: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
||||||
|
/** Records a `license_revoked` answer for the rest of the session. Stable across renders. */
|
||||||
|
markRevoked: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LicenseContext = createContext<LicenseContextValue | null>(null);
|
const LicenseContext = createContext<LicenseContextValue | null>(null);
|
||||||
|
|
@ -148,6 +177,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markRevoked = useCallback(() => {
|
||||||
|
dispatch({ type: "LICENSE_REVOKED" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load once at boot.
|
// Load once at boot.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
@ -178,8 +211,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||||
error: state.error,
|
error: state.error,
|
||||||
validating: state.validating,
|
validating: state.validating,
|
||||||
validationError: state.validationError,
|
validationError: state.validationError,
|
||||||
|
revoked: state.revoked,
|
||||||
refresh,
|
refresh,
|
||||||
submitKey,
|
submitKey,
|
||||||
|
markRevoked,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ describe("useIsPremium", () => {
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
markRevoked: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(true);
|
expect(useIsPremium()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -35,8 +37,10 @@ describe("useIsPremium", () => {
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
markRevoked: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -50,8 +54,10 @@ describe("useIsPremium", () => {
|
||||||
error: null,
|
error: null,
|
||||||
validating: false,
|
validating: false,
|
||||||
validationError: null,
|
validationError: null,
|
||||||
|
revoked: false,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
|
markRevoked: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
licenseMachinesReducer,
|
licenseMachinesReducer,
|
||||||
initialLicenseMachinesState,
|
initialLicenseMachinesState,
|
||||||
|
|
@ -10,6 +13,7 @@ import {
|
||||||
performDeactivate,
|
performDeactivate,
|
||||||
loadMachines,
|
loadMachines,
|
||||||
loadActivationStatus,
|
loadActivationStatus,
|
||||||
|
useLicenseMachines,
|
||||||
type LicenseMachinesAction,
|
type LicenseMachinesAction,
|
||||||
type LicenseMachinesDeps,
|
type LicenseMachinesDeps,
|
||||||
type LicenseMachinesState,
|
type LicenseMachinesState,
|
||||||
|
|
@ -23,6 +27,11 @@ import {
|
||||||
type MachineInfo,
|
type MachineInfo,
|
||||||
} from "../services/licenseService";
|
} from "../services/licenseService";
|
||||||
|
|
||||||
|
// The hook itself reads the LicenseContext; the pure functions below never do.
|
||||||
|
const context = { refresh: vi.fn(async () => {}), revoked: false, markRevoked: vi.fn() };
|
||||||
|
vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context }));
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||||
|
|
||||||
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 }));
|
||||||
|
|
||||||
|
|
@ -223,11 +232,11 @@ describe("selectCanDeactivate", () => {
|
||||||
|
|
||||||
describe("selectCanActivate", () => {
|
describe("selectCanActivate", () => {
|
||||||
it("allows an activation when nothing is running", () => {
|
it("allows an activation when nothing is running", () => {
|
||||||
expect(selectCanActivate(initialLicenseMachinesState)).toBe(true);
|
expect(selectCanActivate(initialLicenseMachinesState, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks the button while an activation is running", () => {
|
it("blocks the button while an activation is running", () => {
|
||||||
expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]))).toBe(false);
|
expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]), false)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks the button while a deactivation is running", () => {
|
it("blocks the button while a deactivation is running", () => {
|
||||||
|
|
@ -241,14 +250,19 @@ describe("selectCanActivate", () => {
|
||||||
],
|
],
|
||||||
limitHit,
|
limitHit,
|
||||||
);
|
);
|
||||||
expect(selectCanActivate(s)).toBe(false);
|
expect(selectCanActivate(s, false)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks the button while the list loads", () => {
|
it("blocks the button while the list loads", () => {
|
||||||
// Otherwise a verify answer landing after a successful activation would
|
// Otherwise a verify answer landing after a successful activation would
|
||||||
// mark as fresh a list that no longer includes this machine.
|
// mark as fresh a list that no longer includes this machine.
|
||||||
const loading = reduce([{ type: "TOGGLE_LIST" }, { type: "LIST_LOAD_START" }]);
|
const loading = reduce([{ type: "TOGGLE_LIST" }, { type: "LIST_LOAD_START" }]);
|
||||||
expect(selectCanActivate(loading)).toBe(false);
|
expect(selectCanActivate(loading, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the button for a key revoked during the session", () => {
|
||||||
|
// Every attempt would get a 403 and spend the shared rate budget (#347).
|
||||||
|
expect(selectCanActivate(initialLicenseMachinesState, true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -288,6 +302,7 @@ function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
||||||
),
|
),
|
||||||
getActivationStatus: vi.fn(async () => ({ is_activated: true, machine_id: "this" })),
|
getActivationStatus: vi.fn(async () => ({ is_activated: true, machine_id: "this" })),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
|
markRevoked: vi.fn(),
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
state = licenseMachinesReducer(state, action);
|
state = licenseMachinesReducer(state, action);
|
||||||
},
|
},
|
||||||
|
|
@ -340,7 +355,7 @@ describe("machine-limit path (D2) end to end", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("performActivate", () => {
|
describe("performActivate", () => {
|
||||||
it("refreshes the edition after a successful activation, without reloading the list", async () => {
|
it("reloads the license context after a successful activation, without reloading the list", async () => {
|
||||||
const { deps, getState } = harness();
|
const { deps, getState } = harness();
|
||||||
await performActivate(deps);
|
await performActivate(deps);
|
||||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -421,3 +436,80 @@ describe("performDeactivate", () => {
|
||||||
expect(deps.refresh).not.toHaveBeenCalled();
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A revoked key stays flagged for the session through the LicenseContext (#347), and
|
||||||
|
// the three server calls can each be the one that learns it.
|
||||||
|
describe("revocation is recorded for the session", () => {
|
||||||
|
const revokedResult = { ok: false as const, error: err("license_revoked") };
|
||||||
|
|
||||||
|
it("from an activation", async () => {
|
||||||
|
const { deps } = harness({ activateMachine: vi.fn(async () => revokedResult) });
|
||||||
|
await performActivate(deps);
|
||||||
|
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("from the machine list", async () => {
|
||||||
|
const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => revokedResult) });
|
||||||
|
await loadMachines(deps);
|
||||||
|
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getState().listError?.code).toBe("license_revoked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("from a deactivation", async () => {
|
||||||
|
const { deps } = harness({ deactivateMachine: vi.fn(async () => revokedResult) });
|
||||||
|
await performDeactivate(deps, "a");
|
||||||
|
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never from another refusal or a success", async () => {
|
||||||
|
const { deps } = harness({
|
||||||
|
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })),
|
||||||
|
listActivatedMachines: vi.fn(async () => ({ ok: false as const, error: err("network") })),
|
||||||
|
});
|
||||||
|
await performActivate(deps);
|
||||||
|
await loadMachines(deps);
|
||||||
|
await performDeactivate(deps, "a");
|
||||||
|
expect(deps.markRevoked).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hook glues the pure functions to the LicenseContext: rendered once on the
|
||||||
|
// server, with the context faked, to check both directions of that glue.
|
||||||
|
describe("useLicenseMachines wiring to the LicenseContext", () => {
|
||||||
|
function renderHook(): ReturnType<typeof useLicenseMachines> {
|
||||||
|
let view: ReturnType<typeof useLicenseMachines> | undefined;
|
||||||
|
function Probe() {
|
||||||
|
view = useLicenseMachines(true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
renderToStaticMarkup(createElement(Probe));
|
||||||
|
if (!view) throw new Error("the probe did not render");
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
context.revoked = false;
|
||||||
|
context.markRevoked.mockClear();
|
||||||
|
vi.mocked(invoke).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the session revocation and blocks Activate", () => {
|
||||||
|
context.revoked = true;
|
||||||
|
const view = renderHook();
|
||||||
|
expect(view.revoked).toBe(true);
|
||||||
|
expect(view.canActivate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Activate without a revocation", () => {
|
||||||
|
const view = renderHook();
|
||||||
|
expect(view.revoked).toBe(false);
|
||||||
|
expect(view.canActivate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the context's markRevoked to the server calls", async () => {
|
||||||
|
vi.mocked(invoke).mockRejectedValue('{"code":"license_revoked"}');
|
||||||
|
const view = renderHook();
|
||||||
|
await view.activate();
|
||||||
|
expect(context.markRevoked).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ import { useLicenseContext } from "../contexts/LicenseContext";
|
||||||
// a successful deactivation removes the machine locally instead of re-running
|
// a successful deactivation removes the machine locally instead of re-running
|
||||||
// verify, and nothing loads the list from an effect (StrictMode would double
|
// verify, and nothing loads the list from an effect (StrictMode would double
|
||||||
// it). List loads come only from explicit handlers.
|
// it). List loads come only from explicit handlers.
|
||||||
|
//
|
||||||
|
// A `license_revoked` answer, from any of the three calls, is recorded in the
|
||||||
|
// LicenseContext for the rest of the session (#347): this hook's own state dies
|
||||||
|
// with the card when the user leaves Settings → Users or switches profile.
|
||||||
|
|
||||||
export interface LicenseMachinesState {
|
export interface LicenseMachinesState {
|
||||||
activation: ActivationStatus | null;
|
activation: ActivationStatus | null;
|
||||||
|
|
@ -136,13 +140,15 @@ export function selectCanDeactivate(state: LicenseMachinesState): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Activate button is live only when no activation, no list reload and no
|
* The Activate button is live only when the key is not revoked and no activation, no
|
||||||
* deactivation is running. A verify answer landing after a successful activation would
|
* list reload and no deactivation is running. A revoked key gets a 403 on every attempt,
|
||||||
* mark as fresh a list that no longer includes this machine; an activation racing a
|
* each one spending the shared rate budget. A verify answer landing after a successful
|
||||||
* deactivation could clear the machine-limit message and spend the rate budget.
|
* activation would mark as fresh a list that no longer includes this machine; an
|
||||||
|
* activation racing a deactivation could clear the machine-limit message and spend the
|
||||||
|
* rate budget.
|
||||||
*/
|
*/
|
||||||
export function selectCanActivate(state: LicenseMachinesState): boolean {
|
export function selectCanActivate(state: LicenseMachinesState, revoked: boolean): boolean {
|
||||||
return !state.activating && !state.listLoading && state.deactivatingId === null;
|
return !revoked && !state.activating && !state.listLoading && 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. */
|
||||||
|
|
@ -156,9 +162,16 @@ export interface LicenseMachinesDeps {
|
||||||
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
||||||
getActivationStatus: () => Promise<ActivationStatus>;
|
getActivationStatus: () => Promise<ActivationStatus>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
/** Records a `license_revoked` answer in the LicenseContext for the session. */
|
||||||
|
markRevoked: () => void;
|
||||||
dispatch: (action: LicenseMachinesAction) => void;
|
dispatch: (action: LicenseMachinesAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A revoked key stays flagged for the session, whichever call learned it. */
|
||||||
|
function noteRevocation(deps: LicenseMachinesDeps, error: LicenseError): void {
|
||||||
|
if (error.code === "license_revoked") deps.markRevoked();
|
||||||
|
}
|
||||||
|
|
||||||
/** Local read (no network). Best effort: a failure keeps the last known status. */
|
/** Local read (no network). Best effort: a failure keeps the last known status. */
|
||||||
export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise<void> {
|
export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -172,11 +185,12 @@ export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise<v
|
||||||
export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
deps.dispatch({ type: "LIST_LOAD_START" });
|
deps.dispatch({ type: "LIST_LOAD_START" });
|
||||||
const result = await deps.listActivatedMachines();
|
const result = await deps.listActivatedMachines();
|
||||||
deps.dispatch(
|
if (!result.ok) {
|
||||||
result.ok
|
deps.dispatch({ type: "LIST_LOAD_ERROR", error: result.error });
|
||||||
? { type: "LIST_LOAD_DONE", machines: result.value }
|
noteRevocation(deps, result.error);
|
||||||
: { type: "LIST_LOAD_ERROR", error: result.error },
|
return;
|
||||||
);
|
}
|
||||||
|
deps.dispatch({ type: "LIST_LOAD_DONE", machines: result.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
|
|
@ -184,6 +198,7 @@ export async function performActivate(deps: LicenseMachinesDeps): Promise<void>
|
||||||
const result = await deps.activateMachine();
|
const result = await deps.activateMachine();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
|
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
|
||||||
|
noteRevocation(deps, result.error);
|
||||||
if (result.error.code === "machine_limit_reached") {
|
if (result.error.code === "machine_limit_reached") {
|
||||||
await loadMachines(deps);
|
await loadMachines(deps);
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +206,8 @@ export async function performActivate(deps: LicenseMachinesDeps): Promise<void>
|
||||||
}
|
}
|
||||||
deps.dispatch({ type: "ACTIVATE_DONE" });
|
deps.dispatch({ type: "ACTIVATE_DONE" });
|
||||||
await loadActivationStatus(deps);
|
await loadActivationStatus(deps);
|
||||||
// The edition shown by the app follows without a restart.
|
// Reloads the license context after the server call. Since #347 (D12) activation no
|
||||||
|
// longer changes the edition: the key alone decides it.
|
||||||
await deps.refresh();
|
await deps.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,11 +216,13 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
|
||||||
const result = await deps.deactivateMachine(machineId);
|
const result = await deps.deactivateMachine(machineId);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
|
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
|
||||||
|
noteRevocation(deps, result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Removed locally, no verify call: see the rate budget note at the top.
|
// Removed locally, no verify call: see the rate budget note at the top.
|
||||||
deps.dispatch({ type: "DEACTIVATE_DONE", machineId });
|
deps.dispatch({ type: "DEACTIVATE_DONE", machineId });
|
||||||
await loadActivationStatus(deps);
|
await loadActivationStatus(deps);
|
||||||
|
// Same reload as after an activation; the edition does not change either (#347, D12).
|
||||||
await deps.refresh();
|
await deps.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +231,7 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
|
||||||
* free edition, where the card shows no machine section.
|
* free edition, where the card shows no machine section.
|
||||||
*/
|
*/
|
||||||
export function useLicenseMachines(enabled: boolean) {
|
export function useLicenseMachines(enabled: boolean) {
|
||||||
const { refresh } = useLicenseContext();
|
const { refresh, revoked, markRevoked } = useLicenseContext();
|
||||||
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
|
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
|
||||||
|
|
||||||
const deps = useMemo<LicenseMachinesDeps>(
|
const deps = useMemo<LicenseMachinesDeps>(
|
||||||
|
|
@ -223,9 +241,10 @@ export function useLicenseMachines(enabled: boolean) {
|
||||||
listActivatedMachines,
|
listActivatedMachines,
|
||||||
getActivationStatus,
|
getActivationStatus,
|
||||||
refresh,
|
refresh,
|
||||||
|
markRevoked,
|
||||||
dispatch,
|
dispatch,
|
||||||
}),
|
}),
|
||||||
[refresh],
|
[refresh, markRevoked],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Local activation status only (get_activation_status reads the token on
|
// Local activation status only (get_activation_status reads the token on
|
||||||
|
|
@ -244,11 +263,13 @@ export function useLicenseMachines(enabled: boolean) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
/** The key was revoked during this session (LicenseContext, #347). */
|
||||||
|
revoked,
|
||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
toggleList,
|
toggleList,
|
||||||
showEmptyState: selectShowEmptyState(state),
|
showEmptyState: selectShowEmptyState(state),
|
||||||
canDeactivate: selectCanDeactivate(state),
|
canDeactivate: selectCanDeactivate(state),
|
||||||
canActivate: selectCanActivate(state),
|
canActivate: selectCanActivate(state, revoked),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1118,10 +1118,14 @@
|
||||||
"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 key, machine change), 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 or invalid 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",
|
||||||
|
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
||||||
|
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer or an expired activation, the card shows \"Not activated\": click \"Activate\"",
|
||||||
|
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
|
||||||
|
"If the license server reports that your key was revoked, the License card says so until the app is closed and no longer offers \"Activate\""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1184,6 +1188,7 @@
|
||||||
"title": "License",
|
"title": "License",
|
||||||
"currentEdition": "Current edition",
|
"currentEdition": "Current edition",
|
||||||
"expiresAt": "Expires on",
|
"expiresAt": "Expires on",
|
||||||
|
"noExpiry": "No expiry date",
|
||||||
"enterKey": "Enter a license key",
|
"enterKey": "Enter a license key",
|
||||||
"keyPlaceholder": "SR-BASE-...",
|
"keyPlaceholder": "SR-BASE-...",
|
||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
|
|
@ -1193,7 +1198,6 @@
|
||||||
"base": "Base",
|
"base": "Base",
|
||||||
"premium": "Premium"
|
"premium": "Premium"
|
||||||
},
|
},
|
||||||
"removeLicense": "Remove license",
|
|
||||||
"machines": {
|
"machines": {
|
||||||
"title": "Machines",
|
"title": "Machines",
|
||||||
"count": "{{count}}/{{limit}} machines activated",
|
"count": "{{count}}/{{limit}} machines activated",
|
||||||
|
|
@ -1202,7 +1206,6 @@
|
||||||
"notActivated": "Not activated",
|
"notActivated": "Not activated",
|
||||||
"deactivate": "Deactivate",
|
"deactivate": "Deactivate",
|
||||||
"deactivating": "Deactivating...",
|
"deactivating": "Deactivating...",
|
||||||
"activateError": "Activation failed",
|
|
||||||
"thisMachine": "This machine",
|
"thisMachine": "This machine",
|
||||||
"noMachines": "No machines activated"
|
"noMachines": "No machines activated"
|
||||||
},
|
},
|
||||||
|
|
@ -1213,6 +1216,10 @@
|
||||||
"machineLimitReached": "This license has reached its maximum number of machines. Deactivate a machine in the list to free a slot.",
|
"machineLimitReached": "This license has reached its maximum number of machines. Deactivate a machine in the list to free a slot.",
|
||||||
"noActiveActivation": "This machine is not activated for this license.",
|
"noActiveActivation": "This machine is not activated for this license.",
|
||||||
"invalidInput": "The request sent to the license server is invalid.",
|
"invalidInput": "The request sent to the license server is invalid.",
|
||||||
|
"invalidPrefix": "A license key starts with SR-BASE- or SR-PREMIUM-. Check that it was copied in full.",
|
||||||
|
"invalidSignature": "This license key was not issued by the Simpl'Result license server.",
|
||||||
|
"malformed": "This license key cannot be read. Check that it was copied in full, without spaces or line breaks.",
|
||||||
|
"unknownEdition": "This license key is for an edition this version of the app does not know. Update the app, then enter the key again.",
|
||||||
"rateLimit_one": "Too many attempts. Try again in {{count}} second.",
|
"rateLimit_one": "Too many attempts. Try again in {{count}} second.",
|
||||||
"rateLimit_other": "Too many attempts. Try again in {{count}} seconds.",
|
"rateLimit_other": "Too many attempts. Try again in {{count}} seconds.",
|
||||||
"network": "Cannot reach the license server. Check your internet connection.",
|
"network": "Cannot reach the license server. Check your internet connection.",
|
||||||
|
|
@ -2087,7 +2094,7 @@
|
||||||
"invalidSymbol": "Invalid symbol",
|
"invalidSymbol": "Invalid symbol",
|
||||||
"invalidDate": "Invalid date",
|
"invalidDate": "Invalid date",
|
||||||
"missingParam": "Missing parameter",
|
"missingParam": "Missing parameter",
|
||||||
"authFailed": "Authentication failed — check your license",
|
"authFailed": "Authentication failed — activate this machine in Settings → Users",
|
||||||
"premiumRequired": "This feature requires a premium subscription",
|
"premiumRequired": "This feature requires a premium subscription",
|
||||||
"licenseRevoked": "License revoked",
|
"licenseRevoked": "License revoked",
|
||||||
"symbolNotFound": "Symbol not found",
|
"symbolNotFound": "Symbol not found",
|
||||||
|
|
|
||||||
|
|
@ -1118,10 +1118,14 @@
|
||||||
"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, changement de machine), 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 ou invalide), 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",
|
||||||
|
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
||||||
|
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
|
||||||
|
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
|
||||||
|
"Si le serveur de licences signale que votre clé a été révoquée, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1184,6 +1188,7 @@
|
||||||
"title": "Licence",
|
"title": "Licence",
|
||||||
"currentEdition": "Édition actuelle",
|
"currentEdition": "Édition actuelle",
|
||||||
"expiresAt": "Expire le",
|
"expiresAt": "Expire le",
|
||||||
|
"noExpiry": "Sans échéance",
|
||||||
"enterKey": "Entrer une clé de licence",
|
"enterKey": "Entrer une clé de licence",
|
||||||
"keyPlaceholder": "SR-BASE-...",
|
"keyPlaceholder": "SR-BASE-...",
|
||||||
"activate": "Activer",
|
"activate": "Activer",
|
||||||
|
|
@ -1193,7 +1198,6 @@
|
||||||
"base": "Base",
|
"base": "Base",
|
||||||
"premium": "Premium"
|
"premium": "Premium"
|
||||||
},
|
},
|
||||||
"removeLicense": "Supprimer la licence",
|
|
||||||
"machines": {
|
"machines": {
|
||||||
"title": "Machines",
|
"title": "Machines",
|
||||||
"count": "{{count}}/{{limit}} machines activées",
|
"count": "{{count}}/{{limit}} machines activées",
|
||||||
|
|
@ -1202,7 +1206,6 @@
|
||||||
"notActivated": "Non activée",
|
"notActivated": "Non activée",
|
||||||
"deactivate": "Désactiver",
|
"deactivate": "Désactiver",
|
||||||
"deactivating": "Désactivation...",
|
"deactivating": "Désactivation...",
|
||||||
"activateError": "Échec de l'activation",
|
|
||||||
"thisMachine": "Cette machine",
|
"thisMachine": "Cette machine",
|
||||||
"noMachines": "Aucune machine activée"
|
"noMachines": "Aucune machine activée"
|
||||||
},
|
},
|
||||||
|
|
@ -1213,6 +1216,10 @@
|
||||||
"machineLimitReached": "Le nombre maximal de machines pour cette licence est atteint. Désactivez une machine dans la liste pour libérer une place.",
|
"machineLimitReached": "Le nombre maximal de machines pour cette licence est atteint. Désactivez une machine dans la liste pour libérer une place.",
|
||||||
"noActiveActivation": "Cette machine n'est pas activée pour cette licence.",
|
"noActiveActivation": "Cette machine n'est pas activée pour cette licence.",
|
||||||
"invalidInput": "La requête envoyée au serveur de licences est invalide.",
|
"invalidInput": "La requête envoyée au serveur de licences est invalide.",
|
||||||
|
"invalidPrefix": "Une clé de licence commence par SR-BASE- ou SR-PREMIUM-. Vérifiez qu'elle a été copiée en entier.",
|
||||||
|
"invalidSignature": "Cette clé de licence n'a pas été émise par le serveur de licences de Simpl'Résultat.",
|
||||||
|
"malformed": "Cette clé de licence est illisible. Vérifiez qu'elle a été copiée en entier, sans espace ni retour à la ligne.",
|
||||||
|
"unknownEdition": "Cette clé de licence désigne une édition que cette version de l'application ne connaît pas. Mettez l'application à jour, puis entrez la clé de nouveau.",
|
||||||
"rateLimit_one": "Trop de tentatives. Réessayez dans {{count}} seconde.",
|
"rateLimit_one": "Trop de tentatives. Réessayez dans {{count}} seconde.",
|
||||||
"rateLimit_other": "Trop de tentatives. Réessayez dans {{count}} secondes.",
|
"rateLimit_other": "Trop de tentatives. Réessayez dans {{count}} secondes.",
|
||||||
"network": "Impossible de joindre le serveur de licences. Vérifiez votre connexion Internet.",
|
"network": "Impossible de joindre le serveur de licences. Vérifiez votre connexion Internet.",
|
||||||
|
|
@ -2087,7 +2094,7 @@
|
||||||
"invalidSymbol": "Symbole invalide",
|
"invalidSymbol": "Symbole invalide",
|
||||||
"invalidDate": "Date invalide",
|
"invalidDate": "Date invalide",
|
||||||
"missingParam": "Paramètre manquant",
|
"missingParam": "Paramètre manquant",
|
||||||
"authFailed": "Échec d'authentification — vérifie ta licence",
|
"authFailed": "Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs",
|
||||||
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
||||||
"licenseRevoked": "Licence révoquée",
|
"licenseRevoked": "Licence révoquée",
|
||||||
"symbolNotFound": "Symbole introuvable",
|
"symbolNotFound": "Symbole introuvable",
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ describe("isEntitled — features[] override", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("IGNORES the override when edition is free (CWE-863)", () => {
|
it("IGNORES the override when edition is free (CWE-863)", () => {
|
||||||
// A copied key downgrades to free (machine-binding) but still carries its
|
// A key that resolves to free (invalid or expired) still lists its signed
|
||||||
// signed features[]. Those must not be re-granted.
|
// features[]. Those must not be re-granted.
|
||||||
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
|
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
|
||||||
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
|
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ const EXPECTED_KEYS: [Exclude<LicenseErrorCode, "unknown" | "rate_limit">, strin
|
||||||
["machine_limit_reached", "license.errors.machineLimitReached"],
|
["machine_limit_reached", "license.errors.machineLimitReached"],
|
||||||
["no_active_activation", "license.errors.noActiveActivation"],
|
["no_active_activation", "license.errors.noActiveActivation"],
|
||||||
["invalid_input", "license.errors.invalidInput"],
|
["invalid_input", "license.errors.invalidInput"],
|
||||||
|
["invalid_prefix", "license.errors.invalidPrefix"],
|
||||||
|
["invalid_signature", "license.errors.invalidSignature"],
|
||||||
|
["malformed", "license.errors.malformed"],
|
||||||
|
["unknown_edition", "license.errors.unknownEdition"],
|
||||||
["network", "license.errors.network"],
|
["network", "license.errors.network"],
|
||||||
["invalid_response", "license.errors.invalidResponse"],
|
["invalid_response", "license.errors.invalidResponse"],
|
||||||
["server", "license.errors.server"],
|
["server", "license.errors.server"],
|
||||||
|
|
@ -128,9 +132,10 @@ describe("license command wrappers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// No jsdom here, so nothing renders the card: a key missing from a locale
|
// The render tests echo i18n keys instead of translating them: a key missing
|
||||||
// would show up as a raw "license.errors.x" string with the suite green.
|
// from a locale would show up as a raw "license.errors.x" string with the
|
||||||
describe("license.errors i18n coverage (fr + en)", () => {
|
// suite green. Only these checks read the real locale files.
|
||||||
|
describe("license i18n coverage (fr + en)", () => {
|
||||||
const locales: Record<string, unknown> = { fr, en };
|
const locales: Record<string, unknown> = { fr, en };
|
||||||
|
|
||||||
const resolve = (messages: unknown, key: string): unknown =>
|
const resolve = (messages: unknown, key: string): unknown =>
|
||||||
|
|
@ -167,4 +172,39 @@ describe("license.errors i18n coverage (fr + en)", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keys of the License card outside the error map (#347).
|
||||||
|
const CARD_KEYS = [
|
||||||
|
"license.noExpiry",
|
||||||
|
"license.machines.count",
|
||||||
|
"license.machines.activating",
|
||||||
|
"license.machines.deactivating",
|
||||||
|
"license.machines.notActivated",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves every License card key to a non-empty string in both locales", () => {
|
||||||
|
for (const [lng, messages] of Object.entries(locales)) {
|
||||||
|
for (const key of CARD_KEYS) {
|
||||||
|
const value = resolve(messages, key);
|
||||||
|
expect(typeof value, `${key} in ${lng}`).toBe("string");
|
||||||
|
expect((value as string).trim(), `${key} in ${lng}`).not.toBe("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates both numbers of the machine counter", () => {
|
||||||
|
for (const [lng, messages] of Object.entries(locales)) {
|
||||||
|
const counter = resolve(messages, "license.machines.count");
|
||||||
|
expect(counter, lng).toContain("{{count}}");
|
||||||
|
expect(counter, lng).toContain("{{limit}}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer carries the keys #347 removed", () => {
|
||||||
|
for (const [lng, messages] of Object.entries(locales)) {
|
||||||
|
for (const key of ["license.removeLicense", "license.machines.activateError"]) {
|
||||||
|
expect(resolve(messages, key), `${key} in ${lng}`).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,15 @@ export interface ActivationStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// License server errors (issue #270)
|
// License errors (issues #270 and #347)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// The network commands (activate_machine, deactivate_machine,
|
// The network commands (activate_machine, deactivate_machine,
|
||||||
// list_activated_machines) reject with a JSON string serialized from the Rust
|
// list_activated_machines) and the key commands (store_license, read_license,
|
||||||
|
// validate_license_key) 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":"server","status":502} | {"code":"invalid_prefix"} | ...
|
||||||
// `unknown` is the client-side fallback for anything that is not such a string.
|
// `unknown` is the client-side fallback for anything that is not such a string.
|
||||||
|
|
||||||
export type LicenseErrorCode =
|
export type LicenseErrorCode =
|
||||||
|
|
@ -65,6 +66,10 @@ export type LicenseErrorCode =
|
||||||
| "machine_limit_reached"
|
| "machine_limit_reached"
|
||||||
| "no_active_activation"
|
| "no_active_activation"
|
||||||
| "invalid_input"
|
| "invalid_input"
|
||||||
|
| "invalid_prefix"
|
||||||
|
| "invalid_signature"
|
||||||
|
| "malformed"
|
||||||
|
| "unknown_edition"
|
||||||
| "rate_limit"
|
| "rate_limit"
|
||||||
| "network"
|
| "network"
|
||||||
| "invalid_response"
|
| "invalid_response"
|
||||||
|
|
@ -91,6 +96,10 @@ export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = {
|
||||||
machine_limit_reached: "license.errors.machineLimitReached",
|
machine_limit_reached: "license.errors.machineLimitReached",
|
||||||
no_active_activation: "license.errors.noActiveActivation",
|
no_active_activation: "license.errors.noActiveActivation",
|
||||||
invalid_input: "license.errors.invalidInput",
|
invalid_input: "license.errors.invalidInput",
|
||||||
|
invalid_prefix: "license.errors.invalidPrefix",
|
||||||
|
invalid_signature: "license.errors.invalidSignature",
|
||||||
|
malformed: "license.errors.malformed",
|
||||||
|
unknown_edition: "license.errors.unknownEdition",
|
||||||
rate_limit: "license.errors.rateLimit",
|
rate_limit: "license.errors.rateLimit",
|
||||||
network: "license.errors.network",
|
network: "license.errors.network",
|
||||||
invalid_response: "license.errors.invalidResponse",
|
invalid_response: "license.errors.invalidResponse",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import type { Edition } from "../services/licenseService";
|
||||||
* Keys are kebab-case because the JWT `features[]` array is a namespace shared
|
* Keys are kebab-case because the JWT `features[]` array is a namespace shared
|
||||||
* between the Rust override and this TS layer (cf. `auto-update` string on the
|
* between the Rust override and this TS layer (cf. `auto-update` string on the
|
||||||
* Rust side). Enforcement is UI-only (soft-paywall, GPL assumed) — the edition
|
* Rust side). Enforcement is UI-only (soft-paywall, GPL assumed) — the edition
|
||||||
* itself is still resolved by the machine-bound Rust `current_edition()`.
|
* itself is still resolved by the Rust `current_edition()`, from the license key
|
||||||
|
* alone since #347 (the activation token binds the key to a machine on the
|
||||||
|
* server only).
|
||||||
*
|
*
|
||||||
* Modules that stay Free (dashboard, import, transactions, categories,
|
* Modules that stay Free (dashboard, import, transactions, categories,
|
||||||
* reports/trends, export, changelog, docs) have NO key here → never gated.
|
* reports/trends, export, changelog, docs) have NO key here → never gated.
|
||||||
|
|
@ -29,10 +31,10 @@ export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
|
||||||
/**
|
/**
|
||||||
* Pure entitlement check.
|
* Pure entitlement check.
|
||||||
*
|
*
|
||||||
* Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
|
* Fail-closed in Free (CWE-863): a key that no longer validates (invalid or
|
||||||
* downgrades `edition` → "free" (machine-binding), but still carries its signed
|
* expired) resolves `edition` → "free", yet its JWT still lists signed
|
||||||
* `features[]`. We must NOT re-grant those features to a downgraded license, so
|
* `features[]`. We must NOT re-grant those features to a license that resolves
|
||||||
* the Free short-circuit runs BEFORE the `features[]` override.
|
* to free, so the Free short-circuit runs BEFORE the `features[]` override.
|
||||||
*
|
*
|
||||||
* `licenseFeatures` is the JWT override namespace. An unknown feature (not in
|
* `licenseFeatures` is the JWT override namespace. An unknown feature (not in
|
||||||
* the matrix — the namespace is shared with Rust and may drift) is deny-all
|
* the matrix — the namespace is shared with Rust and may drift) is deny-all
|
||||||
|
|
|
||||||
34
src/shared/licenseExpiry.test.ts
Normal file
34
src/shared/licenseExpiry.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { licenseExpiryDisplay, NO_EXPIRY_HORIZON_YEARS } from "./licenseExpiry";
|
||||||
|
|
||||||
|
const DAY_S = 86400;
|
||||||
|
const YEAR_S = 365.25 * DAY_S;
|
||||||
|
// Noon UTC: the same calendar day in every time zone the suite runs in (UTC in CI,
|
||||||
|
// America/Toronto on a workstation).
|
||||||
|
const NOW_S = Date.UTC(2026, 8, 29, 12) / 1000;
|
||||||
|
|
||||||
|
describe("licenseExpiryDisplay", () => {
|
||||||
|
it("shows no date for a 100-year key (maximus-api#63)", () => {
|
||||||
|
expect(licenseExpiryDisplay(NOW_S + 36525 * DAY_S, NOW_S, "fr-CA")).toEqual({
|
||||||
|
kind: "noExpiry",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the date of a 10-year key, formatted for the given locale", () => {
|
||||||
|
const expiresAt = Date.UTC(2036, 8, 26, 12) / 1000;
|
||||||
|
expect(licenseExpiryDisplay(expiresAt, NOW_S, "fr-CA")).toEqual({
|
||||||
|
kind: "date",
|
||||||
|
text: "2036-09-26",
|
||||||
|
});
|
||||||
|
expect(licenseExpiryDisplay(expiresAt, NOW_S, "en-CA")).toEqual({
|
||||||
|
kind: "date",
|
||||||
|
text: "2036-09-26",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a date up to the 50-year horizon and drops it just beyond", () => {
|
||||||
|
const horizon = NOW_S + NO_EXPIRY_HORIZON_YEARS * YEAR_S;
|
||||||
|
expect(licenseExpiryDisplay(horizon, NOW_S, "fr-CA").kind).toBe("date");
|
||||||
|
expect(licenseExpiryDisplay(horizon + DAY_S, NOW_S, "fr-CA").kind).toBe("noExpiry");
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/shared/licenseExpiry.ts
Normal file
28
src/shared/licenseExpiry.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Display rule of the license expiry date on the License card (#347, D9).
|
||||||
|
//
|
||||||
|
// Keys issued since maximus-api#63 last 100 years: a date in 2126 is noise, and the
|
||||||
|
// key does not end in any practical sense. Beyond the horizon the card shows
|
||||||
|
// "No expiry date"; closer dates show in the app language.
|
||||||
|
|
||||||
|
/** Beyond this horizon, an expiry date is shown as "no expiry date". */
|
||||||
|
export const NO_EXPIRY_HORIZON_YEARS = 50;
|
||||||
|
|
||||||
|
const SECONDS_PER_YEAR = 365.25 * 24 * 3600;
|
||||||
|
|
||||||
|
export type LicenseExpiryDisplay = { kind: "noExpiry" } | { kind: "date"; text: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the card shows `expiresAt` (seconds since the epoch) at `nowSeconds`: no date
|
||||||
|
* beyond the horizon, otherwise the date formatted for `locale` (`fr-CA` or `en-CA`,
|
||||||
|
* chosen by the card from the app language).
|
||||||
|
*/
|
||||||
|
export function licenseExpiryDisplay(
|
||||||
|
expiresAt: number,
|
||||||
|
nowSeconds: number,
|
||||||
|
locale: string,
|
||||||
|
): LicenseExpiryDisplay {
|
||||||
|
if (expiresAt - nowSeconds > NO_EXPIRY_HORIZON_YEARS * SECONDS_PER_YEAR) {
|
||||||
|
return { kind: "noExpiry" };
|
||||||
|
}
|
||||||
|
return { kind: "date", text: new Date(expiresAt * 1000).toLocaleDateString(locale) };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue