fix(license): call /v1 routes with product, name server refusals, localize purchase URL #359
13 changed files with 1570 additions and 234 deletions
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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é
|
### 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).
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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).
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| `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_*) |
|
| `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,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` |
|
| `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 |
|
||||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||||
|
|
||||||
### Hook transverse — `useCollapsibleGroups`
|
### 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
|
- `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)
|
||||||
- `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 (appel API serveur de licences, issue #49)
|
- `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
|
- `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 pour la licence
|
- `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
|
||||||
|
|
||||||
|
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)
|
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
|
||||||
|
|
||||||
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
|
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
|
||||||
|
|
|
||||||
|
|
@ -180,16 +180,24 @@ pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo,
|
||||||
|
|
||||||
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
||||||
/// caller — it must validate against the local machine_id to be considered active.
|
/// caller — it must validate against the local machine_id to be considered active.
|
||||||
|
/// Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||||
let local_id = machine_id_internal()?;
|
persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e))
|
||||||
let decoding_key = embedded_decoding_key()?;
|
}
|
||||||
validate_activation_with_key(&token, &local_id, &decoding_key)?;
|
|
||||||
let path = activation_path(&app)?;
|
/// 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() {
|
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.
|
/// Read the stored license without revalidating. Returns `None` when no license is present.
|
||||||
|
|
@ -361,12 +369,202 @@ fn machine_id_internal() -> Result<String, String> {
|
||||||
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
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 {
|
fn api_base_url() -> String {
|
||||||
std::env::var("MAXIMUS_API_URL")
|
std::env::var("MAXIMUS_API_URL")
|
||||||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
.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<serde_json::Value, LicenseError> {
|
||||||
|
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::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| LicenseError::InvalidResponse)
|
||||||
|
} else {
|
||||||
|
let body = resp
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.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<String, LicenseError> {
|
||||||
|
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<Vec<MachineInfo>, 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<Vec<MachineInfo>, 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::<MachineInfo>(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<Option<String>, 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.
|
/// Machine info returned by the license server.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MachineInfo {
|
pub struct MachineInfo {
|
||||||
|
|
@ -384,101 +582,51 @@ pub struct ActivationStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate this machine with the license server. Reads the stored license key, sends
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
let key_path = license_path(&app)?;
|
activate_machine_impl(&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()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot reach license server: {}", e))?;
|
.map_err(|e| license_error_to_string(&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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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]
|
#[tauri::command]
|
||||||
pub async fn deactivate_machine(
|
pub async fn deactivate_machine(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
machine_id: String,
|
machine_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let key_path = license_path(&app)?;
|
deactivate_machine_impl(&app, &machine_id)
|
||||||
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()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot reach license server: {}", e))?;
|
.map_err(|e| license_error_to_string(&e))
|
||||||
|
}
|
||||||
|
|
||||||
let status = resp.status();
|
async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> {
|
||||||
if !status.is_success() {
|
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
|
||||||
let resp_body: serde_json::Value = resp
|
deactivation_outcome(deactivate_inner(&api_base_url(), &license_key, machine_id).await)?;
|
||||||
.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// If deactivating this machine, remove the local activation token
|
// If this machine was deactivated, remove the local activation token
|
||||||
let local_id = machine_id_internal()?;
|
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
||||||
if machine_id == local_id {
|
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() {
|
if act_path.exists() {
|
||||||
let _ = fs::remove_file(&act_path);
|
let _ = fs::remove_file(&act_path);
|
||||||
}
|
}
|
||||||
|
|
@ -487,48 +635,20 @@ pub async fn deactivate_machine(
|
||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
|
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
|
||||||
let key_path = license_path(&app)?;
|
list_activated_machines_impl(&app)
|
||||||
if !key_path.exists() {
|
.await
|
||||||
|
.map_err(|e| license_error_to_string(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<Vec<MachineInfo>, LicenseError> {
|
||||||
|
let Some(license_key) = read_stored_key(app)? else {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
};
|
||||||
let license_key =
|
verify_inner(&api_base_url(), &license_key).await
|
||||||
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::<MachineInfo>(m.clone()).ok())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(machines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the local activation status without contacting the server.
|
/// 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("<html><body><h1>502 Bad Gateway</h1></body></html>")
|
||||||
|
.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("<html>Service Unavailable</html>")
|
||||||
|
.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,27 @@
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
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 {
|
import { useLicenseMachines } from "../../hooks/useLicenseMachines";
|
||||||
MachineInfo,
|
import type { LicenseError } from "../../services/licenseService";
|
||||||
ActivationStatus,
|
import { purchaseUrl } from "../../shared/purchaseUrl";
|
||||||
activateMachine,
|
|
||||||
deactivateMachine,
|
|
||||||
listActivatedMachines,
|
|
||||||
getActivationStatus,
|
|
||||||
} from "../../services/licenseService";
|
|
||||||
|
|
||||||
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
|
||||||
|
|
||||||
export default function LicenseCard() {
|
export default function LicenseCard() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
|
||||||
useLicenseContext();
|
useLicenseContext();
|
||||||
const [keyInput, setKeyInput] = useState("");
|
const [keyInput, setKeyInput] = useState("");
|
||||||
const [showInput, setShowInput] = useState(false);
|
const [showInput, setShowInput] = useState(false);
|
||||||
const [showMachines, setShowMachines] = useState(false);
|
|
||||||
const [machines, setMachines] = useState<MachineInfo[]>([]);
|
|
||||||
const [activation, setActivation] = useState<ActivationStatus | null>(null);
|
|
||||||
const [machineLoading, setMachineLoading] = useState(false);
|
|
||||||
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
|
||||||
const [machineError, setMachineError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const hasLicense = edition !== "free";
|
const hasLicense = edition !== "free";
|
||||||
|
const {
|
||||||
const loadActivation = useCallback(async () => {
|
state: machines,
|
||||||
if (!hasLicense) return;
|
activate,
|
||||||
try {
|
deactivate,
|
||||||
const status = await getActivationStatus();
|
toggleList,
|
||||||
setActivation(status);
|
showEmptyState,
|
||||||
} catch {
|
} = useLicenseMachines(hasLicense);
|
||||||
// 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 handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -102,13 +35,15 @@ export default function LicenseCard() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePurchase = () => {
|
const handlePurchase = () => {
|
||||||
void openUrl(PURCHASE_URL);
|
void openUrl(purchaseUrl(i18n.language));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiry = (timestamp: number) => {
|
const formatExpiry = (timestamp: number) => {
|
||||||
return new Date(timestamp * 1000).toLocaleDateString();
|
return new Date(timestamp * 1000).toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
|
||||||
|
|
||||||
const editionLabel = t(`license.editions.${edition}`);
|
const editionLabel = t(`license.editions.${edition}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -216,18 +151,18 @@ export default function LicenseCard() {
|
||||||
{t("license.machines.title")}
|
{t("license.machines.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activation && !activation.is_activated && (
|
{machines.activation && !machines.activation.is_activated && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleActivate}
|
onClick={() => void activate()}
|
||||||
disabled={machineLoading}
|
disabled={machines.activating}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{machineLoading && <Loader2 size={12} className="animate-spin" />}
|
{machines.activating && <Loader2 size={12} className="animate-spin" />}
|
||||||
{t("license.activate")}
|
{t("license.activate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{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)]">
|
||||||
<CheckCircle size={12} />
|
<CheckCircle size={12} />
|
||||||
{t("license.machines.activated")}
|
{t("license.machines.activated")}
|
||||||
|
|
@ -235,36 +170,42 @@ export default function LicenseCard() {
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleMachines}
|
onClick={toggleList}
|
||||||
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
|
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
|
||||||
>
|
>
|
||||||
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{machines.listOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{machineError && (
|
{machines.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>{machineError}</p>
|
<p>{errorMessage(machines.activationError)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showMachines && (
|
{machines.listOpen && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{machineLoading && machines.length === 0 && (
|
{machines.listLoading && machines.machines.length === 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||||
<Loader2 size={12} className="animate-spin" />
|
<Loader2 size={12} className="animate-spin" />
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!machineLoading && machines.length === 0 && (
|
{machines.listError && (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
||||||
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{errorMessage(machines.listError)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showEmptyState && (
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{t("license.machines.noMachines")}
|
{t("license.machines.noMachines")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{machines.map((m) => {
|
{machines.machines.map((m) => {
|
||||||
const isThis = activation?.machine_id === m.machine_id;
|
const isThis = machines.activation?.machine_id === m.machine_id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.machine_id}
|
key={m.machine_id}
|
||||||
|
|
@ -285,11 +226,11 @@ export default function LicenseCard() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeactivate(m.machine_id)}
|
onClick={() => void deactivate(m.machine_id)}
|
||||||
disabled={deactivatingId === m.machine_id}
|
disabled={machines.deactivatingId !== null}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{deactivatingId === m.machine_id && (
|
{machines.deactivatingId === m.machine_id && (
|
||||||
<Loader2 size={10} className="animate-spin" />
|
<Loader2 size={10} className="animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t("license.machines.deactivate")}
|
{t("license.machines.deactivate")}
|
||||||
|
|
|
||||||
335
src/hooks/useLicenseMachines.test.ts
Normal file
335
src/hooks/useLicenseMachines.test.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
licenseMachinesReducer,
|
||||||
|
initialLicenseMachinesState,
|
||||||
|
selectShowEmptyState,
|
||||||
|
shouldLoadOnOpen,
|
||||||
|
performActivate,
|
||||||
|
performDeactivate,
|
||||||
|
loadMachines,
|
||||||
|
type LicenseMachinesAction,
|
||||||
|
type LicenseMachinesDeps,
|
||||||
|
type LicenseMachinesState,
|
||||||
|
} from "./useLicenseMachines";
|
||||||
|
import {
|
||||||
|
parseLicenseError,
|
||||||
|
type LicenseError,
|
||||||
|
type LicenseErrorCode,
|
||||||
|
type LicenseResult,
|
||||||
|
type MachineInfo,
|
||||||
|
} from "../services/licenseService";
|
||||||
|
|
||||||
|
const err = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseError =>
|
||||||
|
parseLicenseError(JSON.stringify({ code, ...extra }));
|
||||||
|
|
||||||
|
const machine = (id: string): MachineInfo => ({
|
||||||
|
machine_id: id,
|
||||||
|
machine_name: `host-${id}`,
|
||||||
|
activated_at: "2026-09-01T00:00:00Z",
|
||||||
|
last_seen_at: "2026-09-20T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reduce = (
|
||||||
|
actions: LicenseMachinesAction[],
|
||||||
|
from: LicenseMachinesState = initialLicenseMachinesState,
|
||||||
|
): LicenseMachinesState => actions.reduce(licenseMachinesReducer, from);
|
||||||
|
|
||||||
|
const limitHit = reduce([
|
||||||
|
{ type: "ACTIVATE_START" },
|
||||||
|
{ type: "ACTIVATE_ERROR", error: err("machine_limit_reached") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("licenseMachinesReducer — machine limit (D2)", () => {
|
||||||
|
it("opens the list and keeps the message on machine_limit_reached", () => {
|
||||||
|
expect(limitHit.activationError?.code).toBe("machine_limit_reached");
|
||||||
|
expect(limitHit.listOpen).toBe(true);
|
||||||
|
expect(limitHit.activating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the list for other activation refusals", () => {
|
||||||
|
const s = reduce([
|
||||||
|
{ type: "ACTIVATE_START" },
|
||||||
|
{ type: "ACTIVATE_ERROR", error: err("license_revoked") },
|
||||||
|
]);
|
||||||
|
expect(s.activationError?.code).toBe("license_revoked");
|
||||||
|
expect(s.listOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the activation error through the list load it triggers", () => {
|
||||||
|
const loading = reduce([{ type: "LIST_LOAD_START" }], limitHit);
|
||||||
|
expect(loading.activationError?.code).toBe("machine_limit_reached");
|
||||||
|
const loaded = reduce(
|
||||||
|
[{ type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b"), machine("c")] }],
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
expect(loaded.activationError?.code).toBe("machine_limit_reached");
|
||||||
|
expect(loaded.machines).toHaveLength(3);
|
||||||
|
expect(loaded.listLoaded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the activation error when the list load itself fails (e.g. a 429 right after the 409)", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("rate_limit", { retry_after_s: 30 }) }],
|
||||||
|
limitHit,
|
||||||
|
);
|
||||||
|
expect(s.activationError?.code).toBe("machine_limit_reached");
|
||||||
|
expect(s.listError?.code).toBe("rate_limit");
|
||||||
|
expect(s.listLoaded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears only a machine-limit activation error once a slot is freed", () => {
|
||||||
|
const withList = reduce(
|
||||||
|
[{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }],
|
||||||
|
limitHit,
|
||||||
|
);
|
||||||
|
const freed = reduce(
|
||||||
|
[{ type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_DONE", machineId: "a" }],
|
||||||
|
withList,
|
||||||
|
);
|
||||||
|
expect(freed.activationError).toBeNull();
|
||||||
|
expect(freed.machines.map((m) => m.machine_id)).toEqual(["b"]);
|
||||||
|
expect(freed.deactivatingId).toBeNull();
|
||||||
|
|
||||||
|
const networkFailure = reduce([
|
||||||
|
{ type: "ACTIVATE_START" },
|
||||||
|
{ type: "ACTIVATE_ERROR", error: err("network") },
|
||||||
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
||||||
|
{ type: "DEACTIVATE_DONE", machineId: "a" },
|
||||||
|
]);
|
||||||
|
expect(networkFailure.activationError?.code).toBe("network");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("licenseMachinesReducer — who may clear the activation error", () => {
|
||||||
|
// Only ACTIVATE_START and a DEACTIVATE_DONE after a machine-limit refusal
|
||||||
|
// clear it. Everything else (list loads in particular) must leave it alone.
|
||||||
|
const preserving: LicenseMachinesAction[] = [
|
||||||
|
{ type: "ACTIVATION_STATUS", activation: { is_activated: false, machine_id: "x" } },
|
||||||
|
{ type: "TOGGLE_LIST" },
|
||||||
|
{ type: "TOGGLE_LIST" },
|
||||||
|
{ type: "LIST_LOAD_START" },
|
||||||
|
{ type: "LIST_LOAD_DONE", machines: [] },
|
||||||
|
{ type: "LIST_LOAD_ERROR", error: err("server", { status: 502 }) },
|
||||||
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
||||||
|
{ type: "DEACTIVATE_ERROR", error: err("network") },
|
||||||
|
{ type: "ACTIVATE_DONE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ACTIVATE_DONE is listed too: it is not a clearing path of its own, since
|
||||||
|
// ACTIVATE_START always clears the error before a successful activation.
|
||||||
|
it.each(preserving.map((a) => [a.type, a] as const))("%s keeps it", (_type, action) => {
|
||||||
|
expect(licenseMachinesReducer(limitHit, action).activationError?.code).toBe(
|
||||||
|
"machine_limit_reached",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ACTIVATE_START clears it", () => {
|
||||||
|
expect(licenseMachinesReducer(limitHit, { type: "ACTIVATE_START" }).activationError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("licenseMachinesReducer — list lifecycle", () => {
|
||||||
|
it("DEACTIVATE_START clears a stale list error", () => {
|
||||||
|
const s = reduce([
|
||||||
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
||||||
|
{ type: "DEACTIVATE_ERROR", error: err("rate_limit", { retry_after_s: 20 }) },
|
||||||
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
||||||
|
]);
|
||||||
|
expect(s.listError).toBeNull();
|
||||||
|
expect(s.deactivatingId).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes and invalidates the list after a successful activation", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LIST_LOAD_START" },
|
||||||
|
{ type: "LIST_LOAD_DONE", machines: [machine("a")] },
|
||||||
|
{ type: "ACTIVATE_START" },
|
||||||
|
{ type: "ACTIVATE_DONE" },
|
||||||
|
],
|
||||||
|
{ ...initialLicenseMachinesState, listOpen: true },
|
||||||
|
);
|
||||||
|
expect(s.listOpen).toBe(false);
|
||||||
|
expect(s.listLoaded).toBe(false);
|
||||||
|
expect(s.activating).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectShowEmptyState", () => {
|
||||||
|
it("is false before the first load", () => {
|
||||||
|
expect(selectShowEmptyState(initialLicenseMachinesState)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true once a load returns an empty list without error", () => {
|
||||||
|
expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false while loading, on a list error, or with machines", () => {
|
||||||
|
expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false);
|
||||||
|
expect(
|
||||||
|
selectShowEmptyState(
|
||||||
|
reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("license_revoked") }]),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a")] }])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when a deactivation fails on a list emptied locally", () => {
|
||||||
|
const s = reduce([
|
||||||
|
{ type: "LIST_LOAD_START" },
|
||||||
|
{ type: "LIST_LOAD_DONE", machines: [] },
|
||||||
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
||||||
|
{ type: "DEACTIVATE_ERROR", error: err("network") },
|
||||||
|
]);
|
||||||
|
expect(selectShowEmptyState(s)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldLoadOnOpen", () => {
|
||||||
|
const loaded = reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]);
|
||||||
|
|
||||||
|
it("loads the first time the list opens", () => {
|
||||||
|
expect(shouldLoadOnOpen(initialLicenseMachinesState)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reload a list already loaded without error", () => {
|
||||||
|
expect(shouldLoadOnOpen(loaded)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries after a list error", () => {
|
||||||
|
expect(
|
||||||
|
shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("network") }])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never loads when closing the list or while a load is running", () => {
|
||||||
|
expect(shouldLoadOnOpen({ ...initialLicenseMachinesState, listOpen: true })).toBe(false);
|
||||||
|
expect(shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Orchestration: the effects are plain functions with injected dependencies,
|
||||||
|
// so the D2 path can be replayed without React or Tauri. Every dispatched
|
||||||
|
// action is folded through the real reducer.
|
||||||
|
function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
||||||
|
let state = initialLicenseMachinesState;
|
||||||
|
const deps: LicenseMachinesDeps = {
|
||||||
|
activateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
||||||
|
deactivateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
||||||
|
listActivatedMachines: vi.fn(
|
||||||
|
async (): Promise<LicenseResult<MachineInfo[]>> => ({ ok: true, value: [] }),
|
||||||
|
),
|
||||||
|
getActivationStatus: vi.fn(async () => ({ is_activated: true, machine_id: "this" })),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
dispatch: (action) => {
|
||||||
|
state = licenseMachinesReducer(state, action);
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const networkCalls = () =>
|
||||||
|
vi.mocked(deps.activateMachine).mock.calls.length +
|
||||||
|
vi.mocked(deps.deactivateMachine).mock.calls.length +
|
||||||
|
vi.mocked(deps.listActivatedMachines).mock.calls.length;
|
||||||
|
return { deps, getState: () => state, networkCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("machine-limit path (D2) end to end", () => {
|
||||||
|
it("fits in 4 license-server calls and keeps the message until a slot is freed", async () => {
|
||||||
|
const activateMachine = vi
|
||||||
|
.fn<() => Promise<LicenseResult<void>>>()
|
||||||
|
.mockResolvedValueOnce({ ok: false, error: err("machine_limit_reached") })
|
||||||
|
.mockResolvedValueOnce({ ok: true, value: undefined });
|
||||||
|
const { deps, getState, networkCalls } = harness({
|
||||||
|
activateMachine,
|
||||||
|
listActivatedMachines: vi.fn(async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
value: [machine("a"), machine("b"), machine("c")],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await performActivate(deps);
|
||||||
|
let s = getState();
|
||||||
|
expect(s.activationError?.code).toBe("machine_limit_reached");
|
||||||
|
expect(s.listOpen).toBe(true);
|
||||||
|
expect(s.listLoaded).toBe(true);
|
||||||
|
expect(s.machines).toHaveLength(3);
|
||||||
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await performDeactivate(deps, "b");
|
||||||
|
s = getState();
|
||||||
|
expect(s.machines.map((m) => m.machine_id)).toEqual(["a", "c"]);
|
||||||
|
expect(s.activationError).toBeNull();
|
||||||
|
|
||||||
|
await performActivate(deps);
|
||||||
|
s = getState();
|
||||||
|
expect(s.activationError).toBeNull();
|
||||||
|
|
||||||
|
// activate (409) + verify + deactivate + activate: under the server's
|
||||||
|
// 5 requests per minute and per IP.
|
||||||
|
expect(networkCalls()).toBe(4);
|
||||||
|
expect(deps.listActivatedMachines).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("performActivate", () => {
|
||||||
|
it("refreshes the edition after a successful activation, without reloading the list", async () => {
|
||||||
|
const { deps, getState } = harness();
|
||||||
|
await performActivate(deps);
|
||||||
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.getActivationStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
||||||
|
expect(getState().activation?.is_activated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refreshes when the local activation status cannot be read", async () => {
|
||||||
|
const { deps } = harness({
|
||||||
|
getActivationStatus: vi.fn(async () => {
|
||||||
|
throw new Error("machine id unreadable");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(performActivate(deps)).resolves.toBeUndefined();
|
||||||
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neither loads the list nor refreshes on other refusals", async () => {
|
||||||
|
const { deps, getState } = harness({
|
||||||
|
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_revoked") })),
|
||||||
|
});
|
||||||
|
await performActivate(deps);
|
||||||
|
expect(getState().activationError?.code).toBe("license_revoked");
|
||||||
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
||||||
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("performDeactivate", () => {
|
||||||
|
it("removes the machine locally and refreshes, without calling verify", async () => {
|
||||||
|
const { deps, getState } = harness();
|
||||||
|
await loadMachines({
|
||||||
|
...deps,
|
||||||
|
listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: [machine("a"), machine("b")] })),
|
||||||
|
});
|
||||||
|
await performDeactivate(deps, "a");
|
||||||
|
expect(getState().machines.map((m) => m.machine_id)).toEqual(["b"]);
|
||||||
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
||||||
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a refusal on the list and releases the busy state", async () => {
|
||||||
|
const { deps, getState } = harness({
|
||||||
|
deactivateMachine: vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: err("rate_limit", { retry_after_s: 25 }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await performDeactivate(deps, "a");
|
||||||
|
const s = getState();
|
||||||
|
expect(s.listError?.code).toBe("rate_limit");
|
||||||
|
expect(s.listError?.params).toEqual({ seconds: 25 });
|
||||||
|
expect(s.deactivatingId).toBeNull();
|
||||||
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
233
src/hooks/useLicenseMachines.ts
Normal file
233
src/hooks/useLicenseMachines.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||||
|
import {
|
||||||
|
activateMachine,
|
||||||
|
deactivateMachine,
|
||||||
|
listActivatedMachines,
|
||||||
|
getActivationStatus,
|
||||||
|
type ActivationStatus,
|
||||||
|
type LicenseError,
|
||||||
|
type LicenseResult,
|
||||||
|
type MachineInfo,
|
||||||
|
} from "../services/licenseService";
|
||||||
|
import { useLicenseContext } from "../contexts/LicenseContext";
|
||||||
|
|
||||||
|
// Machine activation state of the License card (issue #270).
|
||||||
|
//
|
||||||
|
// The license server allows 5 requests per minute and per IP, shared by
|
||||||
|
// activate, verify and deactivate. The machine-limit path (activate → 409 →
|
||||||
|
// list via verify → deactivate → activate) therefore has to fit in 4 calls:
|
||||||
|
// a successful deactivation removes the machine locally instead of re-running
|
||||||
|
// verify, and nothing loads the list from an effect (StrictMode would double
|
||||||
|
// it). List loads come only from explicit handlers.
|
||||||
|
|
||||||
|
export interface LicenseMachinesState {
|
||||||
|
activation: ActivationStatus | null;
|
||||||
|
machines: MachineInfo[];
|
||||||
|
listOpen: boolean;
|
||||||
|
listLoading: boolean;
|
||||||
|
listLoaded: boolean;
|
||||||
|
/** Error of the list itself (verify) or of a deactivation. */
|
||||||
|
listError: LicenseError | null;
|
||||||
|
activating: boolean;
|
||||||
|
/** Error of the last activation. Survives the list load it may trigger. */
|
||||||
|
activationError: LicenseError | null;
|
||||||
|
/** Machine being deactivated; every Deactivate button is disabled meanwhile. */
|
||||||
|
deactivatingId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseMachinesAction =
|
||||||
|
| { type: "ACTIVATION_STATUS"; activation: ActivationStatus }
|
||||||
|
| { type: "TOGGLE_LIST" }
|
||||||
|
| { type: "LIST_LOAD_START" }
|
||||||
|
| { type: "LIST_LOAD_DONE"; machines: MachineInfo[] }
|
||||||
|
| { type: "LIST_LOAD_ERROR"; error: LicenseError }
|
||||||
|
| { type: "ACTIVATE_START" }
|
||||||
|
| { type: "ACTIVATE_DONE" }
|
||||||
|
| { type: "ACTIVATE_ERROR"; error: LicenseError }
|
||||||
|
| { type: "DEACTIVATE_START"; machineId: string }
|
||||||
|
| { type: "DEACTIVATE_DONE"; machineId: string }
|
||||||
|
| { type: "DEACTIVATE_ERROR"; error: LicenseError };
|
||||||
|
|
||||||
|
export const initialLicenseMachinesState: LicenseMachinesState = {
|
||||||
|
activation: null,
|
||||||
|
machines: [],
|
||||||
|
listOpen: false,
|
||||||
|
listLoading: false,
|
||||||
|
listLoaded: false,
|
||||||
|
listError: null,
|
||||||
|
activating: false,
|
||||||
|
activationError: null,
|
||||||
|
deactivatingId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function licenseMachinesReducer(
|
||||||
|
state: LicenseMachinesState,
|
||||||
|
action: LicenseMachinesAction,
|
||||||
|
): LicenseMachinesState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ACTIVATION_STATUS":
|
||||||
|
return { ...state, activation: action.activation };
|
||||||
|
case "TOGGLE_LIST":
|
||||||
|
return { ...state, listOpen: !state.listOpen };
|
||||||
|
// The list actions never touch activationError: the machine-limit message
|
||||||
|
// must stay visible while the list it opened loads (D2).
|
||||||
|
case "LIST_LOAD_START":
|
||||||
|
return { ...state, listLoading: true, listError: null };
|
||||||
|
case "LIST_LOAD_DONE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
listLoading: false,
|
||||||
|
listLoaded: true,
|
||||||
|
listError: null,
|
||||||
|
machines: action.machines,
|
||||||
|
};
|
||||||
|
case "LIST_LOAD_ERROR":
|
||||||
|
// listLoaded stays false so that reopening the list retries.
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
listLoading: false,
|
||||||
|
listLoaded: false,
|
||||||
|
listError: action.error,
|
||||||
|
machines: [],
|
||||||
|
};
|
||||||
|
case "ACTIVATE_START":
|
||||||
|
return { ...state, activating: true, activationError: null };
|
||||||
|
case "ACTIVATE_DONE":
|
||||||
|
// The loaded list no longer reflects the server: close it and mark it
|
||||||
|
// stale rather than spending another verify call.
|
||||||
|
return { ...state, activating: false, listOpen: false, listLoaded: false, machines: [] };
|
||||||
|
case "ACTIVATE_ERROR":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activating: false,
|
||||||
|
activationError: action.error,
|
||||||
|
listOpen: action.error.code === "machine_limit_reached" ? true : state.listOpen,
|
||||||
|
};
|
||||||
|
case "DEACTIVATE_START":
|
||||||
|
return { ...state, deactivatingId: action.machineId, listError: null };
|
||||||
|
case "DEACTIVATE_DONE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
deactivatingId: null,
|
||||||
|
machines: state.machines.filter((m) => m.machine_id !== action.machineId),
|
||||||
|
// A slot is now free: the machine-limit message no longer holds.
|
||||||
|
activationError:
|
||||||
|
state.activationError?.code === "machine_limit_reached" ? null : state.activationError,
|
||||||
|
};
|
||||||
|
case "DEACTIVATE_ERROR":
|
||||||
|
return { ...state, deactivatingId: null, listError: action.error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "no machine activated" line: only for a loaded, empty, error-free list. */
|
||||||
|
export function selectShowEmptyState(state: LicenseMachinesState): boolean {
|
||||||
|
return (
|
||||||
|
state.listLoaded && !state.listLoading && state.machines.length === 0 && state.listError === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether opening the list (from `state`, before the toggle) should load it. */
|
||||||
|
export function shouldLoadOnOpen(state: LicenseMachinesState): boolean {
|
||||||
|
return !state.listOpen && !state.listLoading && (!state.listLoaded || state.listError !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseMachinesDeps {
|
||||||
|
activateMachine: () => Promise<LicenseResult<void>>;
|
||||||
|
deactivateMachine: (machineId: string) => Promise<LicenseResult<void>>;
|
||||||
|
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
||||||
|
getActivationStatus: () => Promise<ActivationStatus>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
dispatch: (action: LicenseMachinesAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local read (no network). Best effort: a failure keeps the last known status. */
|
||||||
|
export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activation = await deps.getActivationStatus();
|
||||||
|
deps.dispatch({ type: "ACTIVATION_STATUS", activation });
|
||||||
|
} catch {
|
||||||
|
// The card keeps showing the previous activation status.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
|
deps.dispatch({ type: "LIST_LOAD_START" });
|
||||||
|
const result = await deps.listActivatedMachines();
|
||||||
|
deps.dispatch(
|
||||||
|
result.ok
|
||||||
|
? { type: "LIST_LOAD_DONE", machines: result.value }
|
||||||
|
: { type: "LIST_LOAD_ERROR", error: result.error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
|
deps.dispatch({ type: "ACTIVATE_START" });
|
||||||
|
const result = await deps.activateMachine();
|
||||||
|
if (!result.ok) {
|
||||||
|
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
|
||||||
|
if (result.error.code === "machine_limit_reached") {
|
||||||
|
await loadMachines(deps);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.dispatch({ type: "ACTIVATE_DONE" });
|
||||||
|
await loadActivationStatus(deps);
|
||||||
|
// The edition shown by the app follows without a restart.
|
||||||
|
await deps.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performDeactivate(deps: LicenseMachinesDeps, machineId: string): Promise<void> {
|
||||||
|
deps.dispatch({ type: "DEACTIVATE_START", machineId });
|
||||||
|
const result = await deps.deactivateMachine(machineId);
|
||||||
|
if (!result.ok) {
|
||||||
|
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Removed locally, no verify call: see the rate budget note at the top.
|
||||||
|
deps.dispatch({ type: "DEACTIVATE_DONE", machineId });
|
||||||
|
await loadActivationStatus(deps);
|
||||||
|
await deps.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine list and activation of the License card. `enabled` is false in the
|
||||||
|
* free edition, where the card shows no machine section.
|
||||||
|
*/
|
||||||
|
export function useLicenseMachines(enabled: boolean) {
|
||||||
|
const { refresh } = useLicenseContext();
|
||||||
|
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
|
||||||
|
|
||||||
|
const deps = useMemo<LicenseMachinesDeps>(
|
||||||
|
() => ({
|
||||||
|
activateMachine,
|
||||||
|
deactivateMachine,
|
||||||
|
listActivatedMachines,
|
||||||
|
getActivationStatus,
|
||||||
|
refresh,
|
||||||
|
dispatch,
|
||||||
|
}),
|
||||||
|
[refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local activation status only (get_activation_status reads the token on
|
||||||
|
// disk): safe to run twice under StrictMode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) void loadActivationStatus(deps);
|
||||||
|
}, [enabled, deps]);
|
||||||
|
|
||||||
|
const activate = useCallback(() => performActivate(deps), [deps]);
|
||||||
|
const deactivate = useCallback((machineId: string) => performDeactivate(deps, machineId), [deps]);
|
||||||
|
const toggleList = useCallback(() => {
|
||||||
|
const load = shouldLoadOnOpen(state);
|
||||||
|
dispatch({ type: "TOGGLE_LIST" });
|
||||||
|
if (load) void loadMachines(deps);
|
||||||
|
}, [state, deps]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
activate,
|
||||||
|
deactivate,
|
||||||
|
toggleList,
|
||||||
|
showEmptyState: selectShowEmptyState(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1202,6 +1202,22 @@
|
||||||
"activateError": "Activation failed",
|
"activateError": "Activation failed",
|
||||||
"thisMachine": "This machine",
|
"thisMachine": "This machine",
|
||||||
"noMachines": "No machines activated"
|
"noMachines": "No machines activated"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"licenseNotFound": "This license key was not found on the license server.",
|
||||||
|
"licenseRevoked": "This license key has been revoked.",
|
||||||
|
"licenseExpired": "This license key has expired.",
|
||||||
|
"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.",
|
||||||
|
"invalidInput": "The request sent to the license server is invalid.",
|
||||||
|
"rateLimit": "Too many attempts. Try again in {{seconds}} seconds.",
|
||||||
|
"network": "Cannot reach the license server. Check your internet connection.",
|
||||||
|
"invalidResponse": "The license server returned an unexpected response.",
|
||||||
|
"server": "The license server encountered an error. Try again later.",
|
||||||
|
"noLicenseStored": "No license key is stored on this machine.",
|
||||||
|
"storage": "Cannot read or write the license file.",
|
||||||
|
"internal": "Internal application error.",
|
||||||
|
"unknown": "An unexpected error occurred."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"upsell": {
|
"upsell": {
|
||||||
|
|
|
||||||
|
|
@ -1202,6 +1202,22 @@
|
||||||
"activateError": "Échec de l'activation",
|
"activateError": "Échec de l'activation",
|
||||||
"thisMachine": "Cette machine",
|
"thisMachine": "Cette machine",
|
||||||
"noMachines": "Aucune machine activée"
|
"noMachines": "Aucune machine activée"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"licenseNotFound": "Cette clé de licence est introuvable sur le serveur de licences.",
|
||||||
|
"licenseRevoked": "Cette clé de licence a été révoquée.",
|
||||||
|
"licenseExpired": "Cette clé de licence est expirée.",
|
||||||
|
"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.",
|
||||||
|
"invalidInput": "La requête envoyée au serveur de licences est invalide.",
|
||||||
|
"rateLimit": "Trop de tentatives. Réessayez dans {{seconds}} secondes.",
|
||||||
|
"network": "Impossible de joindre le serveur de licences. Vérifiez votre connexion Internet.",
|
||||||
|
"invalidResponse": "Le serveur de licences a renvoyé une réponse inattendue.",
|
||||||
|
"server": "Le serveur de licences a rencontré une erreur. Réessayez plus tard.",
|
||||||
|
"noLicenseStored": "Aucune clé de licence n'est enregistrée sur cette machine.",
|
||||||
|
"storage": "Impossible de lire ou d'écrire le fichier de licence.",
|
||||||
|
"internal": "Erreur interne de l'application.",
|
||||||
|
"unknown": "Une erreur inattendue est survenue."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"upsell": {
|
"upsell": {
|
||||||
|
|
|
||||||
155
src/services/licenseService.test.ts
Normal file
155
src/services/licenseService.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
parseLicenseError,
|
||||||
|
LICENSE_ERROR_I18N_MAP,
|
||||||
|
activateMachine,
|
||||||
|
deactivateMachine,
|
||||||
|
listActivatedMachines,
|
||||||
|
type LicenseErrorCode,
|
||||||
|
} from "./licenseService";
|
||||||
|
import fr from "../i18n/locales/fr.json";
|
||||||
|
import en from "../i18n/locales/en.json";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockInvoke = vi.mocked(invoke);
|
||||||
|
|
||||||
|
// Codes the Rust `LicenseError` enum serializes (license_commands.rs). Keep
|
||||||
|
// this list in step with the enum: a code missing here and in the map would
|
||||||
|
// fall back to `unknown` and hide the real reason from the user.
|
||||||
|
const RUST_CODES: Exclude<LicenseErrorCode, "unknown" | "rate_limit">[] = [
|
||||||
|
"license_not_found",
|
||||||
|
"license_revoked",
|
||||||
|
"license_expired",
|
||||||
|
"machine_limit_reached",
|
||||||
|
"no_active_activation",
|
||||||
|
"invalid_input",
|
||||||
|
"network",
|
||||||
|
"invalid_response",
|
||||||
|
"server",
|
||||||
|
"no_license_stored",
|
||||||
|
"storage",
|
||||||
|
"internal",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("parseLicenseError", () => {
|
||||||
|
it.each(RUST_CODES)("maps %s to its own license.errors key", (code) => {
|
||||||
|
const err = parseLicenseError(JSON.stringify({ code }));
|
||||||
|
expect(err.code).toBe(code);
|
||||||
|
expect(err.i18nKey).toBe(LICENSE_ERROR_I18N_MAP[code]);
|
||||||
|
expect(err.i18nKey).not.toBe(LICENSE_ERROR_I18N_MAP.unknown);
|
||||||
|
expect(err.params).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the server status payload without breaking the mapping", () => {
|
||||||
|
const err = parseLicenseError('{"code":"server","status":502}');
|
||||||
|
expect(err.code).toBe("server");
|
||||||
|
expect(err.i18nKey).toBe("license.errors.server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the retry delay of a rate limit into the message params", () => {
|
||||||
|
const err = parseLicenseError('{"code":"rate_limit","retry_after_s":42}');
|
||||||
|
expect(err).toEqual({
|
||||||
|
code: "rate_limit",
|
||||||
|
i18nKey: "license.errors.rateLimit",
|
||||||
|
params: { seconds: 42 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the server's 60 s window when retry_after_s is missing or unusable", () => {
|
||||||
|
for (const body of [
|
||||||
|
'{"code":"rate_limit"}',
|
||||||
|
'{"code":"rate_limit","retry_after_s":0}',
|
||||||
|
'{"code":"rate_limit","retry_after_s":"soon"}',
|
||||||
|
]) {
|
||||||
|
expect(parseLicenseError(body).params).toEqual({ seconds: 60 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unknown for anything that is not a serialized LicenseError", () => {
|
||||||
|
for (const e of [
|
||||||
|
"Activation failed",
|
||||||
|
'{"code":"not_a_license_code"}',
|
||||||
|
'{"error":{"code":"license_revoked"}}',
|
||||||
|
"null",
|
||||||
|
"42",
|
||||||
|
new Error("boom"),
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
|
const err = parseLicenseError(e);
|
||||||
|
expect(err.code).toBe("unknown");
|
||||||
|
expect(err.i18nKey).toBe("license.errors.unknown");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("license command wrappers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockInvoke.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok with the value when the command resolves", async () => {
|
||||||
|
const machines = [
|
||||||
|
{
|
||||||
|
machine_id: "m1",
|
||||||
|
machine_name: "laptop",
|
||||||
|
activated_at: "2026-09-01T00:00:00Z",
|
||||||
|
last_seen_at: "2026-09-20T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockInvoke.mockResolvedValueOnce(machines);
|
||||||
|
await expect(listActivatedMachines()).resolves.toEqual({ ok: true, value: machines });
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("list_activated_machines", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a typed error when the command rejects with a LicenseError", async () => {
|
||||||
|
mockInvoke.mockRejectedValueOnce('{"code":"machine_limit_reached"}');
|
||||||
|
const result = await activateMachine();
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.code).toBe("machine_limit_reached");
|
||||||
|
expect(result.error.i18nKey).toBe("license.errors.machineLimitReached");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the machine id to deactivate_machine", async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce(undefined);
|
||||||
|
await expect(deactivateMachine("m2")).resolves.toEqual({ ok: true, value: undefined });
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("deactivate_machine", { machineId: "m2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// No jsdom here, so nothing renders the card: a key missing from a locale
|
||||||
|
// would show up as a raw "license.errors.x" string with the suite green.
|
||||||
|
describe("license.errors i18n coverage (fr + en)", () => {
|
||||||
|
const locales: Record<string, unknown> = { fr, en };
|
||||||
|
|
||||||
|
const resolve = (messages: unknown, key: string): unknown =>
|
||||||
|
key
|
||||||
|
.split(".")
|
||||||
|
.reduce<unknown>(
|
||||||
|
(node, part) =>
|
||||||
|
node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined,
|
||||||
|
messages,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("resolves every mapped key to a non-empty string in both locales", () => {
|
||||||
|
for (const [lng, messages] of Object.entries(locales)) {
|
||||||
|
for (const key of Object.values(LICENSE_ERROR_I18N_MAP)) {
|
||||||
|
const value = resolve(messages, key);
|
||||||
|
expect(typeof value, `${key} in ${lng}`).toBe("string");
|
||||||
|
expect((value as string).trim(), `${key} in ${lng}`).not.toBe("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the {{seconds}} placeholder in the rate limit message", () => {
|
||||||
|
for (const [lng, messages] of Object.entries(locales)) {
|
||||||
|
expect(resolve(messages, "license.errors.rateLimit"), lng).toContain("{{seconds}}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,16 +47,115 @@ export interface ActivationStatus {
|
||||||
machine_id: string;
|
machine_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateMachine(): Promise<void> {
|
// -----------------------------------------------------------------------------
|
||||||
return invoke<void>("activate_machine");
|
// License server errors (issue #270)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The network commands (activate_machine, deactivate_machine,
|
||||||
|
// list_activated_machines) reject with a JSON string serialized from the Rust
|
||||||
|
// `LicenseError` enum (license_commands.rs), same pattern as fetch_price:
|
||||||
|
// {"code":"license_revoked"} | {"code":"rate_limit","retry_after_s":42}
|
||||||
|
// | {"code":"server","status":502} | ...
|
||||||
|
// `unknown` is the client-side fallback for anything that is not such a string.
|
||||||
|
|
||||||
|
export type LicenseErrorCode =
|
||||||
|
| "license_not_found"
|
||||||
|
| "license_revoked"
|
||||||
|
| "license_expired"
|
||||||
|
| "machine_limit_reached"
|
||||||
|
| "no_active_activation"
|
||||||
|
| "invalid_input"
|
||||||
|
| "rate_limit"
|
||||||
|
| "network"
|
||||||
|
| "invalid_response"
|
||||||
|
| "server"
|
||||||
|
| "no_license_stored"
|
||||||
|
| "storage"
|
||||||
|
| "internal"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export interface LicenseError {
|
||||||
|
code: LicenseErrorCode;
|
||||||
|
i18nKey: string;
|
||||||
|
/** Interpolation values for `i18nKey` — only `rate_limit` carries one. */
|
||||||
|
params?: { seconds: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivateMachine(machineId: string): Promise<void> {
|
export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = {
|
||||||
return invoke<void>("deactivate_machine", { machineId });
|
license_not_found: "license.errors.licenseNotFound",
|
||||||
|
license_revoked: "license.errors.licenseRevoked",
|
||||||
|
license_expired: "license.errors.licenseExpired",
|
||||||
|
machine_limit_reached: "license.errors.machineLimitReached",
|
||||||
|
no_active_activation: "license.errors.noActiveActivation",
|
||||||
|
invalid_input: "license.errors.invalidInput",
|
||||||
|
rate_limit: "license.errors.rateLimit",
|
||||||
|
network: "license.errors.network",
|
||||||
|
invalid_response: "license.errors.invalidResponse",
|
||||||
|
server: "license.errors.server",
|
||||||
|
no_license_stored: "license.errors.noLicenseStored",
|
||||||
|
storage: "license.errors.storage",
|
||||||
|
internal: "license.errors.internal",
|
||||||
|
unknown: "license.errors.unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wait shown when a 429 carries no usable retry_after (the server window is 60 s). */
|
||||||
|
const DEFAULT_RETRY_AFTER_S = 60;
|
||||||
|
|
||||||
|
function isLicenseErrorCode(code: string): code is LicenseErrorCode {
|
||||||
|
return Object.prototype.hasOwnProperty.call(LICENSE_ERROR_I18N_MAP, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listActivatedMachines(): Promise<MachineInfo[]> {
|
/**
|
||||||
return invoke<MachineInfo[]>("list_activated_machines");
|
* Parse the string-serialized Rust `LicenseError` into a typed error with its
|
||||||
|
* i18n key. `invoke` rejects with the `Err(String)` value of the command.
|
||||||
|
*/
|
||||||
|
export function parseLicenseError(e: unknown): LicenseError {
|
||||||
|
if (typeof e === "string") {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(e) as Record<string, unknown> | null;
|
||||||
|
if (j && typeof j.code === "string" && isLicenseErrorCode(j.code)) {
|
||||||
|
const code = j.code;
|
||||||
|
if (code === "rate_limit") {
|
||||||
|
const raw = j.retry_after_s;
|
||||||
|
const seconds =
|
||||||
|
typeof raw === "number" && Number.isFinite(raw) && raw >= 1
|
||||||
|
? Math.ceil(raw)
|
||||||
|
: DEFAULT_RETRY_AFTER_S;
|
||||||
|
return { code, i18nKey: LICENSE_ERROR_I18N_MAP.rate_limit, params: { seconds } };
|
||||||
|
}
|
||||||
|
return { code, i18nKey: LICENSE_ERROR_I18N_MAP[code] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a serialized LicenseError — fall through to `unknown`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { code: "unknown", i18nKey: LICENSE_ERROR_I18N_MAP.unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseResult<T> = { ok: true; value: T } | { ok: false; error: LicenseError };
|
||||||
|
|
||||||
|
async function callLicenseCommand<T>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
): Promise<LicenseResult<T>> {
|
||||||
|
try {
|
||||||
|
const value = await invoke<T>(command, args);
|
||||||
|
return { ok: true, value };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: parseLicenseError(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateMachine(): Promise<LicenseResult<void>> {
|
||||||
|
return callLicenseCommand<void>("activate_machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateMachine(machineId: string): Promise<LicenseResult<void>> {
|
||||||
|
return callLicenseCommand<void>("deactivate_machine", { machineId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActivatedMachines(): Promise<LicenseResult<MachineInfo[]>> {
|
||||||
|
return callLicenseCommand<MachineInfo[]>("list_activated_machines");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivationStatus(): Promise<ActivationStatus> {
|
export async function getActivationStatus(): Promise<ActivationStatus> {
|
||||||
|
|
|
||||||
36
src/shared/purchaseUrl.test.ts
Normal file
36
src/shared/purchaseUrl.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { purchaseUrl } from "./purchaseUrl";
|
||||||
|
|
||||||
|
// The old constant pointed at /simpl-resultat, a path the site never served
|
||||||
|
// (307 to /fr/simpl-resultat, then 404). The product page lives under
|
||||||
|
// /{locale}/produits/simpl-resultat (issue #270).
|
||||||
|
describe("purchaseUrl", () => {
|
||||||
|
it("opens the French product page when the app is in French", () => {
|
||||||
|
expect(purchaseUrl("fr")).toBe(
|
||||||
|
"https://lacompagniemaximus.com/fr/produits/simpl-resultat",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the English product page when the app is in English", () => {
|
||||||
|
expect(purchaseUrl("en")).toBe(
|
||||||
|
"https://lacompagniemaximus.com/en/produits/simpl-resultat",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps regional English variants to the English page", () => {
|
||||||
|
expect(purchaseUrl("en-US")).toBe(
|
||||||
|
"https://lacompagniemaximus.com/en/produits/simpl-resultat",
|
||||||
|
);
|
||||||
|
expect(purchaseUrl("EN")).toBe(
|
||||||
|
"https://lacompagniemaximus.com/en/produits/simpl-resultat",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to French for any other value", () => {
|
||||||
|
for (const language of ["fr-CA", "de", "", "cimode"]) {
|
||||||
|
expect(purchaseUrl(language)).toBe(
|
||||||
|
"https://lacompagniemaximus.com/fr/produits/simpl-resultat",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/shared/purchaseUrl.ts
Normal file
19
src/shared/purchaseUrl.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Product page on the vitrine, where Simpl'Résultat Base is sold.
|
||||||
|
//
|
||||||
|
// Contract with the site: this path is compiled into every released binary,
|
||||||
|
// and an install without a key never updates past v0.15.0 (auto-update is
|
||||||
|
// Base+), so a URL shipped here must keep answering for years. Renaming the
|
||||||
|
// page on the site requires a 308 redirect — see
|
||||||
|
// maximus/la-compagnie-maximus#153, which also redirects the old
|
||||||
|
// `/simpl-resultat` path baked into v0.14.0 and v0.15.0 (issue #270).
|
||||||
|
const PRODUCT_PAGE = "https://lacompagniemaximus.com/{locale}/produits/simpl-resultat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized product page for the app language. `i18n.language` is `fr` or
|
||||||
|
* `en` in practice; any English variant opens the English page, anything else
|
||||||
|
* falls back to French (the app's default language).
|
||||||
|
*/
|
||||||
|
export function purchaseUrl(language: string): string {
|
||||||
|
const locale = language.toLowerCase().startsWith("en") ? "en" : "fr";
|
||||||
|
return PRODUCT_PAGE.replace("{locale}", locale);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue