- Add auth_commands.rs: OAuth2 PKCE flow (start_oauth, handle_auth_callback, refresh_auth_token, get_account_info, check_subscription_status, logout) - Add deep-link handler in lib.rs for simpl-resultat://auth/callback - Add AccountCard.tsx + useAuth hook + authService.ts - Add machine activation commands (activate, deactivate, list, get_activation_status) - Extend LicenseCard with machine management UI - get_edition() now checks account subscription for Premium detection - Daily subscription status check (refresh token if last check > 24h) - Configure CSP for API/auth endpoints - Configure tauri-plugin-deep-link for desktop - Update i18n (FR/EN), changelogs, and architecture docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
877ace370f
commit
b53a902f11
19 changed files with 1746 additions and 29 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -51,3 +51,4 @@ public/CHANGELOG.fr.md
|
||||||
|
|
||||||
# Tauri generated
|
# Tauri generated
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
.keys-temp/
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@
|
||||||
### Ajouté
|
### 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)
|
- 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 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é
|
### Modifié
|
||||||
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
|
- 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@
|
||||||
### Added
|
### 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)
|
- 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)
|
- 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
|
### Changed
|
||||||
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
|
- 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture technique — Simpl'Résultat
|
# 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
|
## Stack technique
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
```
|
```
|
||||||
simpl-resultat/
|
simpl-resultat/
|
||||||
├── src/ # Frontend React/TypeScript
|
├── src/ # Frontend React/TypeScript
|
||||||
│ ├── components/ # 55 composants organisés par domaine
|
│ ├── components/ # 58 composants organisés par domaine
|
||||||
│ │ ├── adjustments/ # 3 composants
|
│ │ ├── adjustments/ # 3 composants
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
|
|
@ -35,11 +35,11 @@ simpl-resultat/
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
||||||
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
|
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
||||||
│ │ ├── shared/ # 6 composants réutilisables
|
│ │ ├── shared/ # 6 composants réutilisables
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
│ ├── contexts/ # ProfileContext (état global profil)
|
│ ├── contexts/ # ProfileContext (état global profil)
|
||||||
│ ├── hooks/ # 12 hooks custom (useReducer)
|
│ ├── hooks/ # 13 hooks custom (useReducer)
|
||||||
│ ├── pages/ # 10 pages
|
│ ├── pages/ # 10 pages
|
||||||
│ ├── services/ # 14 services métier
|
│ ├── services/ # 14 services métier
|
||||||
│ ├── shared/ # Types et constantes partagés
|
│ ├── shared/ # Types et constantes partagés
|
||||||
|
|
@ -49,10 +49,13 @@ simpl-resultat/
|
||||||
│ └── main.tsx # Point d'entrée
|
│ └── main.tsx # Point d'entrée
|
||||||
├── src-tauri/ # Backend Rust
|
├── src-tauri/ # Backend Rust
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── commands/ # 3 modules de commandes Tauri
|
│ │ ├── commands/ # 6 modules de commandes Tauri
|
||||||
│ │ │ ├── fs_commands.rs
|
│ │ │ ├── fs_commands.rs
|
||||||
│ │ │ ├── export_import_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
|
│ │ ├── database/ # Schémas SQL et migrations
|
||||||
│ │ │ ├── schema.sql
|
│ │ │ ├── schema.sql
|
||||||
│ │ │ ├── seed_categories.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).
|
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é |
|
| Service | Responsabilité |
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
|
|
@ -126,8 +129,10 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `dataExportService.ts` | Export de données (chiffré) |
|
| `dataExportService.ts` | Export de données (chiffré) |
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||||
|
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
|
||||||
|
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
||||||
|
|
||||||
## Hooks (12)
|
## Hooks (14)
|
||||||
|
|
||||||
Chaque hook encapsule la logique d'état via `useReducer` :
|
Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
|
|
||||||
|
|
@ -144,9 +149,10 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useReports` | Données analytiques |
|
| `useReports` | Données analytiques |
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
| `useTheme` | Thème clair/sombre |
|
| `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 |
|
||||||
|
|
||||||
## Commandes Tauri (18)
|
## Commandes Tauri (25)
|
||||||
|
|
||||||
### `fs_commands.rs` — Système de fichiers (6)
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
||||||
|
|
@ -175,6 +181,19 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
- `verify_pin` — Vérification du PIN
|
- `verify_pin` — Vérification du PIN
|
||||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
- `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
|
## 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).
|
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).
|
||||||
|
|
|
||||||
531
src-tauri/Cargo.lock
generated
531
src-tauri/Cargo.lock
generated
|
|
@ -43,6 +43,18 @@ dependencies = [
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -562,6 +574,26 @@ version = "0.9.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
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]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -578,6 +610,16 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -601,9 +643,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -614,7 +656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -675,6 +717,12 @@ version = "0.8.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -732,6 +780,33 @@ dependencies = [
|
||||||
"cipher",
|
"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]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
|
|
@ -897,6 +972,15 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
version = "0.15.7"
|
version = "0.15.7"
|
||||||
|
|
@ -939,6 +1023,31 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -1063,6 +1172,18 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -1078,6 +1199,12 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
|
|
@ -1138,6 +1265,15 @@ version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -1145,7 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1159,6 +1295,12 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -1417,8 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1591,12 +1735,40 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
|
|
@ -1614,6 +1786,15 @@ version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
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]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
@ -1674,6 +1855,17 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
|
|
@ -1735,6 +1927,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1762,6 +1955,22 @@ dependencies = [
|
||||||
"tower-service",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
|
|
@ -1780,9 +1989,11 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2086,6 +2297,21 @@ dependencies = [
|
||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -2219,6 +2445,17 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
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]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
|
|
@ -2329,6 +2566,23 @@ dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -2371,6 +2625,16 @@ version = "0.1.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -2693,18 +2957,66 @@ dependencies = [
|
||||||
"pathdiff",
|
"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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
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]]
|
[[package]]
|
||||||
name = "ordered-stream"
|
name = "ordered-stream"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2800,6 +3112,16 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -3338,6 +3660,46 @@ version = "0.8.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -3435,6 +3797,30 @@ dependencies = [
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -3498,7 +3884,7 @@ version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni",
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -3624,7 +4010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
|
|
@ -3894,25 +4280,47 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.3.0"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"ed25519-dalek",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"hostname",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"machine-uid",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-deep-link",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
|
|
@ -4047,7 +4455,7 @@ dependencies = [
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"hashlink",
|
"hashlink 0.10.0",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -4320,6 +4728,27 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
|
|
@ -4341,7 +4770,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
|
|
@ -4431,7 +4860,7 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
|
@ -4532,6 +4961,27 @@ dependencies = [
|
||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
|
|
@ -4640,7 +5090,7 @@ dependencies = [
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -4853,6 +5303,15 @@ dependencies = [
|
||||||
"time-core",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -4889,9 +5348,31 @@ dependencies = [
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|
@ -5253,6 +5734,12 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -5703,6 +6190,28 @@ dependencies = [
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
@ -37,6 +38,10 @@ argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
machine-uid = "0.5"
|
machine-uid = "0.5"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
tokio = { version = "1", features = ["macros"] }
|
||||||
|
hostname = "0.4"
|
||||||
|
urlencoding = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
|
||||||
|
|
|
||||||
405
src-tauri/src/commands/auth_commands.rs
Normal file
405
src-tauri/src/commands/auth_commands.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
// 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::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<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account info exposed to the frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountInfo {
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub picture: Option<String>,
|
||||||
|
pub subscription_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stored tokens (written to auth/tokens.json).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct StoredTokens {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
id_token: Option<String>,
|
||||||
|
expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_encode::encode;
|
||||||
|
encode(data)
|
||||||
|
.replace('+', "-")
|
||||||
|
.replace('/', "_")
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple base64 encoding without external dependency
|
||||||
|
mod base64_encode {
|
||||||
|
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
pub fn encode(data: &[u8]) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b0 = chunk[0] as u32;
|
||||||
|
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
||||||
|
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
||||||
|
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
|
||||||
|
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
|
||||||
|
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
|
||||||
|
if chunk.len() > 1 {
|
||||||
|
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
result.push('=');
|
||||||
|
}
|
||||||
|
if chunk.len() > 2 {
|
||||||
|
result.push(CHARS[(triple & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
result.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String, String> {
|
||||||
|
let (verifier, challenge) = generate_pkce();
|
||||||
|
|
||||||
|
// Store verifier in managed state
|
||||||
|
let state = app.state::<OAuthState>();
|
||||||
|
*state.code_verifier.lock().unwrap() = 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<AccountInfo, String> {
|
||||||
|
let verifier = {
|
||||||
|
let state = app.state::<OAuthState>();
|
||||||
|
let verifier = state.code_verifier.lock().unwrap().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))?;
|
||||||
|
fs::write(dir.join(TOKENS_FILE), tokens_json)
|
||||||
|
.map_err(|e| format!("Cannot write tokens: {}", e))?;
|
||||||
|
|
||||||
|
// 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))?;
|
||||||
|
fs::write(dir.join(ACCOUNT_FILE), account_json)
|
||||||
|
.map_err(|e| format!("Cannot write account info: {}", e))?;
|
||||||
|
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the access token using the stored refresh token.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
|
||||||
|
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))?;
|
||||||
|
fs::write(&tokens_path, tokens_json)
|
||||||
|
.map_err(|e| format!("Cannot write tokens: {}", e))?;
|
||||||
|
|
||||||
|
let account = fetch_userinfo(&endpoint, &new_access).await?;
|
||||||
|
let account_json =
|
||||||
|
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
|
||||||
|
fs::write(dir.join(ACCOUNT_FILE), account_json)
|
||||||
|
.map_err(|e| format!("Cannot write account info: {}", e))?;
|
||||||
|
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read cached account info without network call.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, 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<Option<AccountInfo>, 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::<i64>() {
|
||||||
|
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 _ = fs::write(&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 _ = fs::write(&last_check_path, now.to_string());
|
||||||
|
get_account_info(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInfo, String> {
|
||||||
|
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()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,10 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
||||||
|
|
||||||
// Ed25519 public key for license verification.
|
// Ed25519 public key for license verification.
|
||||||
//
|
//
|
||||||
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
|
// Production key generated 2026-04-10. The corresponding private key lives ONLY
|
||||||
// The matching private key is publicly known, so any license signed with it offers no real
|
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
|
||||||
// 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\
|
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||||
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
|
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\
|
||||||
-----END PUBLIC KEY-----\n";
|
-----END PUBLIC KEY-----\n";
|
||||||
|
|
||||||
const LICENSE_FILE: &str = "license.key";
|
const LICENSE_FILE: &str = "license.key";
|
||||||
|
|
@ -224,7 +222,16 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
|
||||||
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
|
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
|
||||||
/// failure resolves to "free" so feature gates fail closed.
|
/// 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 {
|
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 {
|
let Ok(path) = license_path(app) else {
|
||||||
return EDITION_FREE.to_string();
|
return EDITION_FREE.to_string();
|
||||||
};
|
};
|
||||||
|
|
@ -260,6 +267,22 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
info.edition
|
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<String> {
|
||||||
|
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
|
/// 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).
|
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -271,6 +294,202 @@ fn machine_id_internal() -> Result<String, String> {
|
||||||
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// License server API base URL. Overridable via 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<String>,
|
||||||
|
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<Vec<MachineInfo>, 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::<MachineInfo>(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<ActivationStatus, String> {
|
||||||
|
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 ====================================================================================
|
// === Tests ====================================================================================
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
pub mod auth_commands;
|
||||||
pub mod entitlements;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
pub mod fs_commands;
|
pub mod fs_commands;
|
||||||
pub mod license_commands;
|
pub mod license_commands;
|
||||||
pub mod profile_commands;
|
pub mod profile_commands;
|
||||||
|
|
||||||
|
pub use auth_commands::*;
|
||||||
pub use entitlements::*;
|
pub use entitlements::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
pub use fs_commands::*;
|
pub use fs_commands::*;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod database;
|
mod database;
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{Emitter, Listener};
|
||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|
@ -82,12 +84,41 @@ pub fn run() {
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(commands::auth_commands::OAuthState {
|
||||||
|
code_verifier: Mutex::new(None),
|
||||||
|
})
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
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::<Vec<String>>(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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(
|
.plugin(
|
||||||
|
|
@ -121,7 +152,34 @@ pub fn run() {
|
||||||
commands::get_edition,
|
commands::get_edition,
|
||||||
commands::get_machine_id,
|
commands::get_machine_id,
|
||||||
commands::check_entitlement,
|
commands::check_entitlement,
|
||||||
|
commands::activate_machine,
|
||||||
|
commands::deactivate_machine,
|
||||||
|
commands::list_activated_machines,
|
||||||
|
commands::get_activation_status,
|
||||||
|
commands::start_oauth,
|
||||||
|
commands::handle_auth_callback,
|
||||||
|
commands::refresh_auth_token,
|
||||||
|
commands::get_account_info,
|
||||||
|
commands::check_subscription_status,
|
||||||
|
commands::logout,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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<String> {
|
||||||
|
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| v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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: https:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|
@ -34,6 +34,11 @@
|
||||||
"createUpdaterArtifacts": true
|
"createUpdaterArtifacts": true
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["simpl-resultat"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|
|
||||||
83
src/components/settings/AccountCard.tsx
Normal file
83
src/components/settings/AccountCard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<User size={18} />
|
||||||
|
{t("account.title")}
|
||||||
|
<span className="text-xs font-normal text-[var(--muted-foreground)]">
|
||||||
|
{t("account.optional")}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "authenticated" && state.account && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{state.account.picture && (
|
||||||
|
<img
|
||||||
|
src={state.account.picture}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{state.account.name || state.account.email}
|
||||||
|
</p>
|
||||||
|
{state.account.name && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{state.account.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
{t("account.signOut")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(state.status === "unauthenticated" || state.status === "idle") && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("account.description")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={login}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
|
||||||
|
>
|
||||||
|
<LogIn size={14} />
|
||||||
|
{t("account.signIn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useLicense } from "../../hooks/useLicense";
|
import { useLicense } from "../../hooks/useLicense";
|
||||||
|
import {
|
||||||
|
MachineInfo,
|
||||||
|
ActivationStatus,
|
||||||
|
activateMachine,
|
||||||
|
deactivateMachine,
|
||||||
|
listActivatedMachines,
|
||||||
|
getActivationStatus,
|
||||||
|
} from "../../services/licenseService";
|
||||||
|
|
||||||
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
||||||
|
|
||||||
|
|
@ -11,6 +19,75 @@ export default function LicenseCard() {
|
||||||
const { state, submitKey } = useLicense();
|
const { state, submitKey } = useLicense();
|
||||||
const [keyInput, setKeyInput] = useState("");
|
const [keyInput, setKeyInput] = useState("");
|
||||||
const [showInput, setShowInput] = useState(false);
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
const [showMachines, setShowMachines] = useState(false);
|
||||||
|
const [machines, setMachines] = useState<MachineInfo[]>([]);
|
||||||
|
const [activation, setActivation] = useState<ActivationStatus | null>(null);
|
||||||
|
const [machineLoading, setMachineLoading] = useState(false);
|
||||||
|
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
||||||
|
const [machineError, setMachineError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasLicense = 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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -123,6 +200,100 @@ export default function LicenseCard() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasLicense && (
|
||||||
|
<div className="border-t border-[var(--border)] pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Monitor size={16} />
|
||||||
|
{t("license.machines.title")}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activation && !activation.is_activated && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={machineLoading}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{machineLoading && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{t("license.activate")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activation?.is_activated && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
||||||
|
<CheckCircle size={12} />
|
||||||
|
{t("license.machines.activated")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMachines}
|
||||||
|
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{machineError && (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
||||||
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{machineError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMachines && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{machineLoading && machines.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!machineLoading && machines.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("license.machines.noMachines")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{machines.map((m) => {
|
||||||
|
const isThis = activation?.machine_id === m.machine_id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.machine_id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{m.machine_name || m.machine_id.slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
{isThis && (
|
||||||
|
<span className="ml-2 text-xs text-[var(--positive)]">
|
||||||
|
({t("license.machines.thisMachine")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{new Date(m.activated_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeactivate(m.machine_id)}
|
||||||
|
disabled={deactivatingId === m.machine_id}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deactivatingId === m.machine_id && (
|
||||||
|
<Loader2 size={10} className="animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("license.machines.deactivate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
src/hooks/useAuth.ts
Normal file
126
src/hooks/useAuth.ts
Normal file
|
|
@ -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<AccountInfo>("auth-callback-success", (event) => {
|
||||||
|
dispatch({ type: "LOAD_DONE", account: event.payload });
|
||||||
|
}).then((fn) => unlisten.push(fn));
|
||||||
|
|
||||||
|
listen<string>("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 };
|
||||||
|
}
|
||||||
|
|
@ -861,6 +861,27 @@
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"base": "Base",
|
"base": "Base",
|
||||||
"premium": "Premium"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -861,6 +861,27 @@
|
||||||
"free": "Gratuite",
|
"free": "Gratuite",
|
||||||
"base": "Base",
|
"base": "Base",
|
||||||
"premium": "Premium"
|
"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é"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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 LicenseCard from "../components/settings/LicenseCard";
|
||||||
|
import AccountCard from "../components/settings/AccountCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
|
@ -76,6 +77,9 @@ export default function SettingsPage() {
|
||||||
{/* License card */}
|
{/* License card */}
|
||||||
<LicenseCard />
|
<LicenseCard />
|
||||||
|
|
||||||
|
{/* Account card */}
|
||||||
|
<AccountCard />
|
||||||
|
|
||||||
{/* 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">
|
||||||
|
|
|
||||||
32
src/services/authService.ts
Normal file
32
src/services/authService.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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<string> {
|
||||||
|
return invoke<string>("start_oauth");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthCallback(code: string): Promise<AccountInfo> {
|
||||||
|
return invoke<AccountInfo>("handle_auth_callback", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuthToken(): Promise<AccountInfo> {
|
||||||
|
return invoke<AccountInfo>("refresh_auth_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountInfo(): Promise<AccountInfo | null> {
|
||||||
|
return invoke<AccountInfo | null>("get_account_info");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSubscriptionStatus(): Promise<AccountInfo | null> {
|
||||||
|
return invoke<AccountInfo | null>("check_subscription_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutAccount(): Promise<void> {
|
||||||
|
return invoke<void>("logout");
|
||||||
|
}
|
||||||
|
|
@ -34,3 +34,31 @@ export async function getMachineId(): Promise<string> {
|
||||||
export async function checkEntitlement(feature: string): Promise<boolean> {
|
export async function checkEntitlement(feature: string): Promise<boolean> {
|
||||||
return invoke<boolean>("check_entitlement", { feature });
|
return invoke<boolean>("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<void> {
|
||||||
|
return invoke<void>("activate_machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateMachine(machineId: string): Promise<void> {
|
||||||
|
return invoke<void>("deactivate_machine", { machineId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivatedMachines(): Promise<MachineInfo[]> {
|
||||||
|
return invoke<MachineInfo[]>("list_activated_machines");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivationStatus(): Promise<ActivationStatus> {
|
||||||
|
return invoke<ActivationStatus>("get_activation_status");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue