Compare commits

...

2 commits

Author SHA1 Message Date
le king fu
a6ffd2c4c4 feat: license card in settings (#47)
Adds the user-facing layer on top of the Rust license commands shipped
in #46.

- `licenseService.ts` thin wrapper around the new Tauri commands
- `useLicense` hook follows the project's useReducer pattern (idle,
  loading, ready, validating, error) and exposes `submitKey`,
  `refresh`, and `checkEntitlement` for cross-component use
- `LicenseCard` shows the current edition, the expiry date when set,
  accepts a license key with inline validation feedback, and links to
  the purchase page via `openUrl` from `@tauri-apps/plugin-opener`
- Card is inserted at the top of `SettingsPage` so the edition is the
  first thing users see when looking for license-related actions
- i18n: new `license.*` keys in both `fr.json` and `en.json`
- Bilingual CHANGELOG entries
2026-04-09 08:54:37 -04:00
le king fu
a9eacc8b9a feat: add license validation and entitlements (Rust) (#46)
Introduces the offline license infrastructure for the Base/Premium editions.

- jsonwebtoken (EdDSA) verifies license JWTs against an embedded Ed25519
  public key. The exp claim is mandatory (CWE-613) and is enforced via
  Validation::set_required_spec_claims.
- Activation tokens (server-issued, machine-bound) prevent license.key
  copying between machines. Storage is wired up; the actual issuance flow
  ships with Issue #49.
- get_edition() fails closed to "free" when the license is missing,
  invalid, expired, or activated for a different machine.
- New commands/entitlements module centralizes feature → tier mapping so
  Issue #48 (and any future gate) reads from a single source of truth.
- machine-uid provides the cross-platform machine identifier; OS reinstall
  invalidates the activation token by design.
- Tests cover happy path, expiry, wrong-key signature, malformed JWT,
  unknown edition, and machine_id matching for activation tokens.

The embedded PUBLIC_KEY_PEM is the RFC 8410 §10.3 test vector, clearly
labelled as a development placeholder; replacing it with the production
public key is a release-time task.
2026-04-09 08:49:43 -04:00
13 changed files with 810 additions and 2 deletions

View file

@ -2,6 +2,9 @@
## [Non publié] ## [Non publié]
### Ajouté
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
## [0.6.7] - 2026-03-29 ## [0.6.7] - 2026-03-29
### Modifié ### Modifié

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Added
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
## [0.6.7] - 2026-03-29 ## [0.6.7] - 2026-03-29
### Changed ### Changed

View file

@ -35,4 +35,8 @@ walkdir = "2"
aes-gcm = "0.10" aes-gcm = "0.10"
argon2 = "0.5" argon2 = "0.5"
rand = "0.8" rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
[dev-dependencies]
ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] }

View file

@ -0,0 +1,67 @@
// Centralized feature → tier mapping for license entitlements.
//
// This module is the single source of truth for which features are gated by which tier.
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks
// throughout the codebase.
/// Editions, ordered from least to most privileged.
pub const EDITION_FREE: &str = "free";
pub const EDITION_BASE: &str = "base";
pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions.
const FEATURE_TIERS: &[(&str, &[&str])] = &[
("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]),
];
/// Pure check: does `edition` grant access to `feature`?
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
FEATURE_TIERS
.iter()
.find(|(name, _)| *name == feature)
.map(|(_, tiers)| tiers.contains(&edition))
.unwrap_or(false)
}
#[tauri::command]
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
let edition = crate::commands::license_commands::current_edition(&app);
Ok(is_feature_allowed(&feature, &edition))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_blocks_auto_update() {
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
}
#[test]
fn base_unlocks_auto_update() {
assert!(is_feature_allowed("auto-update", EDITION_BASE));
}
#[test]
fn premium_unlocks_everything() {
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
}
#[test]
fn base_does_not_unlock_premium_features() {
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
}
#[test]
fn unknown_feature_denied() {
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
}
}

View file

@ -0,0 +1,426 @@
// License validation, storage and reading for the Base/Premium editions.
//
// Architecture:
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
// - Activation token = separate JWT, also signed by the server, binds the license to a machine
// (machine_id claim must match the local machine_id). Without it, a copied license.key would
// work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
// - Both files live in app_data_dir/ — license.key and activation.token
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
// either there is no activation token (graceful pre-activation state) OR the activation token
// matches the local machine_id.
//
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
// The matching private key is publicly known, so any license signed with it offers no real
// protection. Replace this constant with the production public key before shipping a paid
// release. The corresponding private key MUST live only on the license server (Issue #49).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
const ACTIVATION_FILE: &str = "activation.token";
const KEY_PREFIX_BASE: &str = "SR-BASE-";
const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-";
/// Decoded license metadata exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub edition: String,
pub email: String,
pub features: Vec<String>,
pub machine_limit: u32,
pub issued_at: i64,
pub expires_at: i64,
}
/// Claims embedded in the license JWT (signed by the license server).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LicenseClaims {
sub: String, // email
iss: String,
iat: i64,
exp: i64, // mandatory — see CWE-613
edition: String,
#[serde(default)]
features: Vec<String>,
machine_limit: u32,
}
/// Claims embedded in the activation token JWT (server-signed, machine-bound).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ActivationClaims {
sub: String, // license id or hash
iat: i64,
exp: i64,
machine_id: String,
}
fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))
}
fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir(app)?.join(LICENSE_FILE))
}
fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
}
/// Strip the human-readable prefix and return the bare JWT.
fn strip_prefix(key: &str) -> Result<&str, String> {
let trimmed = key.trim();
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
return Ok(jwt);
}
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
return Ok(jwt);
}
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
}
/// Pure validation: decode the JWT, verify signature against `public_key_pem`, ensure the
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
///
/// Separated from the Tauri command so tests can pass their own key.
fn validate_with_key(key: &str, public_key_pem: &[u8]) -> Result<LicenseInfo, String> {
let jwt = strip_prefix(key)?;
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
.map_err(|e| format!("Invalid public key: {}", e))?;
let mut validation = Validation::new(Algorithm::EdDSA);
// jsonwebtoken validates `exp` by default; assert this explicitly so a future config
// change cannot silently disable it (CWE-613).
validation.validate_exp = true;
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "iat"]);
let data = decode::<LicenseClaims>(jwt, &decoding_key, &validation)
.map_err(|e| format!("Invalid license: {}", e))?;
let claims = data.claims;
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
return Err(format!("Unknown edition '{}'", claims.edition));
}
Ok(LicenseInfo {
edition: claims.edition,
email: claims.sub,
features: claims.features,
machine_limit: claims.machine_limit,
issued_at: claims.iat,
expires_at: claims.exp,
})
}
/// Validate an activation token against the local machine. The token must be signed by the
/// license server and its `machine_id` claim must match the local machine identifier.
fn validate_activation_with_key(
token: &str,
local_machine_id: &str,
public_key_pem: &[u8],
) -> Result<(), String> {
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
.map_err(|e| format!("Invalid public key: {}", e))?;
let mut validation = Validation::new(Algorithm::EdDSA);
validation.validate_exp = true;
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "iat"]);
let data = decode::<ActivationClaims>(token.trim(), &decoding_key, &validation)
.map_err(|e| format!("Invalid activation token: {}", e))?;
if data.claims.machine_id != local_machine_id {
return Err("Activation token belongs to a different machine".to_string());
}
Ok(())
}
// === Tauri commands ===========================================================================
/// Validate a license key without persisting it. Used by the UI to give immediate feedback
/// before the user confirms storage.
#[tauri::command]
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())
}
/// Persist a previously-validated license key to disk. The activation token (machine binding)
/// is stored separately by [`store_activation_token`] once the server has issued one.
#[tauri::command]
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
let info = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())?;
let path = license_path(&app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
Ok(info)
}
/// 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.
#[tauri::command]
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
let local_id = machine_id_internal()?;
validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes())?;
let path = activation_path(&app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e))
}
/// Read the stored license without revalidating. Returns `None` when no license is present.
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
#[tauri::command]
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
let path = license_path(&app)?;
if !path.exists() {
return Ok(None);
}
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
Ok(validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()).ok())
}
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
///
/// Returns "free" when:
/// - no license is stored,
/// - the license JWT is invalid or expired,
/// - an activation token exists but does not match this machine.
///
/// Note: a missing activation token is treated as a graceful pre-activation state and does
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
#[tauri::command]
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
Ok(current_edition(&app))
}
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string();
};
if !path.exists() {
return EDITION_FREE.to_string();
}
let Ok(key) = fs::read_to_string(&path) else {
return EDITION_FREE.to_string();
};
let Ok(info) = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()) else {
return EDITION_FREE.to_string();
};
// If an activation token exists, it must match the local machine. A missing token is
// accepted (graceful pre-activation).
if let Ok(activation_path) = activation_path(app) {
if activation_path.exists() {
let Ok(token) = fs::read_to_string(&activation_path) else {
return EDITION_FREE.to_string();
};
let Ok(local_id) = machine_id_internal() else {
return EDITION_FREE.to_string();
};
if validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes()).is_err()
{
return EDITION_FREE.to_string();
}
}
}
info.edition
}
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command]
pub fn get_machine_id() -> Result<String, String> {
machine_id_internal()
}
fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}
// === Tests ====================================================================================
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
use ed25519_dalek::SigningKey;
use jsonwebtoken::{encode, EncodingKey, Header};
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
fn test_keypair() -> (String, String) {
let seed = [42u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let private_pem = signing_key
.to_pkcs8_pem(LineEnding::LF)
.unwrap()
.to_string();
let public_pem = verifying_key.to_public_key_pem(LineEnding::LF).unwrap();
(private_pem, public_pem)
}
fn make_token(private_pem: &str, claims: &LicenseClaims) -> String {
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
}
fn make_activation_token(private_pem: &str, claims: &ActivationClaims) -> String {
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn rejects_key_without_prefix() {
let (_priv, public_pem) = test_keypair();
let result = validate_with_key("nonsense", public_pem.as_bytes());
assert!(result.is_err());
}
#[test]
fn accepts_well_formed_base_license() {
let (private_pem, public_pem) = test_keypair();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec!["auto-update".to_string()],
machine_limit: 3,
};
let jwt = make_token(&private_pem, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let info = validate_with_key(&key, public_pem.as_bytes()).unwrap();
assert_eq!(info.edition, EDITION_BASE);
assert_eq!(info.email, "user@example.com");
assert_eq!(info.machine_limit, 3);
}
#[test]
fn rejects_expired_license() {
let (private_pem, public_pem) = test_keypair();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now() - 1000,
exp: now() - 100,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&private_pem, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, public_pem.as_bytes());
assert!(result.is_err(), "expired license must be rejected");
}
#[test]
fn rejects_license_signed_with_wrong_key() {
let (private_pem, _public_pem) = test_keypair();
// Generate a different keypair to verify with
let other_seed = [7u8; 32];
let other_signing = SigningKey::from_bytes(&other_seed);
let other_public = other_signing
.verifying_key()
.to_public_key_pem(LineEnding::LF)
.unwrap();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&private_pem, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, other_public.as_bytes());
assert!(result.is_err(), "wrong-key signature must be rejected");
}
#[test]
fn rejects_corrupted_jwt() {
let (_priv, public_pem) = test_keypair();
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
let result = validate_with_key(&key, public_pem.as_bytes());
assert!(result.is_err());
}
#[test]
fn rejects_unknown_edition() {
let (private_pem, public_pem) = test_keypair();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: "enterprise".to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&private_pem, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, public_pem.as_bytes());
assert!(result.is_err());
}
#[test]
fn activation_token_matches_machine() {
let (private_pem, public_pem) = test_keypair();
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: "this-machine".to_string(),
};
let token = make_activation_token(&private_pem, &claims);
assert!(validate_activation_with_key(&token, "this-machine", public_pem.as_bytes()).is_ok());
}
#[test]
fn activation_token_rejects_other_machine() {
let (private_pem, public_pem) = test_keypair();
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: "machine-A".to_string(),
};
let token = make_activation_token(&private_pem, &claims);
let result = validate_activation_with_key(&token, "machine-B", public_pem.as_bytes());
assert!(result.is_err(), "copied activation token must be rejected");
}
}

View file

@ -1,7 +1,11 @@
pub mod fs_commands; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands; pub mod profile_commands;
pub use fs_commands::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use fs_commands::*;
pub use license_commands::*;
pub use profile_commands::*; pub use profile_commands::*;

View file

@ -114,6 +114,13 @@ pub fn run() {
commands::hash_pin, commands::hash_pin,
commands::verify_pin, commands::verify_pin,
commands::repair_migrations, commands::repair_migrations,
commands::validate_license_key,
commands::store_license,
commands::store_activation_token,
commands::read_license,
commands::get_edition,
commands::get_machine_id,
commands::check_entitlement,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -0,0 +1,128 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
import { useLicense } from "../../hooks/useLicense";
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() {
const { t } = useTranslation();
const { state, submitKey } = useLicense();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = keyInput.trim();
if (!trimmed) return;
const result = await submitKey(trimmed);
if (result.ok) {
setKeyInput("");
setShowInput(false);
}
};
const handlePurchase = () => {
void openUrl(PURCHASE_URL);
};
const formatExpiry = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString();
};
const editionLabel = t(`license.editions.${state.edition}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<KeyRound size={18} />
{t("license.title")}
</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("license.currentEdition")}
</p>
<p className="text-base font-medium">
{editionLabel}
{state.edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)}
</p>
</div>
{state.info && state.info.expires_at > 0 && (
<div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")}
</p>
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
</div>
)}
</div>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{!showInput && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowInput(true)}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("license.enterKey")}
</button>
{state.edition === "free" && (
<button
type="button"
onClick={handlePurchase}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<ExternalLink size={14} />
{t("license.purchase")}
</button>
)}
</div>
)}
{showInput && (
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={t("license.keyPlaceholder")}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
autoFocus
/>
<div className="flex gap-2">
<button
type="submit"
disabled={state.status === "validating" || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
>
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setKeyInput("");
}}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("common.cancel")}
</button>
</div>
</form>
)}
</div>
);
}

98
src/hooks/useLicense.ts Normal file
View file

@ -0,0 +1,98 @@
import { useCallback, useEffect, useReducer } from "react";
import {
Edition,
LicenseInfo,
checkEntitlement as checkEntitlementCmd,
getEdition,
readLicense,
storeLicense,
} from "../services/licenseService";
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
error: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string };
const initialState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
};
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: "ready",
edition: action.edition,
info: action.info,
error: null,
};
case "VALIDATE_START":
return { ...state, status: "validating", error: null };
case "VALIDATE_DONE":
return {
status: "ready",
edition: action.info.edition,
info: action.info,
error: null,
};
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useLicense() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string) => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true as const, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message });
return { ok: false as const, error: message };
}
}, []);
const checkEntitlement = useCallback(
(feature: string) => checkEntitlementCmd(feature),
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
return { state, refresh, submitKey, checkEntitlement };
}

View file

@ -846,5 +846,19 @@
"total": "Total", "total": "Total",
"darkMode": "Dark mode", "darkMode": "Dark mode",
"lightMode": "Light mode" "lightMode": "Light mode"
},
"license": {
"title": "License",
"currentEdition": "Current edition",
"expiresAt": "Expires on",
"enterKey": "Enter a license key",
"keyPlaceholder": "SR-BASE-...",
"activate": "Activate",
"purchase": "Buy Simpl'Result",
"editions": {
"free": "Free",
"base": "Base",
"premium": "Premium"
}
} }
} }

View file

@ -846,5 +846,19 @@
"total": "Total", "total": "Total",
"darkMode": "Mode sombre", "darkMode": "Mode sombre",
"lightMode": "Mode clair" "lightMode": "Mode clair"
},
"license": {
"title": "Licence",
"currentEdition": "Édition actuelle",
"expiresAt": "Expire le",
"enterKey": "Entrer une clé de licence",
"keyPlaceholder": "SR-BASE-...",
"activate": "Activer",
"purchase": "Acheter Simpl'Résultat",
"editions": {
"free": "Gratuite",
"base": "Base",
"premium": "Premium"
}
} }
} }

View file

@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
import { APP_NAME } from "../shared/constants"; import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard"; import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import LogViewerCard from "../components/settings/LogViewerCard"; import LogViewerCard from "../components/settings/LogViewerCard";
export default function SettingsPage() { export default function SettingsPage() {
@ -72,6 +73,9 @@ export default function SettingsPage() {
<PageHelp helpKey="settings" /> <PageHelp helpKey="settings" />
</div> </div>
{/* License card */}
<LicenseCard />
{/* About card */} {/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View file

@ -0,0 +1,36 @@
import { invoke } from "@tauri-apps/api/core";
export type Edition = "free" | "base" | "premium";
export interface LicenseInfo {
edition: Edition;
email: string;
features: string[];
machine_limit: number;
issued_at: number;
expires_at: number;
}
export async function validateLicenseKey(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("validate_license_key", { key });
}
export async function storeLicense(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("store_license", { key });
}
export async function readLicense(): Promise<LicenseInfo | null> {
return invoke<LicenseInfo | null>("read_license");
}
export async function getEdition(): Promise<Edition> {
return invoke<Edition>("get_edition");
}
export async function getMachineId(): Promise<string> {
return invoke<string>("get_machine_id");
}
export async function checkEntitlement(feature: string): Promise<boolean> {
return invoke<boolean>("check_entitlement", { feature });
}