From 7d667f7ca38f8c6eecc1a012cd0e0c54240458e8 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 26 Sep 2026 09:15:52 -0400 Subject: [PATCH] fix(license): call /v1 routes with product, name server refusals, localize purchase URL - Activation, deactivation and verify POST to /v1/licenses/* with "product":"simpl-resultat" instead of the legacy /licenses/* paths, which cost a 308 on every call. - New LicenseError enum, serialized as {code} like FetchPriceError and read from error.code of the /v1 envelope. A 200 {valid:false} from verify is now a coded refusal instead of an empty machine list, and an HTML error page on a non-2xx maps to server instead of invalid_response. Deactivation is idempotent: no_active_activation counts as done. - The three calls live in *_inner functions without AppHandle, tested against a mockito server that only declares the /v1 routes. - useLicenseMachines hook with an exported reducer. The activation error survives the list load it triggers (machine_limit_reached opens the list), a deactivation removes the machine locally without re-running verify (4 requests on the machine-limit path, under the server's 5/min/IP limit), and refresh() follows every successful activation or deactivation. - license.errors.* in FR and EN; purchaseUrl() opens /{fr|en}/produits/simpl-resultat. test: reproduces #270. Each original defect, reintroduced on the final tree, turns tests red: legacy activate path 5, missing product 2, envelope read as a string 3, verify valid:false as an empty list 2, strict body parse on a non-2xx 2, untrimmed key 1, list load clearing the activation error 4, verify re-run after deactivation 2, empty state despite a list error 1, no refresh after activation 3, old purchase URL 4. Fixes #270 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.fr.md | 8 + CHANGELOG.md | 8 + docs/architecture.md | 13 +- src-tauri/src/commands/license_commands.rs | 719 +++++++++++++++++---- src/components/settings/LicenseCard.tsx | 135 ++-- src/hooks/useLicenseMachines.test.ts | 335 ++++++++++ src/hooks/useLicenseMachines.ts | 233 +++++++ src/i18n/locales/en.json | 16 + src/i18n/locales/fr.json | 16 + src/services/licenseService.test.ts | 155 +++++ src/services/licenseService.ts | 111 +++- src/shared/purchaseUrl.test.ts | 36 ++ src/shared/purchaseUrl.ts | 19 + 13 files changed, 1570 insertions(+), 234 deletions(-) create mode 100644 src/hooks/useLicenseMachines.test.ts create mode 100644 src/hooks/useLicenseMachines.ts create mode 100644 src/services/licenseService.test.ts create mode 100644 src/shared/purchaseUrl.test.ts create mode 100644 src/shared/purchaseUrl.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 092c811..c3e7be1 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,14 @@ ## [Non publié] +### Corrigé + +- L'activation, la désactivation et la liste des machines passent désormais par les points d'accès `/v1` du serveur de licences et nomment le produit, au lieu de passer par une redirection à chaque appel (#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). +- L'édition affichée se met désormais à jour dès l'activation ou la désactivation d'une machine, sans redémarrer l'application (#270). +- Le bouton « Acheter » de la carte Licence ouvrait une page qui n'existe pas ; il ouvre désormais la page produit dans la langue de l'application (#270). + ### Sécurité - Mise à jour de la dépendance Rust `rustls` (0.23.36 → 0.23.45, qui exige `rustls-webpki` 0.103.13 → 0.103.15 avec elle), corrigeant RUSTSEC-2026-0285. Les messages de poignée de main TLS 1.3 étaient acceptés au mauvais niveau de chiffrement lorsqu'ils suivaient un message de changement de clé dans le même enregistrement — un `EncryptedExtensions` en clair empaqueté avec le `ServerHello`, par exemple — là où la RFC 8446 impose de fermer la connexion. Sévérité moyenne, confidentialité seulement : la transcription de la poignée de main reste authentifiée, donc cela ne permet ni d'altérer ni de compléter une poignée de main depuis une position réseau ; l'effet est qu'un pair peut envoyer en clair ce qui aurait dû être chiffré sans que rustls refuse la connexion. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` — les appels de licence, d'authentification, de cours et de rétroaction de l'application elle-même passent par un autre moteur TLS et n'ont jamais été sur ce chemin. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Le seul comportement qui change est le correctif lui-même : une poignée de main qui enfreint la RFC 8446 §5.1 est désormais fermée au lieu d'être acceptée (#348). diff --git a/CHANGELOG.md b/CHANGELOG.md index 17adaf7..45b9d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- Machine activation, deactivation and the machine list now call the license server's current `/v1` endpoints and name the product explicitly, instead of going through a redirect on every call (#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). +- The edition shown now updates right after a machine activation or deactivation, without restarting the app (#270). +- The "Buy" button of the License card opened a page that does not exist; it now opens the product page in the app's language (#270). + ### Security - Updated the Rust dependency `rustls` (0.23.36 → 0.23.45, which requires `rustls-webpki` 0.103.13 → 0.103.15 alongside it), clearing RUSTSEC-2026-0285. TLS 1.3 handshake messages were accepted at the wrong encryption level when they followed a key-changing message inside the same record — a plaintext `EncryptedExtensions` packed together with the `ServerHello`, for instance — where RFC 8446 requires closing the connection instead. Medium severity, confidentiality only: the handshake transcript stays authenticated, so this cannot be used to alter or complete a handshake from a network position; the effect is that a peer can send in the clear what should have been encrypted without rustls refusing the connection. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater` — the application's own licence, authentication, market-price and feedback calls use a different TLS backend and were never on this path. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. The one behaviour that does change is the fix itself: a handshake that breaks RFC 8446 §5.1 is now closed instead of accepted (#348). diff --git a/docs/architecture.md b/docs/architecture.md index 714b96c..ef94fa0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` | | `userPreferenceService.ts` | Stockage préférences utilisateur | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | -| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) | +| `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) | | `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) | @@ -227,6 +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` | | `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) | +| `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 | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | ### Hook transverse — `useCollapsibleGroups` @@ -339,11 +340,15 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac - `read_license` — Lecture de la licence stockée - `get_edition` — Détection de l'édition active (free/base/premium) - `get_machine_id` — Génération d'un identifiant machine unique -- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49) -- `deactivate_machine` — Désactivation d'une machine enregistrée -- `list_activated_machines` — Liste des machines activées pour la licence +- `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 +- `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 +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é. + +Le lien d'achat de la carte Licence, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), 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). + ### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5) - `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 7d18a56..14fbbfe 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -180,16 +180,24 @@ pub fn store_license(app: tauri::AppHandle, key: String) -> Result Result<(), String> { - let local_id = machine_id_internal()?; - let decoding_key = embedded_decoding_key()?; - validate_activation_with_key(&token, &local_id, &decoding_key)?; - let path = activation_path(&app)?; + persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e)) +} + +/// Validate then write the activation token. A token that fails validation came from +/// the server, hence `InvalidResponse`; a failed write is `Storage`. +fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> { + let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?; + let decoding_key = embedded_decoding_key().map_err(|_| LicenseError::Internal)?; + validate_activation_with_key(token, &local_id, &decoding_key) + .map_err(|_| LicenseError::InvalidResponse)?; + let path = activation_path(app).map_err(|_| LicenseError::Storage)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?; + fs::create_dir_all(parent).map_err(|_| LicenseError::Storage)?; } - fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e)) + fs::write(&path, token.trim()).map_err(|_| LicenseError::Storage) } /// Read the stored license without revalidating. Returns `None` when no license is present. @@ -361,12 +369,202 @@ fn machine_id_internal() -> Result { machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) } -// License server API base URL. Overridable via MAXIMUS_API_URL env var for development. +// License server API base URL. Overridable via MAXIMUS_API_URL for development. fn api_base_url() -> String { std::env::var("MAXIMUS_API_URL") .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) } +/// Product sent with every license server call. The `/v1/licenses/*` routes accept it +/// as optional and otherwise infer it from the key prefix (maximus-api#48). +const PRODUCT: &str = "simpl-resultat"; + +/// Wait assumed when a 429 carries no usable `retry_after` (the server window is 60 s). +const DEFAULT_RETRY_AFTER_S: u64 = 60; + +/// Typed error of the license commands, serialized as JSON to cross the Tauri boundary — +/// same pattern as `FetchPriceError` in `balance_commands.rs`: `{"code":"license_revoked"}`, +/// `{"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 +/// `license.errors.*` message. Issue #270 (server-side refusals); #347 extends it to the +/// local key-entry errors. +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum LicenseError { + LicenseNotFound, + LicenseRevoked, + LicenseExpired, + MachineLimitReached, + NoActiveActivation, + InvalidInput, + RateLimit { retry_after_s: u64 }, + /// The request could not be sent or timed out. + Network, + /// Unreadable 2xx body, missing `activation_token`, or a token refused locally. + InvalidResponse, + /// Any other non-2xx answer, or an unknown code. + Server { status: u16 }, + NoLicenseStored, + /// Reading or writing the license / activation files. + Storage, + /// Unreadable machine id or invalid embedded public key. + Internal, +} + +/// Serialize a `LicenseError` to the stable JSON string returned across the Tauri boundary. +fn license_error_to_string(err: &LicenseError) -> String { + serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string()) +} + +/// Map a non-2xx answer to a `LicenseError` from the `/v1` envelope +/// `{ "error": { "code", "message" } }`. Only `error.code` decides: a 404 is +/// `license_not_found` or `no_active_activation`, never "missing route". The status is +/// used for 429 and for bodies without a known code (e.g. an HTML 502 page). +fn error_from_response(status: u16, body: &serde_json::Value) -> LicenseError { + let code = body.pointer("/error/code").and_then(|v| v.as_str()); + match code { + Some("license_not_found") => LicenseError::LicenseNotFound, + Some("license_revoked") => LicenseError::LicenseRevoked, + Some("license_expired") => LicenseError::LicenseExpired, + Some("machine_limit_reached") => LicenseError::MachineLimitReached, + Some("no_active_activation") => LicenseError::NoActiveActivation, + Some("invalid_input") => LicenseError::InvalidInput, + _ if status == 429 || code == Some("rate_limit_exceeded") => { + let retry_after_s = body + .pointer("/error/retry_after") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_RETRY_AFTER_S); + LicenseError::RateLimit { retry_after_s } + } + _ => LicenseError::Server { status }, + } +} + +/// POST a JSON body to the license server. The body of a non-2xx answer is read +/// leniently (it may be an HTML error page) before the status decides. +async fn post_license_api( + api_base: &str, + path: &str, + body: &serde_json::Value, +) -> Result { + let resp = reqwest::Client::new() + .post(format!("{}{}", api_base, path)) + .json(body) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|_| LicenseError::Network)?; + + let status = resp.status(); + if status.is_success() { + resp.json::() + .await + .map_err(|_| LicenseError::InvalidResponse) + } else { + let body = resp + .json::() + .await + .unwrap_or(serde_json::Value::Null); + Err(error_from_response(status.as_u16(), &body)) + } +} + +/// Activation call, without `AppHandle` nor disk access so tests can point it at a mock +/// server. Returns the activation token issued for `machine_id`. +async fn activate_inner( + api_base: &str, + license_key: &str, + machine_id: &str, + machine_name: Option<&str>, +) -> Result { + let mut body = serde_json::json!({ + "product": PRODUCT, + "license_key": license_key.trim(), + "machine_id": machine_id, + }); + if let Some(name) = machine_name { + body["machine_name"] = serde_json::Value::String(name.to_string()); + } + let resp = post_license_api(api_base, "/v1/licenses/activate", &body).await?; + resp["activation_token"] + .as_str() + .map(str::to_string) + .ok_or(LicenseError::InvalidResponse) +} + +/// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as +/// the server says it; [`deactivation_outcome`] decides what that means for the user. +async fn deactivate_inner( + api_base: &str, + license_key: &str, + machine_id: &str, +) -> Result<(), LicenseError> { + let body = serde_json::json!({ + "product": PRODUCT, + "license_key": license_key.trim(), + "machine_id": machine_id, + }); + post_license_api(api_base, "/v1/licenses/deactivate", &body) + .await + .map(|_| ()) +} + +/// Verify call: the machines activated for the key. +async fn verify_inner(api_base: &str, license_key: &str) -> Result, LicenseError> { + let body = serde_json::json!({ + "product": PRODUCT, + "license_key": license_key.trim(), + }); + let resp = post_license_api(api_base, "/v1/licenses/verify", &body).await?; + machines_from_verify(&resp) +} + +/// Read a verify answer. The server answers 200 in every business case: +/// `{valid:false, reason}` for an unknown, revoked or expired key, `{valid:true, +/// machines, ...}` otherwise. Before #270 a `valid:false` answer had no `machines` and +/// read as an empty list, so a revoked key showed "no machine activated". +fn machines_from_verify(body: &serde_json::Value) -> Result, LicenseError> { + match body.get("valid").and_then(|v| v.as_bool()) { + Some(false) => Err(match body.get("reason").and_then(|v| v.as_str()) { + Some("not_found") => LicenseError::LicenseNotFound, + Some("revoked") => LicenseError::LicenseRevoked, + Some("expired") => LicenseError::LicenseExpired, + _ => LicenseError::Server { status: 200 }, + }), + Some(true) => { + let machines = body + .get("machines") + .and_then(|v| v.as_array()) + .ok_or(LicenseError::InvalidResponse)?; + Ok(machines + .iter() + .filter_map(|m| serde_json::from_value::(m.clone()).ok()) + .collect()) + } + None => Err(LicenseError::InvalidResponse), + } +} + +/// Deactivation is idempotent: `no_active_activation` means the slot is already free, +/// which is the state the user asked for. +fn deactivation_outcome(result: Result<(), LicenseError>) -> Result<(), LicenseError> { + match result { + Err(LicenseError::NoActiveActivation) => Ok(()), + other => other, + } +} + +/// Stored license key for a server call, `None` when no key is stored. +fn read_stored_key(app: &tauri::AppHandle) -> Result, LicenseError> { + let path = license_path(app).map_err(|_| LicenseError::Storage)?; + if !path.exists() { + return Ok(None); + } + fs::read_to_string(&path) + .map(Some) + .map_err(|_| LicenseError::Storage) +} + /// Machine info returned by the license server. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MachineInfo { @@ -384,101 +582,51 @@ pub struct ActivationStatus { } /// Activate this machine with the license server. Reads the stored license key, sends -/// the machine_id to the API, and stores the returned activation token. +/// the machine_id to the API, and stores the returned activation token. Errors cross +/// the boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> { - let key_path = license_path(&app)?; - if !key_path.exists() { - return Err("No license key stored".to_string()); - } - let license_key = - fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; - let machine_id = machine_id_internal()?; - let machine_name = hostname::get() - .ok() - .and_then(|h| h.into_string().ok()); - - let url = format!("{}/licenses/activate", api_base_url()); - let client = reqwest::Client::new(); - - let mut body = serde_json::json!({ - "license_key": license_key.trim(), - "machine_id": machine_id, - }); - if let Some(name) = machine_name { - body["machine_name"] = serde_json::Value::String(name); - } - - let resp = client - .post(&url) - .json(&body) - .timeout(std::time::Duration::from_secs(15)) - .send() + activate_machine_impl(&app) .await - .map_err(|e| format!("Cannot reach license server: {}", e))?; - - let status = resp.status(); - let resp_body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Invalid server response: {}", e))?; - - if !status.is_success() { - let error = resp_body["error"] - .as_str() - .unwrap_or("Activation failed"); - return Err(error.to_string()); - } - - let token = resp_body["activation_token"] - .as_str() - .ok_or("Server did not return an activation token")?; - - // store_activation_token validates the token against local machine_id before writing - store_activation_token(app, token.to_string())?; - Ok(()) + .map_err(|e| license_error_to_string(&e)) } -/// Deactivate a machine on the license server, freeing a slot. +async fn activate_machine_impl(app: &tauri::AppHandle) -> Result<(), LicenseError> { + let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; + let machine_id = machine_id_internal().map_err(|_| LicenseError::Internal)?; + let machine_name = hostname::get().ok().and_then(|h| h.into_string().ok()); + let token = activate_inner( + &api_base_url(), + &license_key, + &machine_id, + machine_name.as_deref(), + ) + .await?; + // Validates the token against the local machine_id before writing it. + persist_activation_token(app, &token) +} + +/// Deactivate a machine on the license server, freeing a slot. Idempotent: a machine the +/// server no longer counts as active is treated as deactivated. Errors cross the +/// boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn deactivate_machine( app: tauri::AppHandle, machine_id: String, ) -> Result<(), String> { - let key_path = license_path(&app)?; - if !key_path.exists() { - return Err("No license key stored".to_string()); - } - let license_key = - fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; - - let url = format!("{}/licenses/deactivate", api_base_url()); - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .json(&serde_json::json!({ - "license_key": license_key.trim(), - "machine_id": machine_id, - })) - .timeout(std::time::Duration::from_secs(15)) - .send() + deactivate_machine_impl(&app, &machine_id) .await - .map_err(|e| format!("Cannot reach license server: {}", e))?; + .map_err(|e| license_error_to_string(&e)) +} - let status = resp.status(); - if !status.is_success() { - let resp_body: serde_json::Value = resp - .json() - .await - .unwrap_or(serde_json::json!({"error": "Deactivation failed"})); - let error = resp_body["error"].as_str().unwrap_or("Deactivation failed"); - return Err(error.to_string()); - } +async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> { + let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; + deactivation_outcome(deactivate_inner(&api_base_url(), &license_key, machine_id).await)?; - // If deactivating this machine, remove the local activation token - let local_id = machine_id_internal()?; + // If this machine was deactivated, remove the local activation token + let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?; if machine_id == local_id { - let act_path = activation_path(&app)?; + let act_path = activation_path(app).map_err(|_| LicenseError::Storage)?; if act_path.exists() { let _ = fs::remove_file(&act_path); } @@ -487,48 +635,20 @@ pub async fn deactivate_machine( Ok(()) } -/// List all machines currently activated for the stored license. +/// List all machines currently activated for the stored license (empty when no key is +/// stored). Errors cross the boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn list_activated_machines(app: tauri::AppHandle) -> Result, String> { - let key_path = license_path(&app)?; - if !key_path.exists() { + list_activated_machines_impl(&app) + .await + .map_err(|e| license_error_to_string(&e)) +} + +async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result, LicenseError> { + let Some(license_key) = read_stored_key(app)? else { return Ok(vec![]); - } - let license_key = - fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; - - let url = format!("{}/licenses/verify", api_base_url()); - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .json(&serde_json::json!({ - "license_key": license_key.trim(), - })) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|e| format!("Cannot reach license server: {}", e))?; - - if !resp.status().is_success() { - return Err("Cannot verify license".to_string()); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Invalid server response: {}", e))?; - - // The verify endpoint returns machines in the response when valid - let machines = body["machines"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|m| serde_json::from_value::(m.clone()).ok()) - .collect() - }) - .unwrap_or_default(); - - Ok(machines) + }; + verify_inner(&api_base_url(), &license_key).await } /// Check the local activation status without contacting the server. @@ -858,3 +978,348 @@ mod tests { } } } + +// === License server calls (issue #270) ========================================================= +// +// Pure mapping functions are tested with tables; the HTTP layer with an in-process mockito +// server passed as `api_base` (same strategy as `fetch_price_inner`). A request that matches +// no declared mock gets mockito's 501, so a client still calling a legacy `/licenses/*` path +// fails these tests. +#[cfg(test)] +mod license_server_tests { + use super::*; + use mockito::Matcher; + use serde_json::json; + + /// JSON the front receives for an error. + fn wire(err: &LicenseError) -> serde_json::Value { + serde_json::from_str(&license_error_to_string(err)).unwrap() + } + + #[test] + fn error_codes_serialize_like_fetch_price_error() { + assert_eq!(wire(&LicenseError::LicenseNotFound), json!({"code": "license_not_found"})); + assert_eq!( + wire(&LicenseError::RateLimit { retry_after_s: 42 }), + json!({"code": "rate_limit", "retry_after_s": 42}) + ); + assert_eq!( + wire(&LicenseError::Server { status: 502 }), + json!({"code": "server", "status": 502}) + ); + assert_eq!(wire(&LicenseError::NoLicenseStored), json!({"code": "no_license_stored"})); + } + + #[test] + fn every_envelope_refusal_maps_to_its_code() { + let envelope = |code: &str| json!({"error": {"code": code, "message": "x"}}); + let rows: Vec<(u16, serde_json::Value, serde_json::Value)> = vec![ + (404, envelope("license_not_found"), json!({"code": "license_not_found"})), + (403, envelope("license_revoked"), json!({"code": "license_revoked"})), + (403, envelope("license_expired"), json!({"code": "license_expired"})), + ( + 409, + json!({"error": {"code": "machine_limit_reached", "message": "Machine limit reached (3)"}, + "machines": [{"machine_id": "a", "machine_name": null, + "activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-01T00:00:00Z"}]}), + json!({"code": "machine_limit_reached"}), + ), + (404, envelope("no_active_activation"), json!({"code": "no_active_activation"})), + ( + 400, + json!({"error": {"code": "invalid_input", "message": "Invalid input"}, "details": {"fieldErrors": {}}}), + json!({"code": "invalid_input"}), + ), + ( + 429, + json!({"error": {"code": "rate_limit_exceeded", "message": "slow down", "retry_after": 42}}), + json!({"code": "rate_limit", "retry_after_s": 42}), + ), + ( + 429, + json!({"error": {"code": "rate_limit_exceeded", "message": "slow down"}}), + json!({"code": "rate_limit", "retry_after_s": 60}), + ), + (418, envelope("not_a_known_code"), json!({"code": "server", "status": 418})), + (502, serde_json::Value::Null, json!({"code": "server", "status": 502})), + ]; + for (status, body, expected) in rows { + assert_eq!( + wire(&error_from_response(status, &body)), + expected, + "status {} body {}", + status, + body + ); + } + } + + #[test] + fn verify_answers_map_to_machines_or_codes() { + let machines = json!([{"machine_id": "a", "machine_name": "laptop", + "activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-20T00:00:00Z"}]); + let refusals = vec![ + (json!({"valid": false, "reason": "not_found"}), json!({"code": "license_not_found"})), + (json!({"valid": false, "reason": "revoked"}), json!({"code": "license_revoked"})), + (json!({"valid": false, "reason": "expired"}), json!({"code": "license_expired"})), + (json!({"valid": false, "reason": "suspended"}), json!({"code": "server", "status": 200})), + (json!({"machines": machines}), json!({"code": "invalid_response"})), + (json!({"valid": true}), json!({"code": "invalid_response"})), + ]; + for (body, expected) in refusals { + let err = machines_from_verify(&body).expect_err(&body.to_string()); + assert_eq!(wire(&err), expected, "body {}", body); + } + + let listed = machines_from_verify(&json!({"valid": true, "machine_limit": 3, "machines": machines})) + .expect("valid answer"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].machine_id, "a"); + assert_eq!(listed[0].machine_name.as_deref(), Some("laptop")); + } + + #[test] + fn deactivation_treats_an_already_free_slot_as_done() { + assert_eq!(deactivation_outcome(Err(LicenseError::NoActiveActivation)), Ok(())); + assert_eq!(deactivation_outcome(Ok(())), Ok(())); + assert_eq!( + deactivation_outcome(Err(LicenseError::LicenseRevoked)), + Err(LicenseError::LicenseRevoked) + ); + } + + #[tokio::test] + async fn activate_posts_the_trimmed_key_and_product_to_v1() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("POST", "/v1/licenses/activate") + .match_body(Matcher::PartialJson(json!({ + "product": "simpl-resultat", + "license_key": "SR-BASE-abc.def.ghi", + "machine_id": "machine-1", + "machine_name": "laptop", + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"activation_token":"tok-123"}"#) + .create_async() + .await; + + let token = activate_inner(&server.url(), " SR-BASE-abc.def.ghi \n", "machine-1", Some("laptop")) + .await + .expect("activation token"); + assert_eq!(token, "tok-123"); + m.assert_async().await; + } + + #[tokio::test] + async fn activate_reads_the_v1_error_envelope() { + let cases = vec![ + (404, r#"{"error":{"code":"license_not_found","message":"License not found"}}"#, LicenseError::LicenseNotFound), + (403, r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#, LicenseError::LicenseRevoked), + ( + 409, + r#"{"error":{"code":"machine_limit_reached","message":"Machine limit reached (3)"},"machines":[]}"#, + LicenseError::MachineLimitReached, + ), + ]; + for (status, body, expected) in cases { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/activate") + .with_status(status) + .with_header("content-type", "application/json") + .with_body(body) + .create_async() + .await; + let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) + .await + .expect_err("refusal"); + assert_eq!(err, expected, "status {}", status); + } + } + + #[tokio::test] + async fn activate_html_error_page_is_a_server_error() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/activate") + .with_status(502) + .with_header("content-type", "text/html") + .with_body("

502 Bad Gateway

") + .create_async() + .await; + let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) + .await + .expect_err("502"); + assert_eq!(err, LicenseError::Server { status: 502 }); + } + + #[tokio::test] + async fn activate_without_a_token_is_an_invalid_response() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/activate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) + .await + .expect_err("no token"); + assert_eq!(err, LicenseError::InvalidResponse); + } + + #[tokio::test] + async fn deactivate_posts_key_machine_and_product_to_v1() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("POST", "/v1/licenses/deactivate") + .match_body(Matcher::PartialJson(json!({ + "product": "simpl-resultat", + "license_key": "SR-BASE-k", + "machine_id": "machine-2", + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + deactivate_inner(&server.url(), "SR-BASE-k\n", "machine-2") + .await + .expect("deactivated"); + m.assert_async().await; + } + + #[tokio::test] + async fn deactivate_reports_no_active_activation() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/deactivate") + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#) + .create_async() + .await; + let err = deactivate_inner(&server.url(), "SR-BASE-k", "machine-2") + .await + .expect_err("already free"); + assert_eq!(err, LicenseError::NoActiveActivation); + } + + #[tokio::test] + async fn verify_posts_product_to_v1_and_lists_machines() { + let mut server = mockito::Server::new_async().await; + let m = server + .mock("POST", "/v1/licenses/verify") + .match_body(Matcher::PartialJson(json!({ + "product": "simpl-resultat", + "license_key": "SR-BASE-k", + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"valid":true,"edition":"base","email":"x@example.com","machine_limit":3,"active_machines":1, + "machines":[{"machine_id":"a","machine_name":null,"activated_at":"2026-09-01T00:00:00.000Z","last_seen_at":"2026-09-20T00:00:00.000Z"}], + "expires_at":"2036-09-09T00:00:00.000Z"}"#, + ) + .create_async() + .await; + let machines = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines"); + assert_eq!(machines.len(), 1); + assert_eq!(machines[0].machine_id, "a"); + m.assert_async().await; + } + + #[tokio::test] + async fn verify_revoked_key_is_a_refusal_not_an_empty_list() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/verify") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":false,"reason":"revoked"}"#) + .create_async() + .await; + let err = verify_inner(&server.url(), "SR-BASE-k").await.expect_err("revoked"); + assert_eq!(err, LicenseError::LicenseRevoked); + } + + #[tokio::test] + async fn verify_rate_limit_and_html_outage() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/verify") + .with_status(429) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Too many requests","retry_after":42}}"#) + .create_async() + .await; + assert_eq!( + verify_inner(&server.url(), "SR-BASE-k").await.expect_err("429"), + LicenseError::RateLimit { retry_after_s: 42 } + ); + + let mut outage = mockito::Server::new_async().await; + let _o = outage + .mock("POST", "/v1/licenses/verify") + .with_status(503) + .with_header("content-type", "text/html") + .with_body("Service Unavailable") + .create_async() + .await; + assert_eq!( + verify_inner(&outage.url(), "SR-BASE-k").await.expect_err("503"), + LicenseError::Server { status: 503 } + ); + } + + /// Regression for #270: a server that only knows the `/v1` routes. Before the fix the + /// client called `/licenses/*`; here that would get mockito's 501 and fail. + #[tokio::test] + async fn all_three_calls_work_against_v1_only_routes() { + let mut server = mockito::Server::new_async().await; + let a = server + .mock("POST", "/v1/licenses/activate") + .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"activation_token":"t"}"#) + .create_async() + .await; + let v = server + .mock("POST", "/v1/licenses/verify") + .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":true,"machines":[]}"#) + .create_async() + .await; + let d = server + .mock("POST", "/v1/licenses/deactivate") + .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + let base = server.url(); + assert_eq!(activate_inner(&base, "SR-BASE-k", "m", None).await, Ok("t".to_string())); + assert_eq!(verify_inner(&base, "SR-BASE-k").await.map(|m| m.len()), Ok(0)); + assert_eq!(deactivate_inner(&base, "SR-BASE-k", "m").await, Ok(())); + a.assert_async().await; + v.assert_async().await; + d.assert_async().await; + } + + #[tokio::test] + async fn unreachable_server_is_a_network_error() { + // Port 1 on loopback: nothing listens, the connection is refused at once. + assert_eq!( + verify_inner("http://127.0.0.1:1", "SR-BASE-k").await.expect_err("refused"), + LicenseError::Network + ); + } +} diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index 021db1f..21a88d3 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -1,94 +1,27 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { openUrl } from "@tauri-apps/plugin-opener"; import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useLicenseContext } from "../../contexts/LicenseContext"; -import { - MachineInfo, - ActivationStatus, - activateMachine, - deactivateMachine, - listActivatedMachines, - getActivationStatus, -} from "../../services/licenseService"; - -const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat"; +import { useLicenseMachines } from "../../hooks/useLicenseMachines"; +import type { LicenseError } from "../../services/licenseService"; +import { purchaseUrl } from "../../shared/purchaseUrl"; export default function LicenseCard() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } = useLicenseContext(); const [keyInput, setKeyInput] = useState(""); const [showInput, setShowInput] = useState(false); - const [showMachines, setShowMachines] = useState(false); - const [machines, setMachines] = useState([]); - const [activation, setActivation] = useState(null); - const [machineLoading, setMachineLoading] = useState(false); - const [deactivatingId, setDeactivatingId] = useState(null); - const [machineError, setMachineError] = useState(null); const hasLicense = edition !== "free"; - - const loadActivation = useCallback(async () => { - if (!hasLicense) return; - try { - const status = await getActivationStatus(); - setActivation(status); - } catch { - // Ignore — activation status is best-effort - } - }, [hasLicense]); - - const loadMachines = useCallback(async () => { - setMachineLoading(true); - setMachineError(null); - try { - const list = await listActivatedMachines(); - setMachines(list); - } catch (e) { - setMachineError(e instanceof Error ? e.message : String(e)); - } finally { - setMachineLoading(false); - } - }, []); - - useEffect(() => { - void loadActivation(); - }, [loadActivation]); - - const handleActivate = async () => { - setMachineLoading(true); - setMachineError(null); - try { - await activateMachine(); - await loadActivation(); - } catch (e) { - setMachineError(e instanceof Error ? e.message : String(e)); - } finally { - setMachineLoading(false); - } - }; - - const handleDeactivate = async (machineId: string) => { - setDeactivatingId(machineId); - try { - await deactivateMachine(machineId); - await loadActivation(); - await loadMachines(); - } catch (e) { - setMachineError(e instanceof Error ? e.message : String(e)); - } finally { - setDeactivatingId(null); - } - }; - - const toggleMachines = async () => { - const next = !showMachines; - setShowMachines(next); - if (next && machines.length === 0) { - await loadMachines(); - } - }; + const { + state: machines, + activate, + deactivate, + toggleList, + showEmptyState, + } = useLicenseMachines(hasLicense); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -102,13 +35,15 @@ export default function LicenseCard() { }; const handlePurchase = () => { - void openUrl(PURCHASE_URL); + void openUrl(purchaseUrl(i18n.language)); }; const formatExpiry = (timestamp: number) => { return new Date(timestamp * 1000).toLocaleDateString(); }; + const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params); + const editionLabel = t(`license.editions.${edition}`); return ( @@ -216,18 +151,18 @@ export default function LicenseCard() { {t("license.machines.title")}
- {activation && !activation.is_activated && ( + {machines.activation && !machines.activation.is_activated && ( )} - {activation?.is_activated && ( + {machines.activation?.is_activated && ( {t("license.machines.activated")} @@ -235,36 +170,42 @@ export default function LicenseCard() { )}
- {machineError && ( + {machines.activationError && (
-

{machineError}

+

{errorMessage(machines.activationError)}

)} - {showMachines && ( + {machines.listOpen && (
- {machineLoading && machines.length === 0 && ( + {machines.listLoading && machines.machines.length === 0 && (
{t("common.loading")}
)} - {!machineLoading && machines.length === 0 && ( + {machines.listError && ( +
+ +

{errorMessage(machines.listError)}

+
+ )} + {showEmptyState && (

{t("license.machines.noMachines")}

)} - {machines.map((m) => { - const isThis = activation?.machine_id === m.machine_id; + {machines.machines.map((m) => { + const isThis = machines.activation?.machine_id === m.machine_id; return (