diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index c5fe501..e081f6d 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,9 +5,13 @@ ### Ajouté - CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60) - 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) +- Carte Compte Maximus dans les Paramètres : connexion optionnelle via OAuth2 PKCE pour les fonctionnalités Premium (#51) +- Activation de machines : activer/désactiver des machines via le serveur de licences, voir les machines activées dans la carte licence (#53) +- Vérification quotidienne de l'abonnement : rafraîchit automatiquement les infos du compte une fois par jour au lancement (#51) ### Modifié - Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48) +- La détection d'édition prend maintenant en compte l'abonnement Compte Maximus : Premium remplace Base quand l'abonnement est actif (#51) ## [0.6.7] - 2026-03-29 diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b1dbd..9068e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,13 @@ ### Added - CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60) - License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47) +- Maximus Account card in Settings: optional sign-in via OAuth2 PKCE for Premium features (#51) +- Machine activation: activate/deactivate machines against the license server, view activated machines in the license card (#53) +- Daily subscription status check: automatically refreshes account info once per day at launch (#51) ### Changed - Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48) +- Edition detection now considers Maximus Account subscription: Premium overrides Base when subscription is active (#51) ## [0.6.7] - 2026-03-29 diff --git a/docs/architecture.md b/docs/architecture.md index f1d2ae8..3266c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture technique — Simpl'Résultat -> Document mis à jour le 2026-03-07 — Version 0.6.3 +> Document mis à jour le 2026-04-10 — Version 0.6.7 ## Stack technique @@ -26,7 +26,7 @@ ``` simpl-resultat/ ├── src/ # Frontend React/TypeScript -│ ├── components/ # 55 composants organisés par domaine +│ ├── components/ # 58 composants organisés par domaine │ │ ├── adjustments/ # 3 composants │ │ ├── budget/ # 5 composants │ │ ├── categories/ # 5 composants @@ -35,11 +35,11 @@ simpl-resultat/ │ │ ├── layout/ # AppShell, Sidebar │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique) -│ │ ├── settings/ # 3 composants (+ LogViewerCard) +│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard) │ │ ├── shared/ # 6 composants réutilisables │ │ └── transactions/ # 5 composants │ ├── contexts/ # ProfileContext (état global profil) -│ ├── hooks/ # 12 hooks custom (useReducer) +│ ├── hooks/ # 14 hooks custom (useReducer) │ ├── pages/ # 10 pages │ ├── services/ # 14 services métier │ ├── shared/ # Types et constantes partagés @@ -49,10 +49,13 @@ simpl-resultat/ │ └── main.tsx # Point d'entrée ├── src-tauri/ # Backend Rust │ ├── src/ -│ │ ├── commands/ # 3 modules de commandes Tauri +│ │ ├── commands/ # 6 modules de commandes Tauri │ │ │ ├── fs_commands.rs │ │ │ ├── export_import_commands.rs -│ │ │ └── profile_commands.rs +│ │ │ ├── profile_commands.rs +│ │ │ ├── license_commands.rs +│ │ │ ├── auth_commands.rs +│ │ │ └── entitlements.rs │ │ ├── database/ # Schémas SQL et migrations │ │ │ ├── schema.sql │ │ │ ├── seed_categories.sql @@ -107,7 +110,7 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). -## Services TypeScript (15) +## Services TypeScript (17) | Service | Responsabilité | |---------|---------------| @@ -126,8 +129,10 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le | `dataExportService.ts` | Export de données (chiffré) | | `userPreferenceService.ts` | Stockage préférences utilisateur | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | +| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) | +| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) | -## Hooks (12) +## Hooks (14) Chaque hook encapsule la logique d'état via `useReducer` : @@ -144,9 +149,11 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useReports` | Données analytiques | | `useDataExport` | Export de données | | `useTheme` | Thème clair/sombre | -| `useUpdater` | Mise à jour de l'application | +| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) | +| `useLicense` | État de la licence et entitlements | +| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | -## Commandes Tauri (18) +## Commandes Tauri (25) ### `fs_commands.rs` — Système de fichiers (6) @@ -175,6 +182,19 @@ Chaque hook encapsule la logique d'état via `useReducer` : - `verify_pin` — Vérification du PIN - `repair_migrations` — Réparation des checksums de migration (rusqlite) +### `license_commands.rs` — Licence (6) + +- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519) +- `store_license` — Stockage de la clé dans le répertoire app data +- `store_activation_token` — Stockage du token d'activation +- `read_license` — Lecture de la licence stockée +- `get_edition` — Détection de l'édition active (free/base/premium) +- `get_machine_id` — Génération d'un identifiant machine unique + +### `entitlements.rs` — Entitlements (1) + +- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition + ## Pages et routing Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b800b60..a9dda2f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -43,6 +43,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -562,6 +574,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -578,6 +610,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -601,9 +643,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -614,7 +656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -675,6 +717,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -732,6 +780,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling" version = "0.21.3" @@ -897,6 +972,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -939,6 +1023,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1063,6 +1172,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1078,6 +1199,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1138,6 +1265,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1145,7 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1159,6 +1295,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1417,8 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1591,12 +1735,40 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1614,6 +1786,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1674,6 +1855,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1735,6 +1927,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1762,6 +1955,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1780,9 +1989,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry 0.6.1", ] [[package]] @@ -2086,6 +2297,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2219,6 +2445,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "machine-uid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7217d573cdb141d6da43113b098172e057d39915d79c4bdedbc3aacd46bd96" +dependencies = [ + "libc", + "windows-registry 0.6.1", + "windows-sys 0.61.2", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2329,6 +2566,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2371,6 +2625,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2693,18 +2957,66 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2800,6 +3112,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3338,6 +3660,46 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3435,6 +3797,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -3498,7 +3884,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3624,7 +4010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3894,25 +4280,48 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simpl-result" -version = "0.3.0" +version = "0.6.7" dependencies = [ "aes-gcm", "argon2", + "base64 0.22.1", + "ed25519-dalek", "encoding_rs", + "hostname", + "jsonwebtoken", + "libsqlite3-sys", + "machine-uid", "rand 0.8.5", + "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "sha2", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-sql", "tauri-plugin-updater", + "tokio", + "urlencoding", "walkdir", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -4047,7 +4456,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap 2.13.0", "log", "memchr", @@ -4320,6 +4729,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4341,7 +4771,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -4431,7 +4861,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4532,6 +4962,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4640,7 +5091,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -4853,6 +5304,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4889,9 +5349,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5253,6 +5735,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5703,6 +6191,28 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 336e9c0..41fda00 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-deep-link = "2" libsqlite3-sys = { version = "0.30", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } @@ -37,6 +38,11 @@ argon2 = "0.5" rand = "0.8" jsonwebtoken = "9" machine-uid = "0.5" +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["macros"] } +hostname = "0.4" +urlencoding = "2" +base64 = "0.22" [dev-dependencies] # Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` diff --git a/src-tauri/src/commands/auth_commands.rs b/src-tauri/src/commands/auth_commands.rs new file mode 100644 index 0000000..bcf7218 --- /dev/null +++ b/src-tauri/src/commands/auth_commands.rs @@ -0,0 +1,392 @@ +// OAuth2 PKCE flow for Compte Maximus (Logto) integration. +// +// Architecture: +// - The desktop app is registered as a "Native App" in Logto (public client, no secret). +// - OAuth2 Authorization Code + PKCE flow via the system browser. +// - Deep-link callback: simpl-resultat://auth/callback?code=... +// - Tokens are stored as files in app_data_dir/auth/ (encrypted at rest in a future +// iteration via OS keychain). For now, plain JSON — acceptable because: +// (a) the app data dir has user-only permissions, +// (b) the access token is short-lived (1h default in Logto), +// (c) the refresh token is rotated on each use. +// +// The PKCE verifier is held in memory via Tauri managed state, so it cannot be +// intercepted by another process. It is cleared after the callback exchange. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::Manager; + +// Logto endpoint — overridable via env var for development. +fn logto_endpoint() -> String { + std::env::var("LOGTO_ENDPOINT") + .unwrap_or_else(|_| "https://auth.lacompagniemaximus.com".to_string()) +} + +// Logto app ID for the desktop native app. +fn logto_app_id() -> String { + std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "simpl-resultat-desktop".to_string()) +} + +const REDIRECT_URI: &str = "simpl-resultat://auth/callback"; +const AUTH_DIR: &str = "auth"; +const TOKENS_FILE: &str = "tokens.json"; +const ACCOUNT_FILE: &str = "account.json"; +const LAST_CHECK_FILE: &str = "last_check"; +const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours + +/// PKCE state held in memory during the OAuth2 flow. +pub struct OAuthState { + pub code_verifier: Mutex>, +} + +/// Account info exposed to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfo { + pub email: String, + pub name: Option, + pub picture: Option, + pub subscription_status: Option, +} + +/// Stored tokens (written to auth/tokens.json). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredTokens { + access_token: String, + refresh_token: Option, + id_token: Option, + expires_at: i64, +} + +fn auth_dir(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot get app data dir: {}", e))? + .join(AUTH_DIR); + if !dir.exists() { + fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?; + } + Ok(dir) +} + +/// Write a file with restricted permissions (0600 on Unix) for sensitive data like tokens. +fn write_restricted(path: &Path, contents: &str) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; + file.write_all(contents.as_bytes()) + .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; + } + #[cfg(not(unix))] + { + fs::write(path, contents) + .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; + } + Ok(()) +} + +fn generate_pkce() -> (String, String) { + use rand::Rng; + let mut rng = rand::thread_rng(); + let verifier: String = (0..64) + .map(|_| { + let idx = rng.gen_range(0..62); + let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx]; + c as char + }) + .collect(); + + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(verifier.as_bytes()); + let challenge = base64_url_encode(&hash); + + (verifier, challenge) +} + +fn base64_url_encode(data: &[u8]) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD.encode(data) +} + +/// Start the OAuth2 PKCE flow. Generates a code verifier/challenge, stores the verifier +/// in memory, and returns the authorization URL to open in the system browser. +#[tauri::command] +pub fn start_oauth(app: tauri::AppHandle) -> Result { + let (verifier, challenge) = generate_pkce(); + + // Store verifier in managed state + let state = app.state::(); + *state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))? = Some(verifier); + + let endpoint = logto_endpoint(); + let client_id = logto_app_id(); + + let url = format!( + "{}/oidc/auth?client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=openid%20profile%20email%20offline_access", + endpoint, + urlencoding::encode(&client_id), + urlencoding::encode(REDIRECT_URI), + urlencoding::encode(&challenge), + ); + + Ok(url) +} + +/// Exchange the authorization code for tokens. Called from the deep-link callback handler. +#[tauri::command] +pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result { + let verifier = { + let state = app.state::(); + let verifier = state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))?.take(); + verifier.ok_or("No pending OAuth flow (verifier missing)")? + }; + + let endpoint = logto_endpoint(); + let client_id = logto_app_id(); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/oidc/token", endpoint)) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", &client_id), + ("redirect_uri", REDIRECT_URI), + ("code", &code), + ("code_verifier", &verifier), + ]) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Token exchange failed: {}", e))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token exchange error: {}", body)); + } + + let token_resp: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Invalid token response: {}", e))?; + + let access_token = token_resp["access_token"] + .as_str() + .ok_or("Missing access_token")? + .to_string(); + let refresh_token = token_resp["refresh_token"].as_str().map(|s| s.to_string()); + let id_token = token_resp["id_token"].as_str().map(|s| s.to_string()); + let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600); + let expires_at = chrono_now() + expires_in; + + // Store tokens + let tokens = StoredTokens { + access_token: access_token.clone(), + refresh_token, + id_token, + expires_at, + }; + let dir = auth_dir(&app)?; + let tokens_json = + serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?; + write_restricted(&dir.join(TOKENS_FILE), &tokens_json)?; + + // Fetch user info + let account = fetch_userinfo(&endpoint, &access_token).await?; + + // Store account info + let account_json = + serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?; + write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?; + + Ok(account) +} + +/// Refresh the access token using the stored refresh token. +#[tauri::command] +pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result { + let dir = auth_dir(&app)?; + let tokens_path = dir.join(TOKENS_FILE); + if !tokens_path.exists() { + return Err("Not authenticated".to_string()); + } + + let tokens_raw = + fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?; + let tokens: StoredTokens = + serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?; + + let refresh_token = tokens + .refresh_token + .ok_or("No refresh token available")?; + + let endpoint = logto_endpoint(); + let client_id = logto_app_id(); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/oidc/token", endpoint)) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_id), + ("refresh_token", &refresh_token), + ]) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Token refresh failed: {}", e))?; + + if !resp.status().is_success() { + // Clear stored tokens on refresh failure + let _ = fs::remove_file(&tokens_path); + let _ = fs::remove_file(dir.join(ACCOUNT_FILE)); + return Err("Session expired, please sign in again".to_string()); + } + + let token_resp: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Invalid token response: {}", e))?; + + let new_access = token_resp["access_token"] + .as_str() + .ok_or("Missing access_token")? + .to_string(); + let new_refresh = token_resp["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600); + + let new_tokens = StoredTokens { + access_token: new_access.clone(), + refresh_token: new_refresh.or(Some(refresh_token)), + id_token: token_resp["id_token"].as_str().map(|s| s.to_string()), + expires_at: chrono_now() + expires_in, + }; + + let tokens_json = serde_json::to_string_pretty(&new_tokens) + .map_err(|e| format!("Serialize error: {}", e))?; + write_restricted(&tokens_path, &tokens_json)?; + + let account = fetch_userinfo(&endpoint, &new_access).await?; + let account_json = + serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?; + write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?; + + Ok(account) +} + +/// Read cached account info without network call. +#[tauri::command] +pub fn get_account_info(app: tauri::AppHandle) -> Result, String> { + let dir = auth_dir(&app)?; + let path = dir.join(ACCOUNT_FILE); + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?; + let account: AccountInfo = + serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?; + Ok(Some(account)) +} + +/// Log out: clear all stored tokens and account info. +#[tauri::command] +pub fn logout(app: tauri::AppHandle) -> Result<(), String> { + let dir = auth_dir(&app)?; + let _ = fs::remove_file(dir.join(TOKENS_FILE)); + let _ = fs::remove_file(dir.join(ACCOUNT_FILE)); + Ok(()) +} + +/// Check subscription status if the last check was more than 24h ago. +/// Returns the refreshed account info, or the cached info if no check was needed. +/// Graceful: returns Ok(None) if not authenticated, silently skips on network errors. +#[tauri::command] +pub async fn check_subscription_status( + app: tauri::AppHandle, +) -> Result, String> { + let dir = auth_dir(&app)?; + + // Not authenticated — nothing to check + if !dir.join(TOKENS_FILE).exists() { + return Ok(None); + } + + let last_check_path = dir.join(LAST_CHECK_FILE); + let now = chrono_now(); + + // Check if we need to verify (more than 24h since last check) + if last_check_path.exists() { + if let Ok(raw) = fs::read_to_string(&last_check_path) { + if let Ok(ts) = raw.trim().parse::() { + if now - ts < CHECK_INTERVAL_SECS { + // Recent check — return cached account info + return get_account_info(app); + } + } + } + } + + // Try to refresh the token to get fresh subscription status + match refresh_auth_token(app.clone()).await { + Ok(account) => { + // Update last check timestamp + let _ = write_restricted(&last_check_path, &now.to_string()); + Ok(Some(account)) + } + Err(_) => { + // Network error or expired session — graceful degradation. + // Still update the timestamp to avoid hammering on every launch. + let _ = write_restricted(&last_check_path, &now.to_string()); + get_account_info(app) + } + } +} + +async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/oidc/me", endpoint)) + .bearer_auth(access_token) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Userinfo fetch failed: {}", e))?; + + if !resp.status().is_success() { + return Err("Cannot fetch user info".to_string()); + } + + let info: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Invalid userinfo response: {}", e))?; + + Ok(AccountInfo { + email: info["email"] + .as_str() + .unwrap_or_default() + .to_string(), + name: info["name"].as_str().map(|s| s.to_string()), + picture: info["picture"].as_str().map(|s| s.to_string()), + subscription_status: info["custom_data"]["subscription_status"] + .as_str() + .map(|s| s.to_string()), + }) +} + +fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 8e94862..8092a73 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -22,12 +22,10 @@ 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). +// Production key generated 2026-04-10. The corresponding private key lives ONLY +// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM. const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ -MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\ +MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\ -----END PUBLIC KEY-----\n"; const LICENSE_FILE: &str = "license.key"; @@ -224,7 +222,16 @@ pub fn get_edition(app: tauri::AppHandle) -> Result { /// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// failure resolves to "free" so feature gates fail closed. +/// +/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free. pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { + // Check Compte Maximus subscription first — Premium overrides Base + if let Some(edition) = check_account_edition(app) { + if edition == EDITION_PREMIUM { + return edition; + } + } + let Ok(path) = license_path(app) else { return EDITION_FREE.to_string(); }; @@ -260,6 +267,22 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { info.edition } +/// Read the cached account.json to check for an active Premium subscription. +/// Returns None if no account file exists or the file is invalid. +fn check_account_edition(app: &tauri::AppHandle) -> Option { + let dir = app_data_dir(app).ok()?.join("auth"); + let account_path = dir.join("account.json"); + if !account_path.exists() { + return None; + } + let raw = fs::read_to_string(&account_path).ok()?; + let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?; + match account.subscription_status.as_deref() { + Some("active") => Some(EDITION_PREMIUM.to_string()), + _ => None, + } +} + /// 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] @@ -271,6 +294,202 @@ fn machine_id_internal() -> Result { machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) } +// License server API base URL. Overridable via SIMPL_API_URL env var for development. +fn api_base_url() -> String { + std::env::var("SIMPL_API_URL") + .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) +} + +/// Machine info returned by the license server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineInfo { + pub machine_id: String, + pub machine_name: Option, + pub activated_at: String, + pub last_seen_at: String, +} + +/// Activation status for display in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivationStatus { + pub is_activated: bool, + pub machine_id: String, +} + +/// 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. +#[tauri::command] +pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> { + let key_path = license_path(&app)?; + if !key_path.exists() { + return Err("No license key stored".to_string()); + } + let license_key = + fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; + let machine_id = machine_id_internal()?; + let machine_name = hostname::get() + .ok() + .and_then(|h| h.into_string().ok()); + + let url = format!("{}/licenses/activate", api_base_url()); + let client = reqwest::Client::new(); + + let mut body = serde_json::json!({ + "license_key": license_key.trim(), + "machine_id": machine_id, + }); + if let Some(name) = machine_name { + body["machine_name"] = serde_json::Value::String(name); + } + + let resp = client + .post(&url) + .json(&body) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Cannot reach license server: {}", e))?; + + let status = resp.status(); + let resp_body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Invalid server response: {}", e))?; + + if !status.is_success() { + let error = resp_body["error"] + .as_str() + .unwrap_or("Activation failed"); + return Err(error.to_string()); + } + + let token = resp_body["activation_token"] + .as_str() + .ok_or("Server did not return an activation token")?; + + // store_activation_token validates the token against local machine_id before writing + store_activation_token(app, token.to_string())?; + Ok(()) +} + +/// Deactivate a machine on the license server, freeing a slot. +#[tauri::command] +pub async fn deactivate_machine( + app: tauri::AppHandle, + machine_id: String, +) -> Result<(), String> { + let key_path = license_path(&app)?; + if !key_path.exists() { + return Err("No license key stored".to_string()); + } + let license_key = + fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; + + let url = format!("{}/licenses/deactivate", api_base_url()); + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "license_key": license_key.trim(), + "machine_id": machine_id, + })) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Cannot reach license server: {}", e))?; + + let status = resp.status(); + if !status.is_success() { + let resp_body: serde_json::Value = resp + .json() + .await + .unwrap_or(serde_json::json!({"error": "Deactivation failed"})); + let error = resp_body["error"].as_str().unwrap_or("Deactivation failed"); + return Err(error.to_string()); + } + + // If deactivating this machine, remove the local activation token + let local_id = machine_id_internal()?; + if machine_id == local_id { + let act_path = activation_path(&app)?; + if act_path.exists() { + let _ = fs::remove_file(&act_path); + } + } + + Ok(()) +} + +/// List all machines currently activated for the stored license. +#[tauri::command] +pub async fn list_activated_machines(app: tauri::AppHandle) -> Result, String> { + let key_path = license_path(&app)?; + if !key_path.exists() { + return Ok(vec![]); + } + let license_key = + fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; + + let url = format!("{}/licenses/verify", api_base_url()); + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "license_key": license_key.trim(), + })) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Cannot reach license server: {}", e))?; + + if !resp.status().is_success() { + return Err("Cannot verify license".to_string()); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Invalid server response: {}", e))?; + + // The verify endpoint returns machines in the response when valid + let machines = body["machines"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| serde_json::from_value::(m.clone()).ok()) + .collect() + }) + .unwrap_or_default(); + + Ok(machines) +} + +/// Check the local activation status without contacting the server. +#[tauri::command] +pub fn get_activation_status(app: tauri::AppHandle) -> Result { + let machine_id = machine_id_internal()?; + let act_path = activation_path(&app)?; + + let is_activated = if act_path.exists() { + if let Ok(token) = fs::read_to_string(&act_path) { + if let Ok(decoding_key) = embedded_decoding_key() { + validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok() + } else { + false + } + } else { + false + } + } else { + false + }; + + Ok(ActivationStatus { + is_activated, + machine_id, + }) +} + // === Tests ==================================================================================== #[cfg(test)] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5ecb666..d87c164 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,9 +1,11 @@ +pub mod auth_commands; pub mod entitlements; pub mod export_import_commands; pub mod fs_commands; pub mod license_commands; pub mod profile_commands; +pub use auth_commands::*; pub use entitlements::*; pub use export_import_commands::*; pub use fs_commands::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c7b1c9..c696613 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ mod commands; mod database; +use std::sync::Mutex; +use tauri::{Emitter, Listener}; use tauri_plugin_sql::{Migration, MigrationKind}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -82,12 +84,41 @@ pub fn run() { ]; tauri::Builder::default() + .manage(commands::auth_commands::OAuthState { + code_verifier: Mutex::new(None), + }) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_deep_link::init()) .setup(|app| { #[cfg(desktop)] app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; + + // Listen for deep-link events (simpl-resultat://auth/callback?code=...) + let handle = app.handle().clone(); + app.listen("deep-link://new-url", move |event| { + let payload = event.payload(); + // payload is a JSON-serialized array of URL strings + if let Ok(urls) = serde_json::from_str::>(payload) { + for url in urls { + if let Some(code) = extract_auth_code(&url) { + let h = handle.clone(); + tauri::async_runtime::spawn(async move { + match commands::handle_auth_callback(h.clone(), code).await { + Ok(account) => { + let _ = h.emit("auth-callback-success", &account); + } + Err(err) => { + let _ = h.emit("auth-callback-error", &err); + } + } + }); + } + } + } + }); + Ok(()) }) .plugin( @@ -121,7 +152,35 @@ pub fn run() { commands::get_edition, commands::get_machine_id, commands::check_entitlement, + commands::activate_machine, + commands::deactivate_machine, + commands::list_activated_machines, + commands::get_activation_status, + commands::start_oauth, + commands::refresh_auth_token, + commands::get_account_info, + commands::check_subscription_status, + commands::logout, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +/// Extract the `code` query parameter from a deep-link callback URL. +/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123") +fn extract_auth_code(url: &str) -> Option { + let url = url.trim(); + if !url.starts_with("simpl-resultat://auth/callback") { + return None; + } + let query = url.split('?').nth(1)?; + for pair in query.split('&') { + let mut kv = pair.splitn(2, '='); + if kv.next()? == "code" { + return kv.next().map(|v| { + urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string()) + }); + } + } + None +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 97c23ab..9d97e68 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" } }, "bundle": { @@ -34,6 +34,11 @@ "createUpdaterArtifacts": true }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["simpl-resultat"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK", "endpoints": [ diff --git a/src/components/settings/AccountCard.tsx b/src/components/settings/AccountCard.tsx new file mode 100644 index 0000000..b19e0d7 --- /dev/null +++ b/src/components/settings/AccountCard.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; +import { User, LogIn, LogOut, Loader2, AlertCircle } from "lucide-react"; +import { useAuth } from "../../hooks/useAuth"; + +export default function AccountCard() { + const { t } = useTranslation(); + const { state, login, logout } = useAuth(); + + return ( +
+

+ + {t("account.title")} + + {t("account.optional")} + +

+ + {state.status === "error" && state.error && ( +
+ +

{state.error}

+
+ )} + + {state.status === "authenticated" && state.account && ( +
+
+
+ {(state.account.name || state.account.email).charAt(0).toUpperCase()} +
+
+

+ {state.account.name || state.account.email} +

+ {state.account.name && ( +

+ {state.account.email} +

+ )} +
+
+ + +
+ )} + + {(state.status === "unauthenticated" || state.status === "idle") && ( +
+

+ {t("account.description")} +

+ +
+ )} + + {state.status === "loading" && ( +
+ + {t("common.loading")} +
+ )} +
+ ); +} diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index d15a659..c502aa5 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -1,8 +1,16 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react"; +import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useLicense } from "../../hooks/useLicense"; +import { + MachineInfo, + ActivationStatus, + activateMachine, + deactivateMachine, + listActivatedMachines, + getActivationStatus, +} from "../../services/licenseService"; const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat"; @@ -11,6 +19,75 @@ export default function LicenseCard() { const { state, submitKey } = useLicense(); const [keyInput, setKeyInput] = useState(""); const [showInput, setShowInput] = useState(false); + const [showMachines, setShowMachines] = useState(false); + const [machines, setMachines] = useState([]); + const [activation, setActivation] = useState(null); + const [machineLoading, setMachineLoading] = useState(false); + const [deactivatingId, setDeactivatingId] = useState(null); + const [machineError, setMachineError] = useState(null); + + const hasLicense = state.edition !== "free"; + + const loadActivation = useCallback(async () => { + if (!hasLicense) return; + try { + const status = await getActivationStatus(); + setActivation(status); + } catch { + // Ignore — activation status is best-effort + } + }, [hasLicense]); + + const loadMachines = useCallback(async () => { + setMachineLoading(true); + setMachineError(null); + try { + const list = await listActivatedMachines(); + setMachines(list); + } catch (e) { + setMachineError(e instanceof Error ? e.message : String(e)); + } finally { + setMachineLoading(false); + } + }, []); + + useEffect(() => { + void loadActivation(); + }, [loadActivation]); + + const handleActivate = async () => { + setMachineLoading(true); + setMachineError(null); + try { + await activateMachine(); + await loadActivation(); + } catch (e) { + setMachineError(e instanceof Error ? e.message : String(e)); + } finally { + setMachineLoading(false); + } + }; + + const handleDeactivate = async (machineId: string) => { + setDeactivatingId(machineId); + try { + await deactivateMachine(machineId); + await loadActivation(); + await loadMachines(); + } catch (e) { + setMachineError(e instanceof Error ? e.message : String(e)); + } finally { + setDeactivatingId(null); + } + }; + + const toggleMachines = async () => { + const next = !showMachines; + setShowMachines(next); + if (next && machines.length === 0) { + await loadMachines(); + } + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -123,6 +200,100 @@ export default function LicenseCard() { )} + + {hasLicense && ( +
+
+

+ + {t("license.machines.title")} +

+
+ {activation && !activation.is_activated && ( + + )} + {activation?.is_activated && ( + + + {t("license.machines.activated")} + + )} + +
+
+ + {machineError && ( +
+ +

{machineError}

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

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

+ )} + {machines.map((m) => { + const isThis = activation?.machine_id === m.machine_id; + return ( +
+
+ + {m.machine_name || m.machine_id.slice(0, 12)} + + {isThis && ( + + ({t("license.machines.thisMachine")}) + + )} +

+ {new Date(m.activated_at).toLocaleDateString()} +

+
+ +
+ ); + })} +
+ )} +
+ )} ); } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..0e533b0 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useReducer } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { listen } from "@tauri-apps/api/event"; +import { + AccountInfo, + startOAuth, + getAccountInfo, + checkSubscriptionStatus, + logoutAccount, +} from "../services/authService"; + +type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error"; + +interface AuthState { + status: AuthStatus; + account: AccountInfo | null; + error: string | null; +} + +type AuthAction = + | { type: "LOAD_START" } + | { type: "LOAD_DONE"; account: AccountInfo | null } + | { type: "LOGIN_START" } + | { type: "LOGOUT" } + | { type: "ERROR"; error: string }; + +const initialState: AuthState = { + status: "idle", + account: null, + error: null, +}; + +function reducer(state: AuthState, action: AuthAction): AuthState { + switch (action.type) { + case "LOAD_START": + return { ...state, status: "loading", error: null }; + case "LOAD_DONE": + return { + status: action.account ? "authenticated" : "unauthenticated", + account: action.account, + error: null, + }; + case "LOGIN_START": + return { ...state, status: "loading", error: null }; + case "LOGOUT": + return { status: "unauthenticated", account: null, error: null }; + case "ERROR": + return { ...state, status: "error", error: action.error }; + } +} + +export function useAuth() { + const [state, dispatch] = useReducer(reducer, initialState); + + const refresh = useCallback(async () => { + dispatch({ type: "LOAD_START" }); + try { + // checkSubscriptionStatus refreshes the token if last check > 24h, + // otherwise returns cached account info. Graceful on network errors. + const account = await checkSubscriptionStatus(); + dispatch({ type: "LOAD_DONE", account }); + } catch (e) { + // Fallback to cached account info if the check command itself fails + try { + const cached = await getAccountInfo(); + dispatch({ type: "LOAD_DONE", account: cached }); + } catch { + dispatch({ + type: "ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + } + }, []); + + const login = useCallback(async () => { + dispatch({ type: "LOGIN_START" }); + try { + const url = await startOAuth(); + await openUrl(url); + // The actual auth completion happens via the deep-link callback, + // which triggers handle_auth_callback on the Rust side. + // The UI should call refresh() after the callback completes. + } catch (e) { + dispatch({ + type: "ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + const logout = useCallback(async () => { + try { + await logoutAccount(); + dispatch({ type: "LOGOUT" }); + } catch (e) { + dispatch({ + type: "ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Listen for deep-link auth callback events from the Rust backend + useEffect(() => { + const unlisten: Array<() => void> = []; + + listen("auth-callback-success", (event) => { + dispatch({ type: "LOAD_DONE", account: event.payload }); + }).then((fn) => unlisten.push(fn)); + + listen("auth-callback-error", (event) => { + dispatch({ type: "ERROR", error: event.payload }); + }).then((fn) => unlisten.push(fn)); + + return () => { + unlisten.forEach((fn) => fn()); + }; + }, []); + + return { state, refresh, login, logout }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 149e8dc..ce2bbb1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -861,6 +861,27 @@ "free": "Free", "base": "Base", "premium": "Premium" + }, + "removeLicense": "Remove license", + "machines": { + "title": "Machines", + "count": "{{count}}/{{limit}} machines activated", + "activating": "Activating...", + "activated": "Activated", + "notActivated": "Not activated", + "deactivate": "Deactivate", + "deactivating": "Deactivating...", + "activateError": "Activation failed", + "thisMachine": "This machine", + "noMachines": "No machines activated" } + }, + "account": { + "title": "Maximus Account", + "optional": "Optional", + "description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.", + "signIn": "Sign in", + "signOut": "Sign out", + "connected": "Connected" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9c0924d..1b0fbff 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -861,6 +861,27 @@ "free": "Gratuite", "base": "Base", "premium": "Premium" + }, + "removeLicense": "Supprimer la licence", + "machines": { + "title": "Machines", + "count": "{{count}}/{{limit}} machines activées", + "activating": "Activation...", + "activated": "Activée", + "notActivated": "Non activée", + "deactivate": "Désactiver", + "deactivating": "Désactivation...", + "activateError": "Échec de l'activation", + "thisMachine": "Cette machine", + "noMachines": "Aucune machine activée" } + }, + "account": { + "title": "Compte Maximus", + "optional": "Optionnel", + "description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.", + "signIn": "Se connecter", + "signOut": "Se déconnecter", + "connected": "Connecté" } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 5f91f3c..b09ae1f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -20,6 +20,7 @@ import { APP_NAME } from "../shared/constants"; import { PageHelp } from "../components/shared/PageHelp"; import DataManagementCard from "../components/settings/DataManagementCard"; import LicenseCard from "../components/settings/LicenseCard"; +import AccountCard from "../components/settings/AccountCard"; import LogViewerCard from "../components/settings/LogViewerCard"; export default function SettingsPage() { @@ -76,6 +77,9 @@ export default function SettingsPage() { {/* License card */} + {/* Account card */} + + {/* About card */}
diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..845e961 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,28 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface AccountInfo { + email: string; + name: string | null; + picture: string | null; + subscription_status: string | null; +} + +export async function startOAuth(): Promise { + return invoke("start_oauth"); +} + +export async function refreshAuthToken(): Promise { + return invoke("refresh_auth_token"); +} + +export async function getAccountInfo(): Promise { + return invoke("get_account_info"); +} + +export async function checkSubscriptionStatus(): Promise { + return invoke("check_subscription_status"); +} + +export async function logoutAccount(): Promise { + return invoke("logout"); +} diff --git a/src/services/licenseService.ts b/src/services/licenseService.ts index 73beee9..4971e76 100644 --- a/src/services/licenseService.ts +++ b/src/services/licenseService.ts @@ -34,3 +34,31 @@ export async function getMachineId(): Promise { export async function checkEntitlement(feature: string): Promise { return invoke("check_entitlement", { feature }); } + +export interface MachineInfo { + machine_id: string; + machine_name: string | null; + activated_at: string; + last_seen_at: string; +} + +export interface ActivationStatus { + is_activated: boolean; + machine_id: string; +} + +export async function activateMachine(): Promise { + return invoke("activate_machine"); +} + +export async function deactivateMachine(machineId: string): Promise { + return invoke("deactivate_machine", { machineId }); +} + +export async function listActivatedMachines(): Promise { + return invoke("list_activated_machines"); +} + +export async function getActivationStatus(): Promise { + return invoke("get_activation_status"); +}