fix: licence — chemins /v1 + product, erreurs serveur nommées FR/EN, URL d'achat localisée #270

Closed
opened 2026-07-08 02:07:56 +00:00 by maximus · 0 comments
Owner

Les trois commandes réseau de licence appellent encore les chemins historiques /licenses/*, ce qui coûte un 308 à chaque appel et laisse le serveur deviner le produit d'après le préfixe SR-. Elles lisent aussi mal l'enveloppe d'erreur /v1 : tout refus s'affiche « Activation failed », et une clé révoquée apparaît comme « Aucune machine activée ». Enfin, le bouton « Acheter » mène à un 404.

Cette issue corrige le protocole et établit le contrat des codes d'erreur de licence, que #347 étendra aux erreurs locales. Elle rend aussi testable l'état de la liste des machines et localise l'URL d'achat.

Supersedes #53 (volet client). Le fallback serveur maximus/maximus-api#48 (308 /licenses/* → /v1/licenses/*) reste en place pour les binaires déjà distribués.

Contexte vérifié (2026-09-24)

Contrat serveur maximus-api, déployé en prod. Vérifié par des sondes réelles et par la lecture de src/routes/licenses.ts, src/services/licenseService.ts et src/middleware/rateLimit.ts.

  • POST /v1/licenses/activate :
    • 200 {activation_token}
    • 404 license_not_found
    • 403 license_revoked, 403 license_expired
    • 409 machine_limit_reached, avec un champ machines à la racine (ignoré, décision D2)
    • 400 invalid_input (+ details)
    • 429 rate_limit_exceeded
  • POST /v1/licenses/deactivate : 200 {ok:true} · 404 license_not_found · 404 no_active_activation · 400 · 429.
  • POST /v1/licenses/verify : toujours 200, hors 400/429/5xx. Réponse {valid:false, reason:"not_found"|"revoked"|"expired"} ou {valid:true, edition, email, machine_limit, active_machines, machines:[{machine_id, machine_name, activated_at, last_seen_at}], expires_at}. Sonde du 2026-09-24 avec une clé bidon : 200 {"valid":false,"reason":"not_found"}.
  • Enveloppe d'erreur : { "error": { "code", "message" } }. Pour 429, retry_after (en secondes) est dans error.
  • Limite de débit : 5 requêtes par minute et par IP, partagée par activate, verify et deactivate.
  • Piège : un 404 d'activate ou de deactivate est une erreur métier (license_not_found, no_active_activation), pas une route absente. Ne jamais décider sur le statut HTTP seul : lire error.code.
  • product est optionnel côté serveur (maximus-api/src/types/index.ts:86-108), seule valeur admise simpl-resultat.

Client (src-tauri/src/commands/license_commands.rs) :

  • activate_machine (l.401), deactivate_machine (l.455) et list_activated_machines (l.500) : chemins sans /v1, payloads sans product.
  • Les erreurs sont lues par resp_body["error"].as_str() (l.426-431, l.469-475), alors que /v1 renvoie un objet. Résultat : toujours « Activation failed » ou « Deactivation failed ».
  • list_activated_machines renvoie « Cannot verify license » sur un non-2xx (l.512-514). Mais un 200 {valid:false} passe en succès : machines est absent, donc Ok([]) (l.521-529), et la carte affiche « Aucune machine activée » pour une clé révoquée.
  • Modèle d'erreur à reproduire : FetchPriceError (src-tauri/src/commands/balance_commands.rs:52-84), avec #[serde(tag = "code", rename_all = "snake_case")] et une sérialisation JSON dans le Err par price_error_to_string. Côté TS : parseRustError + PRICE_ERROR_I18N_MAP (src/services/balance.service.ts:2612-2680), et le wrapper renvoie { ok: false, error: parseRustError(e) } (l.2745-2751).
  • Modèle de test réseau : fetch_price_inner (balance_commands.rs:122) et ses tests mockito::Server::new_async() / #[tokio::test] (balance_commands.rs:366+). mockito 1.6 est en dev-dependency (Cargo.toml:68) ; une requête non appariée reçoit un 501.

Front :

  • src/components/settings/LicenseCard.tsx porte 8 useState.
    • loadMachines() commence par setMachineError(null) (l.44).
    • « Aucune machine activée » s'affiche dès que la liste est vide, qu'il y ait une erreur ou non (l.261-265).
    • handleActivate (l.59-70) et handleDeactivate (l.72-83, qui relance loadMachines() donc verify) n'appellent pas refresh() du LicenseContext.
  • LicenseCard.tsx:15 : PURCHASE_URL = https://lacompagniemaximus.com/simpl-resultat, qui mène à un 307 vers /fr/simpl-resultat, puis à un 404 (sonde du 2026-09-24). /fr/produits/simpl-resultat et /en/produits/simpl-resultat répondent 200. Le bouton n'est visible qu'en Gratuite (l.159-168).
  • Langue : i18n.language vaut fr ou en (src/i18n/config.ts : lng: "fr", fallbackLng: "en").
  • Précédents de reducer exporté et testé : licenseReducer (src/contexts/LicenseContext.tsx:71) et feedbackReducer (src/hooks/useFeedback.ts:27). Le projet n'a ni jsdom ni Testing Library : les tests vitest portent sur des fonctions pures.

Tâches

  1. license_commands.rs : chemins /v1/licenses/activate, /v1/licenses/deactivate et /v1/licenses/verify ; "product": "simpl-resultat" dans les trois payloads.

  2. Enum LicenseError dans license_commands.rs, avec #[derive(Debug, Serialize, Clone)] et #[serde(tag = "code", rename_all = "snake_case")], plus license_error_to_string (JSON dans le Err), sur le modèle de FetchPriceError. Une variante par sens pour l'utilisateur :

    • LicenseNotFound, LicenseRevoked, LicenseExpired, MachineLimitReached, NoActiveActivation, InvalidInput : lus dans error.code de l'enveloppe. Pour verify, valid:false + reason (not_found, revoked, expired) donne LicenseNotFound, LicenseRevoked ou LicenseExpired.
    • RateLimit { retry_after_s: u64 } : 429 (error.retry_after), sérialisé rate_limit comme pour les cours.
    • Network : envoi impossible ou délai dépassé.
    • InvalidResponse : corps illisible, activation_token absent, ou jeton refusé par store_activation_token.
    • Server { status: u16 } : tout autre non-2xx, ou un code inconnu.
    • NoLicenseStored : aucune clé enregistrée.
    • Storage : lecture ou écriture du fichier de licence.
    • Internal : machine_id illisible, ou clé publique embarquée invalide.

    Les commandes Tauri gardent leur signature (Result<_, String>).

  3. Extraire, sans AppHandle ni accès disque et sur le modèle de fetch_price_inner :

    • activate_inner(api_base, license_key, machine_id, machine_name) -> Result<String, LicenseError> (renvoie le jeton) ;
    • deactivate_inner(api_base, license_key, machine_id) -> Result<(), LicenseError> ;
    • verify_inner(api_base, license_key) -> Result<Vec<MachineInfo>, LicenseError>.
  4. Tests Rust (mockito) :

    • l'URL est /v1/licenses/activate et le corps contient "product":"simpl-resultat" (Matcher::PartialJsonString) ;
    • 200 → jeton ;
    • 404 license_not_found → {"code":"license_not_found"} ;
    • 403 license_revoked ;
    • 409 machine_limit_reached, avec un corps qui porte machines à la racine ;
    • 429 avec "retry_after": 42 → {"code":"rate_limit","retry_after_s":42} ;
    • 502 avec un corps HTML → server ;
    • verify 200 valid:false, reason:"revoked" → license_revoked ;
    • verify 200 valid:true → machines lues ;
    • deactivate 404 no_active_activation.
    • Régression : un serveur mockito qui ne déclare que les routes /v1/.... Toute requête legacy y reçoit le 501 de mockito et fait échouer le test.
  5. src/services/licenseService.ts :

    • type LicenseErrorCode (union des codes) et type LicenseError ;
    • parseLicenseError(e: unknown): LicenseError, calqué sur parseRustError : une chaîne non JSON ou un code inconnu donne unknown ;
    • table LICENSE_ERROR_I18N_MAP vers license.errors.<camelCase> ;
    • les wrappers activateMachine, deactivateMachine et listActivatedMachines renvoient un résultat typé { ok: true, ... } | { ok: false, error: LicenseError } (précédent balance.service.ts:2745-2751) ;
    • tests vitest du parseur dans un nouveau src/services/licenseService.test.ts.
  6. i18n FR/EN, clés license.errors.*, au vouvoiement (comme license.* et settings.updates.*) : licenseNotFound, licenseRevoked, licenseExpired, machineLimitReached, noActiveActivation, invalidInput, rateLimit (avec {{seconds}}), network, invalidResponse, server, noLicenseStored, storage, internal, unknown. license.errors.unknown est le seul repli générique.

  7. Nouveau hook src/hooks/useLicenseMachines.ts, avec un reducer exporté (licenseMachinesReducer). Il porte l'état de la liste : machines, statut d'activation, chargements, liste ouverte, et deux champs distincts, erreur d'activation et erreur de liste. Transitions :

    • machine_limit_reached sur activate : l'erreur d'activation est posée, puis la liste s'ouvre et se charge (verify). Le chargement de la liste n'efface jamais l'erreur d'activation (D2).
    • Désactivation réussie : la machine est retirée localement, sans relancer verify. Le parcours D2 tient ainsi en 4 requêtes, sous la limite de 5 par minute.
    • Activation ou désactivation réussie : appel de refresh() du LicenseContext, pour que l'édition se mette à jour sans redémarrage.

    Tests vitest du reducer : ces transitions, plus « liste vide sans erreur → état vide » et « erreur de liste → pas d'état vide ».

  8. LicenseCard.tsx consomme ce hook. Les erreurs s'affichent via t(LICENSE_ERROR_I18N_MAP[...], params). La ligne license.machines.noMachines ne s'affiche que si la liste est chargée, vide et sans erreur.

  9. Nouveau helper src/shared/purchaseUrl.ts, fonction pure voisine de profileGate.ts. purchaseUrl(language) renvoie https://lacompagniemaximus.com/{fr|en}/produits/simpl-resultat, avec language.toLowerCase().startsWith("en") ? "en" : "fr". Test dans src/shared/purchaseUrl.test.ts.

    • LicenseCard appelle purchaseUrl(i18n.language) à la place de PURCHASE_URL.
    • Ce chemin devient un contrat avec le site, gravé dans des binaires qui ne se mettront plus à jour en Gratuite. Un commentaire au-dessus du helper le dit et renvoie à maximus/la-compagnie-maximus#153.
  10. CHANGELOG.md et CHANGELOG.fr.md sous [Unreleased] / [Non publié] (Fixed / Corrigé). docs/architecture.md : contrat d'erreur JSON {code} dans le §license_commands.rs (l.334-344), et useLicenseMachines dans le tableau des hooks.

Fichiers concernés

  • src-tauri/src/commands/license_commands.rs
  • src/services/licenseService.ts
  • src/services/licenseService.test.ts (nouveau)
  • src/hooks/useLicenseMachines.ts (nouveau)
  • src/hooks/useLicenseMachines.test.ts (nouveau)
  • src/shared/purchaseUrl.ts (nouveau)
  • src/shared/purchaseUrl.test.ts (nouveau)
  • src/components/settings/LicenseCard.tsx
  • src/i18n/locales/fr.json
  • src/i18n/locales/en.json
  • CHANGELOG.md
  • CHANGELOG.fr.md
  • docs/architecture.md

Depends on

Aucune.

Critères d'acceptation

  • Les trois appels passent par /v1/licenses/* avec "product":"simpl-resultat", prouvé par un test mockito sans route legacy.
  • Chaque refus serveur produit un code JSON stable, couvert par un test Rust, y compris 429 (rate_limit + retry_after_s) et verify valid:false.
  • Dans la carte, un refus s'affiche avec un message FR/EN de license.errors.*, jamais avec la chaîne Rust « Activation failed » ni avec un message anglais du serveur. Une clé révoquée n'affiche plus « Aucune machine activée ».
  • Sur machine_limit_reached, la liste des machines s'ouvre d'elle-même avec ses boutons Désactiver, et le message reste visible (prouvé par le test du reducer).
  • Après une activation ou une désactivation réussie, l'édition affichée se met à jour sans redémarrage.
  • « Acheter » ouvre /fr/produits/simpl-resultat en français et /en/produits/simpl-resultat en anglais (test vitest du helper).
  • cargo test, npm test (vitest) et npm run build verts ; CHANGELOG FR/EN.

Review caveats

  • 🔴 Revue du 2026-09-24, corrigé par les tâches 7 et 8. loadMachines() effaçait le message 409, et « Aucune machine activée » s'affichait même en cas d'erreur. Il faut deux champs d'erreur distincts, et l'état vide ne doit s'afficher qu'en l'absence d'erreur.
  • 🟡 Le parcours D2 faisait 5 requêtes, soit exactement la limite partagée de 5 par minute et par IP : ne pas relancer verify après une désactivation (tâche 7). Un 429 reste possible si l'utilisateur multiplie les clics ; il s'affiche alors via license.errors.rateLimit, avec le délai.
  • 🟡 La logique de la liste vit dans un hook à reducer exporté, conformément à la règle de CLAUDE.md « état de domaine = hook useReducer ». Sans jsdom, c'est la seule preuve automatisée de D2.
  • 🟢 Le payload envoie encore l'identifiant machine brut (machine_uid::get()). Son hachage fait l'objet de #350, à traiter avant le go-live. Ne pas le changer ici : toute modification de la dérivation invalide les activations existantes.

Décisions prises en planification

  • D2 — Sur machine_limit_reached : message nommé et ouverture automatique de la liste (verify). Le champ machines du 409 est ignoré, pour garder une seule source à la liste.
  • D7 — Cette issue établit le contrat des codes d'erreur de licence (côté serveur) ; #347 l'étend aux erreurs locales. Aucun affichage anglais intermédiaire.
  • Le helper d'URL va dans src/shared/ (fonction pure), pas dans src/services/.
  • L'enum suit FetchPriceError : rate_limit (et non rate_limited), Storage pour le fichier de licence, Internal pour machine_id.
  • Messages au vouvoiement.

Hors périmètre

  • CTA « Obtenir Base » des écrans verrouillés, carte Mises à jour, page d'erreur, texte de la carte Compte → #346.
  • Erreurs de saisie de clé (préfixe, signature, expiration), compteur de machines, dates, jeton d'activation expiré → #347.
  • Redirection de l'ancienne URL /simpl-resultat côté site → maximus/la-compagnie-maximus#153.
  • Hachage de l'identifiant machine → #350, avant le go-live.

Spec source

spec-plan-paiements.md (séance /plan-run du 2026-09-24). C'est un fichier de travail ignoré par git : ce corps se suffit à lui-même.

Les trois commandes réseau de licence appellent encore les chemins historiques `/licenses/*`, ce qui coûte un 308 à chaque appel et laisse le serveur deviner le produit d'après le préfixe `SR-`. Elles lisent aussi mal l'enveloppe d'erreur `/v1` : tout refus s'affiche « Activation failed », et une clé révoquée apparaît comme « Aucune machine activée ». Enfin, le bouton « Acheter » mène à un 404. Cette issue corrige le protocole et établit le contrat des codes d'erreur de licence, que #347 étendra aux erreurs locales. Elle rend aussi testable l'état de la liste des machines et localise l'URL d'achat. Supersedes #53 (volet client). Le fallback serveur maximus/maximus-api#48 (308 `/licenses/*` → `/v1/licenses/*`) reste en place pour les binaires déjà distribués. ## Contexte vérifié (2026-09-24) **Contrat serveur maximus-api**, déployé en prod. Vérifié par des sondes réelles et par la lecture de `src/routes/licenses.ts`, `src/services/licenseService.ts` et `src/middleware/rateLimit.ts`. - `POST /v1/licenses/activate` : - 200 `{activation_token}` - 404 `license_not_found` - 403 `license_revoked`, 403 `license_expired` - 409 `machine_limit_reached`, avec un champ `machines` à la racine (**ignoré**, décision D2) - 400 `invalid_input` (+ `details`) - 429 `rate_limit_exceeded` - `POST /v1/licenses/deactivate` : 200 `{ok:true}` · 404 `license_not_found` · 404 `no_active_activation` · 400 · 429. - `POST /v1/licenses/verify` : **toujours 200**, hors 400/429/5xx. Réponse `{valid:false, reason:"not_found"|"revoked"|"expired"}` ou `{valid:true, edition, email, machine_limit, active_machines, machines:[{machine_id, machine_name, activated_at, last_seen_at}], expires_at}`. Sonde du 2026-09-24 avec une clé bidon : `200 {"valid":false,"reason":"not_found"}`. - Enveloppe d'erreur : `{ "error": { "code", "message" } }`. Pour 429, `retry_after` (en secondes) est **dans** `error`. - Limite de débit : 5 requêtes par minute et par IP, partagée par activate, verify et deactivate. - **Piège** : un 404 d'activate ou de deactivate est une erreur métier (`license_not_found`, `no_active_activation`), pas une route absente. Ne jamais décider sur le statut HTTP seul : lire `error.code`. - `product` est optionnel côté serveur (`maximus-api/src/types/index.ts:86-108`), seule valeur admise `simpl-resultat`. **Client** (`src-tauri/src/commands/license_commands.rs`) : - `activate_machine` (l.401), `deactivate_machine` (l.455) et `list_activated_machines` (l.500) : chemins sans `/v1`, payloads sans `product`. - Les erreurs sont lues par `resp_body["error"].as_str()` (l.426-431, l.469-475), alors que `/v1` renvoie un objet. Résultat : toujours « Activation failed » ou « Deactivation failed ». - `list_activated_machines` renvoie « Cannot verify license » sur un non-2xx (l.512-514). Mais un 200 `{valid:false}` passe en succès : `machines` est absent, donc `Ok([])` (l.521-529), et la carte affiche « Aucune machine activée » pour une clé révoquée. - Modèle d'erreur à reproduire : `FetchPriceError` (`src-tauri/src/commands/balance_commands.rs:52-84`), avec `#[serde(tag = "code", rename_all = "snake_case")]` et une sérialisation JSON dans le `Err` par `price_error_to_string`. Côté TS : `parseRustError` + `PRICE_ERROR_I18N_MAP` (`src/services/balance.service.ts:2612-2680`), et le wrapper renvoie `{ ok: false, error: parseRustError(e) }` (l.2745-2751). - Modèle de test réseau : `fetch_price_inner` (`balance_commands.rs:122`) et ses tests `mockito::Server::new_async()` / `#[tokio::test]` (`balance_commands.rs:366+`). mockito 1.6 est en dev-dependency (`Cargo.toml:68`) ; une requête non appariée reçoit un 501. **Front** : - `src/components/settings/LicenseCard.tsx` porte 8 `useState`. - `loadMachines()` commence par `setMachineError(null)` (l.44). - « Aucune machine activée » s'affiche dès que la liste est vide, qu'il y ait une erreur ou non (l.261-265). - `handleActivate` (l.59-70) et `handleDeactivate` (l.72-83, qui relance `loadMachines()` donc verify) n'appellent pas `refresh()` du `LicenseContext`. - `LicenseCard.tsx:15` : `PURCHASE_URL = https://lacompagniemaximus.com/simpl-resultat`, qui mène à un 307 vers `/fr/simpl-resultat`, puis à un **404** (sonde du 2026-09-24). `/fr/produits/simpl-resultat` et `/en/produits/simpl-resultat` répondent 200. Le bouton n'est visible qu'en Gratuite (l.159-168). - Langue : `i18n.language` vaut `fr` ou `en` (`src/i18n/config.ts` : `lng: "fr"`, `fallbackLng: "en"`). - Précédents de reducer exporté et testé : `licenseReducer` (`src/contexts/LicenseContext.tsx:71`) et `feedbackReducer` (`src/hooks/useFeedback.ts:27`). Le projet n'a ni jsdom ni Testing Library : les tests vitest portent sur des fonctions pures. ## Tâches 1. `license_commands.rs` : chemins `/v1/licenses/activate`, `/v1/licenses/deactivate` et `/v1/licenses/verify` ; `"product": "simpl-resultat"` dans les trois payloads. 2. Enum `LicenseError` dans `license_commands.rs`, avec `#[derive(Debug, Serialize, Clone)]` et `#[serde(tag = "code", rename_all = "snake_case")]`, plus `license_error_to_string` (JSON dans le `Err`), sur le modèle de `FetchPriceError`. Une variante par sens pour l'utilisateur : - `LicenseNotFound`, `LicenseRevoked`, `LicenseExpired`, `MachineLimitReached`, `NoActiveActivation`, `InvalidInput` : lus dans `error.code` de l'enveloppe. Pour verify, `valid:false` + `reason` (`not_found`, `revoked`, `expired`) donne `LicenseNotFound`, `LicenseRevoked` ou `LicenseExpired`. - `RateLimit { retry_after_s: u64 }` : 429 (`error.retry_after`), sérialisé `rate_limit` comme pour les cours. - `Network` : envoi impossible ou délai dépassé. - `InvalidResponse` : corps illisible, `activation_token` absent, ou jeton refusé par `store_activation_token`. - `Server { status: u16 }` : tout autre non-2xx, ou un code inconnu. - `NoLicenseStored` : aucune clé enregistrée. - `Storage` : lecture ou écriture du fichier de licence. - `Internal` : `machine_id` illisible, ou clé publique embarquée invalide. Les commandes Tauri gardent leur signature (`Result<_, String>`). 3. Extraire, sans `AppHandle` ni accès disque et sur le modèle de `fetch_price_inner` : - `activate_inner(api_base, license_key, machine_id, machine_name) -> Result<String, LicenseError>` (renvoie le jeton) ; - `deactivate_inner(api_base, license_key, machine_id) -> Result<(), LicenseError>` ; - `verify_inner(api_base, license_key) -> Result<Vec<MachineInfo>, LicenseError>`. 4. Tests Rust (mockito) : - l'URL est `/v1/licenses/activate` et le corps contient `"product":"simpl-resultat"` (`Matcher::PartialJsonString`) ; - 200 → jeton ; - 404 `license_not_found` → `{"code":"license_not_found"}` ; - 403 `license_revoked` ; - 409 `machine_limit_reached`, avec un corps qui porte `machines` à la racine ; - 429 avec `"retry_after": 42` → `{"code":"rate_limit","retry_after_s":42}` ; - 502 avec un corps HTML → `server` ; - verify 200 `valid:false, reason:"revoked"` → `license_revoked` ; - verify 200 `valid:true` → machines lues ; - deactivate 404 `no_active_activation`. - Régression : un serveur mockito qui ne déclare que les routes `/v1/...`. Toute requête legacy y reçoit le 501 de mockito et fait échouer le test. 5. `src/services/licenseService.ts` : - type `LicenseErrorCode` (union des codes) et type `LicenseError` ; - `parseLicenseError(e: unknown): LicenseError`, calqué sur `parseRustError` : une chaîne non JSON ou un code inconnu donne `unknown` ; - table `LICENSE_ERROR_I18N_MAP` vers `license.errors.<camelCase>` ; - les wrappers `activateMachine`, `deactivateMachine` et `listActivatedMachines` renvoient un résultat typé `{ ok: true, ... } | { ok: false, error: LicenseError }` (précédent `balance.service.ts:2745-2751`) ; - tests vitest du parseur dans un nouveau `src/services/licenseService.test.ts`. 6. i18n FR/EN, clés `license.errors.*`, au vouvoiement (comme `license.*` et `settings.updates.*`) : `licenseNotFound`, `licenseRevoked`, `licenseExpired`, `machineLimitReached`, `noActiveActivation`, `invalidInput`, `rateLimit` (avec `{{seconds}}`), `network`, `invalidResponse`, `server`, `noLicenseStored`, `storage`, `internal`, `unknown`. `license.errors.unknown` est le **seul** repli générique. 7. Nouveau hook `src/hooks/useLicenseMachines.ts`, avec un reducer exporté (`licenseMachinesReducer`). Il porte l'état de la liste : machines, statut d'activation, chargements, liste ouverte, et deux champs distincts, **erreur d'activation** et **erreur de liste**. Transitions : - `machine_limit_reached` sur activate : l'erreur d'activation est posée, puis la liste s'ouvre et se charge (verify). Le chargement de la liste n'efface jamais l'erreur d'activation (D2). - Désactivation réussie : la machine est retirée localement, **sans** relancer verify. Le parcours D2 tient ainsi en 4 requêtes, sous la limite de 5 par minute. - Activation ou désactivation réussie : appel de `refresh()` du `LicenseContext`, pour que l'édition se mette à jour sans redémarrage. Tests vitest du reducer : ces transitions, plus « liste vide sans erreur → état vide » et « erreur de liste → pas d'état vide ». 8. `LicenseCard.tsx` consomme ce hook. Les erreurs s'affichent via `t(LICENSE_ERROR_I18N_MAP[...], params)`. La ligne `license.machines.noMachines` ne s'affiche que si la liste est chargée, vide **et** sans erreur. 9. Nouveau helper `src/shared/purchaseUrl.ts`, fonction pure voisine de `profileGate.ts`. `purchaseUrl(language)` renvoie `https://lacompagniemaximus.com/{fr|en}/produits/simpl-resultat`, avec `language.toLowerCase().startsWith("en") ? "en" : "fr"`. Test dans `src/shared/purchaseUrl.test.ts`. - `LicenseCard` appelle `purchaseUrl(i18n.language)` à la place de `PURCHASE_URL`. - Ce chemin devient un contrat avec le site, gravé dans des binaires qui ne se mettront plus à jour en Gratuite. Un commentaire au-dessus du helper le dit et renvoie à maximus/la-compagnie-maximus#153. 10. `CHANGELOG.md` et `CHANGELOG.fr.md` sous `[Unreleased]` / `[Non publié]` (Fixed / Corrigé). `docs/architecture.md` : contrat d'erreur JSON `{code}` dans le §`license_commands.rs` (l.334-344), et `useLicenseMachines` dans le tableau des hooks. ## Fichiers concernés - `src-tauri/src/commands/license_commands.rs` - `src/services/licenseService.ts` - `src/services/licenseService.test.ts` (nouveau) - `src/hooks/useLicenseMachines.ts` (nouveau) - `src/hooks/useLicenseMachines.test.ts` (nouveau) - `src/shared/purchaseUrl.ts` (nouveau) - `src/shared/purchaseUrl.test.ts` (nouveau) - `src/components/settings/LicenseCard.tsx` - `src/i18n/locales/fr.json` - `src/i18n/locales/en.json` - `CHANGELOG.md` - `CHANGELOG.fr.md` - `docs/architecture.md` ## Depends on Aucune. ## Critères d'acceptation - [ ] Les trois appels passent par `/v1/licenses/*` avec `"product":"simpl-resultat"`, prouvé par un test mockito sans route legacy. - [ ] Chaque refus serveur produit un code JSON stable, couvert par un test Rust, y compris 429 (`rate_limit` + `retry_after_s`) et verify `valid:false`. - [ ] Dans la carte, un refus s'affiche avec un message FR/EN de `license.errors.*`, jamais avec la chaîne Rust « Activation failed » ni avec un message anglais du serveur. Une clé révoquée n'affiche plus « Aucune machine activée ». - [ ] Sur `machine_limit_reached`, la liste des machines s'ouvre d'elle-même avec ses boutons Désactiver, et le message reste visible (prouvé par le test du reducer). - [ ] Après une activation ou une désactivation réussie, l'édition affichée se met à jour sans redémarrage. - [ ] « Acheter » ouvre `/fr/produits/simpl-resultat` en français et `/en/produits/simpl-resultat` en anglais (test vitest du helper). - [ ] `cargo test`, `npm test` (vitest) et `npm run build` verts ; CHANGELOG FR/EN. ## Review caveats - 🔴 Revue du 2026-09-24, corrigé par les tâches 7 et 8. `loadMachines()` effaçait le message 409, et « Aucune machine activée » s'affichait même en cas d'erreur. Il faut deux champs d'erreur distincts, et l'état vide ne doit s'afficher qu'en l'absence d'erreur. - 🟡 Le parcours D2 faisait 5 requêtes, soit exactement la limite partagée de 5 par minute et par IP : ne pas relancer verify après une désactivation (tâche 7). Un 429 reste possible si l'utilisateur multiplie les clics ; il s'affiche alors via `license.errors.rateLimit`, avec le délai. - 🟡 La logique de la liste vit dans un hook à reducer exporté, conformément à la règle de CLAUDE.md « état de domaine = hook `useReducer` ». Sans jsdom, c'est la seule preuve automatisée de D2. - 🟢 Le payload envoie encore l'identifiant machine brut (`machine_uid::get()`). Son hachage fait l'objet de #350, à traiter avant le go-live. Ne pas le changer ici : toute modification de la dérivation invalide les activations existantes. ## Décisions prises en planification - D2 — Sur `machine_limit_reached` : message nommé et ouverture automatique de la liste (verify). Le champ `machines` du 409 est ignoré, pour garder une seule source à la liste. - D7 — Cette issue établit le contrat des codes d'erreur de licence (côté serveur) ; #347 l'étend aux erreurs locales. Aucun affichage anglais intermédiaire. - Le helper d'URL va dans `src/shared/` (fonction pure), pas dans `src/services/`. - L'enum suit `FetchPriceError` : `rate_limit` (et non `rate_limited`), `Storage` pour le fichier de licence, `Internal` pour `machine_id`. - Messages au vouvoiement. ## Hors périmètre - CTA « Obtenir Base » des écrans verrouillés, carte Mises à jour, page d'erreur, texte de la carte Compte → #346. - Erreurs de saisie de clé (préfixe, signature, expiration), compteur de machines, dates, jeton d'activation expiré → #347. - Redirection de l'ancienne URL `/simpl-resultat` côté site → maximus/la-compagnie-maximus#153. - Hachage de l'identifiant machine → #350, avant le go-live. ## Spec source `spec-plan-paiements.md` (séance `/plan-run` du 2026-09-24). C'est un fichier de travail ignoré par git : ce corps se suffit à lui-même.
maximus added this to the spec-paiements milestone 2026-07-08 02:07:56 +00:00
maximus added the
status:ready
type:bug
source:human
labels 2026-07-08 02:07:56 +00:00
maximus changed title from fix: activation /v1 + product explicite + URL achat localisee to fix: licence — chemins /v1 + product, erreurs serveur nommées FR/EN, URL d'achat localisée 2026-09-25 00:49:56 +00:00
maximus modified the milestone from spec-paiements to planned-2026-09-24-paiements 2026-09-25 00:49:56 +00:00
maximus added
status:review
and removed
status:ready
labels 2026-09-26 13:16:48 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-09-26 23:59:22 +00:00
maximus added
status:review
and removed
status:approved
labels 2026-09-27 00:39:17 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-09-27 01:33:38 +00:00
maximus added
status:review
and removed
status:approved
labels 2026-09-27 12:20:29 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-09-27 12:42:28 +00:00
maximus added
status:review
and removed
status:approved
labels 2026-09-27 13:13:32 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#270
No description provided.