Compare commits

...

3 commits

Author SHA1 Message Date
le king fu
ff9656d015 test(dialogs): guard against aliases of confirmAction and other native forms
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m0s
Review of PR #465 (comment 24953), suggestion 1: an alias
(`const c = confirmAction; if (!c(...))`) called confirmAction out of reach
of the "awaited" rule, and window["confirm"](...), confirm?.(...) and
`const { confirm } = window` escaped the native-call rule. Any mention of
confirmAction other than the awaited call, its declaration, a plain named
import or `confirm: confirmAction` is now refused; the three native forms
are flagged. Samples added for each.

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
57ac056bf2 fix(dialogs): wait for the confirmation before deleting or removing the key
On desktop, tauri-plugin-dialog 2.6.0 replaces window.confirm with an async
function: confirm(...) returned a Promise, always truthy, so every guard
`if (!confirm(...)) return;` let the action run before the user answered.
Deleting a secondary profile removed its database even on "Cancel".

- confirmAction() (src/services/confirmService.ts) calls the plugin's ask()
  with a warning icon and buttons labelled in the app language
  (common.confirm / common.cancel); a failed call resolves false and is
  logged (fail closed). @tauri-apps/plugin-dialog 2.6.0 added, the crate's
  version.
- The six sites await it: profile, category, adjustment, budget template and
  Balance category deletions, and both confirmations of the license key
  removal (performRemoveLicense takes an async confirm).
- native-dialog-guard.test.ts fails on any native confirm/alert in src/ and
  on any confirmation not awaited (TypeScript does not flag `!promise`).
- Tests: confirmService, the deletion of a profile in ProfileFormModal, and
  the license removal tests with an async confirm (test: reproduces #421:
  six of them were red on the old code).

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00
le king fu
522232947c docs(release): report edition matrix changes to the site's comparison table
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m3s
The product page of la-compagnie-maximus copies the edition matrix into a
comparison table (la-compagnie-maximus#180). Step 0 of the release skill now
says to prepare the site PR before the tag and deploy it only after the
release is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:18:17 -04:00
18 changed files with 386 additions and 20 deletions

View file

@ -17,6 +17,7 @@ updated: 2026-10-11
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert. 0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag. **Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
1. Determiner la nouvelle version (argument utilisateur ou demander) 1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers : 2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`) - `src-tauri/Cargo.toml` (ligne `version = "..."`)
@ -91,4 +92,5 @@ updated: 2026-10-11
- 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`). - 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416. - 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416.
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415. - 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418). - 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).

View file

@ -23,6 +23,7 @@
- Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424). - Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424).
- Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426). - Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426).
- Aide et guide : le Budget décrit la saisie dans la colonne Annuel (plus de bouton de répartition) et la saisie limitée aux catégories du dernier niveau ; les Rapports ont cinq cartes et plus d'onglets ; l'ajout d'un mot-clé depuis une transaction passe par le clic droit (#431). - Aide et guide : le Budget décrit la saisie dans la colonne Annuel (plus de bouton de répartition) et la saisie limitée aux catégories du dernier niveau ; les Rapports ont cinq cartes et plus d'onglets ; l'ajout d'un mot-clé depuis une transaction passe par le clic droit (#431).
- La suppression d’un profil, d’une catégorie, d’un ajustement, d’un gabarit de budget ou d’une catégorie du Bilan, et le retrait de la clé de licence, attendent maintenant votre réponse à la confirmation. Jusqu’ici, l’action partait dès l’affichage de la confirmation et « Annuler » n’empêchait rien : un profil supprimé perdait ses données. Les boutons de la confirmation suivent désormais la langue de l’app (#421).
### Sécurité ### Sécurité

View file

@ -23,6 +23,7 @@
- Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424). - Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424).
- Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426). - Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426).
- Help and guide: Budget describes typing in the Annual column (no more split button) and that only last-level categories take an amount; Reports have five cards and no tabs; adding a keyword from a transaction goes through the right-click menu (#431). - Help and guide: Budget describes typing in the Annual column (no more split button) and that only last-level categories take an amount; Reports have five cards and no tabs; adding a keyword from a transaction goes through the right-click menu (#431).
- Deleting a profile, a category, an adjustment, a budget template or a Balance sheet category, and removing the license key, now wait for your answer to the confirmation. Until now the action ran as soon as the confirmation appeared, and “Cancel” stopped nothing: a deleted profile lost its data. The confirmation buttons now follow the app language (#421).
### Security ### Security

View file

@ -43,7 +43,7 @@ simpl-resultat/
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396) │ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript ») │ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage) │ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
@ -190,7 +190,7 @@ Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profil
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
## Services TypeScript (20) ## Services TypeScript (21)
| Service | Responsabilité | | Service | Responsabilité |
|---------|---------------| |---------|---------------|
@ -211,6 +211,7 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » | | `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres | | `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `confirmService.ts` | `confirmAction(message)` : seule voie de confirmation de l'app ([#421](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/421)), par `ask` de `@tauri-apps/plugin-dialog` (icône d'avertissement, boutons `common.confirm` / `common.cancel` dans la langue de l'app). Résout `true` sur la confirmation seulement ; un refus, la fermeture de la boîte ou un échec de l'appel (consigné par `logWarn`) résolvent `false`. Toujours attendu (`await`) : sur desktop, le plugin remplace `window.confirm` par une fonction asynchrone (une `Promise`, toujours vraie) et `window.alert` par une fonction qui ne bloque pas. `src/__integration__/native-dialog-guard.test.ts` refuse tout `confirm` / `alert` natif et toute confirmation non attendue. Seul service qui traduit, par l'instance i18next partagée |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine | | `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) | | `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) | | `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
@ -516,7 +517,7 @@ Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ord
|--------|------|-----------| |--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL | | `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — | | `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — | | `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier ; confirmations par `ask` (`confirmService.ts`, paquet JS `@tauri-apps/plugin-dialog`, #421) | Sur desktop, son script d'initialisation remplace `window.confirm` par une fonction asynchrone et `window.alert` par une fonction non bloquante : jamais de `confirm` / `alert` natif (garde `native-dialog-guard.test.ts`) ; paquet JS à la version du crate |
| `tauri-plugin-process` | Relaunch après mise à jour | — | | `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible | | `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux | | `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |

10
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2", "@tauri-apps/plugin-sql": "^2.3.2",
@ -1767,6 +1768,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View file

@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2", "@tauri-apps/plugin-sql": "^2.3.2",

View file

@ -0,0 +1,148 @@
/**
* Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421).
*
* On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and
* `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a
* Promise, always truthy, and the deletion ran before the user answered. TypeScript does not
* see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error.
*
* It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does,
* and fails on:
* 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self`
* (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by
* index (`window["confirm"]`) or destructured from one of those objects;
* 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`,
* `ui.confirm(`) that is not directly preceded by `await`;
* 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed
* import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited
* call: its declaration, a plain named import, and `confirm: confirmAction`, the value of
* an injected `confirm` whose calls rule 2 checks.
* `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is
* scanned like any other file. Keywords rather than a parse: the samples below check that each
* rule flags what it targets and lets the right form through.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "fs";
import { relative, resolve, sep } from "path";
const SRC = resolve(import.meta.dirname, "..");
const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
// `confirmAction(` or `<receiver>.confirm(`, the receiver captured to skip the native ones.
const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g;
const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/;
const AWAITED = /\bawait\s+$/;
const DECLARED = /\bfunction\s+$/;
// Mentions of `confirmAction` that rule 3 allows; any other one is an offence.
const CONFIRM_ACTION = /\bconfirmAction\b/g;
const ALLOWED_MENTION = [
/\bawait\s+confirmAction\s*\(/g,
/\bfunction\s+confirmAction\s*\(/g,
/^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g,
/\bconfirm\s*:\s*confirmAction\s*[,}]/g,
];
/** The source without its comments, line breaks kept so that line numbers stay right. */
function withoutComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offences of one line: a native call, or a confirmation not awaited. */
function lineOffences(line: string): string[] {
const found: string[] = [];
if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) {
found.push("native confirm/alert");
}
for (const match of line.matchAll(CONFIRM_CALL)) {
if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue;
const before = line.slice(0, match.index);
if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited");
}
const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0);
if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach");
return found;
}
/** The offending lines of one source, as `path:line: kind: text`. */
function offences(path: string, source: string): string[] {
const name = relative(SRC, path).split(sep).join("/");
return withoutComments(source)
.split("\n")
.flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`));
}
const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[])
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
.map((entry) => resolve(SRC, entry));
describe("native dialog guard (#421)", () => {
it("scans the app's sources", () => {
const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/"));
expect(names.length).toBeGreaterThan(200);
expect(names).toContain("components/profile/ProfileFormModal.tsx");
expect(names).toContain("hooks/useLicenseMachines.ts");
expect(names).toContain("services/confirmService.ts");
});
it.each([
"if (!confirm(t(\"profile.deleteConfirm\"))) return;",
"if (confirm(message)) {",
"if (!window.confirm(message)) return;",
"onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}",
"globalThis.confirm(message);",
"self . alert(message);",
"alert(message);",
"if (!window[\"confirm\"](message)) return;",
"globalThis['alert'](message);",
"if (!confirm?.(message)) return;",
"const { confirm } = window;",
"const { alert: show, confirm: ask } = globalThis;",
])("flags the native call in %s", (line) => {
expect(lineOffences(line)).toContain("native confirm/alert");
});
it.each([
"if (!confirmAction(t(\"categories.deleteConfirm\"))) return;",
"if (confirmAction(message)) {",
"if (!deps.confirm(deps.t(key))) return;",
"const ok = ui.confirm(message);",
])("flags the confirmation not awaited in %s", (line) => {
expect(lineOffences(line)).toContain("confirmation not awaited");
});
it.each([
"const c = confirmAction;",
"import { confirmAction as ask } from \"../services/confirmService\";",
"const handlers = { remove: confirmAction };",
"items.map(confirmAction);",
"if (await confirmAction(a) && check(confirmAction)) {",
])("flags confirmAction out of reach in %s", (line) => {
expect(lineOffences(line)).toContain("confirmAction out of reach");
});
it.each([
"if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;",
"if (await confirmAction(message)) {",
"if (!(await deps.confirm(deps.t(key)))) return;",
"const ok = await confirmAction(message);",
"onClick={() => void removeKey({ confirm: confirmAction, t })}",
"import { confirmAction } from \"../../services/confirmService\";",
"export async function confirmAction(message: string): Promise<boolean> {",
"if ((await confirmAction(a)) && (await confirmAction(b))) {",
"deps.confirm.mockClear();",
"const confirmed = await ask(message, { kind: \"warning\" });",
"t(\"common.confirm\")",
])("lets %s through", (line) => {
expect(lineOffences(line)).toEqual([]);
});
it("finds no native dialog and no confirmation left unawaited", () => {
const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8")));
expect(found).toEqual([]);
});
});

View file

@ -3,6 +3,7 @@ import { Pencil, RefreshCw } from "lucide-react";
import type { Adjustment, Category } from "../../shared/types"; import type { Adjustment, Category } from "../../shared/types";
import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService"; import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import { confirmAction } from "../../services/confirmService";
import AdjustmentForm from "./AdjustmentForm"; import AdjustmentForm from "./AdjustmentForm";
interface Props { interface Props {
@ -34,9 +35,9 @@ export default function AdjustmentDetailPanel({
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleDelete = () => { const handleDelete = async () => {
if (!selectedAdjustment) return; if (!selectedAdjustment) return;
if (!confirm(t("adjustments.deleteConfirm"))) return; if (!(await confirmAction(t("adjustments.deleteConfirm")))) return;
onDelete(selectedAdjustment.id); onDelete(selectedAdjustment.id);
}; };

View file

@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BookTemplate, Save, Trash2 } from "lucide-react"; import { BookTemplate, Save, Trash2 } from "lucide-react";
import type { BudgetTemplate } from "../../shared/types"; import type { BudgetTemplate } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
interface TemplateActionsProps { interface TemplateActionsProps {
templates: BudgetTemplate[]; templates: BudgetTemplate[];
@ -56,9 +57,9 @@ export default function TemplateActions({
setShowSave(false); setShowSave(false);
}; };
const handleDelete = (e: React.MouseEvent, templateId: number) => { const handleDelete = async (e: React.MouseEvent, templateId: number) => {
e.stopPropagation(); e.stopPropagation();
if (confirm(t("budget.deleteTemplateConfirm"))) { if (await confirmAction(t("budget.deleteTemplateConfirm"))) {
onDelete(templateId); onDelete(templateId);
} }
}; };

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types"; import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
import CategoryForm from "./CategoryForm"; import CategoryForm from "./CategoryForm";
import KeywordList from "./KeywordList"; import KeywordList from "./KeywordList";
@ -41,7 +42,7 @@ export default function CategoryDetailPanel({
const handleDelete = async () => { const handleDelete = async () => {
if (!selectedCategory) return; if (!selectedCategory) return;
if (!confirm(t("categories.deleteConfirm"))) return; if (!(await confirmAction(t("categories.deleteConfirm")))) return;
setDeleteError(null); setDeleteError(null);
const result = await onDelete(selectedCategory.id); const result = await onDelete(selectedCategory.id);
if (result.blocked) { if (result.blocked) {

View file

@ -0,0 +1,84 @@
// ProfileFormModal — deleting a secondary profile waits for the confirmation (#421).
//
// Before #421 the dialog plugin's async `window.confirm` returned a Promise, always truthy:
// the profile and its database were deleted before the user answered, and "Cancel" stopped
// nothing. The modal runs as a plain function under the shared hook harness (#409); the test
// clicks the delete button of the secondary profile through its props, the confirmation
// answered by a mocked `confirmAction`.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn<(message: string) => Promise<boolean>>(),
deleteProfile: vi.fn(async () => undefined),
}));
vi.mock("../../services/confirmService", () => ({ confirmAction: mocks.confirmAction }));
vi.mock("../../hooks/useEntitlement", () => ({
useEntitlement: () => ({ allowed: true, ready: true }),
}));
vi.mock("../../contexts/ProfileContext", () => ({
useProfile: () => ({
profiles: [
{ id: "default", name: "Principal", color: "#4A90A4", pin_hash: null, db_filename: "simpl_resultat.db", created_at: "" },
{ id: "second", name: "Second", color: "#C17767", pin_hash: null, db_filename: "profile_second.db", created_at: "" },
],
createProfile: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: mocks.deleteProfile,
setPin: vi.fn(),
}),
}));
import { Trash2 } from "lucide-react";
import { hookHarness } from "../../test/hookHarness";
import ProfileFormModal from "./ProfileFormModal";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode; onClick?: () => Promise<void> };
/** The elements of the tree whose children hold an element of the given type. */
function holding(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => holding(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
const children = ([] as ReactNode[]).concat(el.props.children ?? []);
const holds = children.some((c) => isValidElement(c) && c.type === type);
return [...(holds ? [el] : []), ...holding(el.props.children, type)];
}
/** Clicks the only delete button, the secondary profile's, and waits for its handler. */
async function clickDelete() {
const tree = harness.render(() => ProfileFormModal({ onClose: vi.fn() }));
const buttons = holding(tree, Trash2);
expect(buttons).toHaveLength(1);
await buttons[0].props.onClick?.();
}
beforeEach(() => {
harness.clear();
mocks.confirmAction.mockReset();
mocks.deleteProfile.mockClear();
});
describe("ProfileFormModal deletion (#421)", () => {
it("keeps the profile when the confirmation is refused", async () => {
mocks.confirmAction.mockResolvedValue(false);
await clickDelete();
expect(mocks.confirmAction).toHaveBeenCalledWith("t(profile.deleteConfirm)");
expect(mocks.deleteProfile).not.toHaveBeenCalled();
});
it("deletes the profile once confirmed", async () => {
mocks.confirmAction.mockResolvedValue(true);
await clickDelete();
expect(mocks.deleteProfile).toHaveBeenCalledWith("second");
});
});

View file

@ -5,6 +5,7 @@ import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement"; import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements"; import { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate"; import { isProfileCreationLocked } from "../../shared/profileGate";
import { confirmAction } from "../../services/confirmService";
import UpsellGate from "../shared/UpsellGate"; import UpsellGate from "../shared/UpsellGate";
const PRESET_COLORS = [ const PRESET_COLORS = [
@ -88,7 +89,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
const profile = profiles.find((p) => p.id === id); const profile = profiles.find((p) => p.id === id);
if (!profile || profile.db_filename === "simpl_resultat.db") return; if (!profile || profile.db_filename === "simpl_resultat.db") return;
if (!confirm(t("profile.deleteConfirm"))) return; if (!(await confirmAction(t("profile.deleteConfirm")))) return;
await deleteProfile(id); await deleteProfile(id);
}; };

View file

@ -7,6 +7,7 @@ import { useLicenseMachines } from "../../hooks/useLicenseMachines";
import { parseLicenseError, type LicenseError } from "../../services/licenseService"; import { parseLicenseError, type LicenseError } from "../../services/licenseService";
import { purchaseUrl } from "../../shared/purchaseUrl"; import { purchaseUrl } from "../../shared/purchaseUrl";
import { licenseExpiryDisplay } from "../../shared/licenseExpiry"; import { licenseExpiryDisplay } from "../../shared/licenseExpiry";
import { confirmAction } from "../../services/confirmService";
import { logError } from "../../services/logService"; import { logError } from "../../services/logService";
import LicenseKeyForm from "./LicenseKeyForm"; import LicenseKeyForm from "./LicenseKeyForm";
@ -158,7 +159,7 @@ function LicenseCardBody() {
{info !== null && ( {info !== null && (
<button <button
type="button" type="button"
onClick={() => void removeKey({ confirm: (message) => window.confirm(message), t })} onClick={() => void removeKey({ confirm: confirmAction, t })}
// Not while a key is being stored: the removal would delete it. // Not while a key is being stored: the removal would delete it.
disabled={!canRemoveKey || validating} disabled={!canRemoveKey || validating}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm text-[var(--negative)] disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm text-[var(--negative)] disabled:opacity-50"

View file

@ -637,7 +637,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
vi.mocked(invoke).mockResolvedValue(null); vi.mocked(invoke).mockResolvedValue(null);
log.logInfo.mockClear(); log.logInfo.mockClear();
context.revoked = true; context.revoked = true;
const confirm = vi.fn(() => true); const confirm = vi.fn(async () => true);
const view = renderHook(); const view = renderHook();
expect(view.canRemoveKey).toBe(true); expect(view.canRemoveKey).toBe(true);
@ -651,7 +651,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
it("takes a key that is not revoked as such", async () => { it("takes a key that is not revoked as such", async () => {
vi.mocked(invoke).mockResolvedValue(null); vi.mocked(invoke).mockResolvedValue(null);
const confirm = vi.fn(() => false); const confirm = vi.fn(async () => false);
const view = renderHook(); const view = renderHook();
await view.removeKey({ confirm, t: markedT }); await view.removeKey({ confirm, t: markedT });
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)"); expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)");
@ -736,7 +736,7 @@ describe("performRemoveLicense", () => {
error: err(code, extra), error: err(code, extra),
}); });
/** Answers the confirmations in order, then the calls of remove_license in order. */ /** Answers the confirmations in order (asynchronously), then the calls of remove_license in order. */
function removalHarness(answers: boolean[], results: LicenseResult<void>[]) { function removalHarness(answers: boolean[], results: LicenseResult<void>[]) {
let state = initialLicenseMachinesState; let state = initialLicenseMachinesState;
// Confirmations, calls and reloads in the order they happen. // Confirmations, calls and reloads in the order they happen.
@ -744,7 +744,9 @@ describe("performRemoveLicense", () => {
const confirmations = [...answers]; const confirmations = [...answers];
const outcomes = [...results]; const outcomes = [...results];
const deps: RemoveLicenseDeps = { const deps: RemoveLicenseDeps = {
confirm: vi.fn((message: string) => { // Asynchronous, as the dialog plugin's `ask` is: a Promise is always truthy, so a guard
// without `await` would go on after a refusal (#421).
confirm: vi.fn(async (message: string) => {
events.push(`confirm ${message}`); events.push(`confirm ${message}`);
return confirmations.shift() ?? false; return confirmations.shift() ?? false;
}), }),

View file

@ -350,8 +350,8 @@ export const REMOVE_LOG = {
/** The card's side of a removal: its confirmation dialog and its translations. */ /** The card's side of a removal: its confirmation dialog and its translations. */
export interface RemoveLicenseUi { export interface RemoveLicenseUi {
/** `window.confirm` in the card: true to go on. */ /** `confirmAction` in the card: resolves true to go on, false on a refusal (#421). */
confirm: (message: string) => boolean; confirm: (message: string) => Promise<boolean>;
/** `t` of react-i18next. */ /** `t` of react-i18next. */
t: (key: string, options?: Record<string, unknown>) => string; t: (key: string, options?: Record<string, unknown>) => string;
} }
@ -380,7 +380,7 @@ export async function performRemoveLicense(
deps: RemoveLicenseDeps, deps: RemoveLicenseDeps,
state: RemoveLicenseState, state: RemoveLicenseState,
): Promise<void> { ): Promise<void> {
if (!deps.confirm(deps.t(removeLicenseConfirmKey(state)))) return; if (!(await deps.confirm(deps.t(removeLicenseConfirmKey(state))))) return;
deps.dispatch({ type: "REMOVE_START" }); deps.dispatch({ type: "REMOVE_START" });
const releaseSlot = releasesSlot(state); const releaseSlot = releasesSlot(state);
let result = await deps.removeLicense(releaseSlot); let result = await deps.removeLicense(releaseSlot);
@ -388,7 +388,7 @@ export async function performRemoveLicense(
if (!result.ok && releaseSlot && RELEASE_RETRY_CODES.includes(result.error.code)) { if (!result.ok && releaseSlot && RELEASE_RETRY_CODES.includes(result.error.code)) {
deps.logWarn(REMOVE_LOG.releaseFailed, result.error.code); deps.logWarn(REMOVE_LOG.releaseFailed, result.error.code);
const reason = deps.t(result.error.i18nKey, result.error.params); const reason = deps.t(result.error.i18nKey, result.error.params);
if (!deps.confirm(deps.t("license.removeKey.confirmWithoutRelease", { reason }))) { if (!(await deps.confirm(deps.t("license.removeKey.confirmWithoutRelease", { reason })))) {
deps.dispatch({ type: "REMOVE_END" }); deps.dispatch({ type: "REMOVE_END" });
return; return;
} }

View file

@ -20,6 +20,7 @@ import type {
} from "../shared/types"; } from "../shared/types";
import { useBalanceAccounts } from "../hooks/useBalanceAccounts"; import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
import AccountForm from "../components/balance/AccountForm"; import AccountForm from "../components/balance/AccountForm";
import { confirmAction } from "../services/confirmService";
import type { CreateBalanceCategoryInput } from "../services/balance.service"; import type { CreateBalanceCategoryInput } from "../services/balance.service";
import { import {
renderCategoryLabelFromAccount, renderCategoryLabelFromAccount,
@ -108,7 +109,7 @@ export default function AccountsPage() {
* category or one with linked accounts, but we pre-check at the UI to * category or one with linked accounts, but we pre-check at the UI to
* surface a richer message that lists the linked-account names. * surface a richer message that lists the linked-account names.
*/ */
const handleDeleteCategory = (cat: BalanceCategory) => { const handleDeleteCategory = async (cat: BalanceCategory) => {
setCategoryDeleteError(null); setCategoryDeleteError(null);
if (cat.is_seed) return; if (cat.is_seed) return;
const linked = accountsByCategory.get(cat.id) ?? []; const linked = accountsByCategory.get(cat.id) ?? [];
@ -123,7 +124,7 @@ export default function AccountsPage() {
); );
return; return;
} }
if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return; if (!(await confirmAction(t("balance.category.actions.deleteConfirm")))) return;
removeCategory(cat.id); removeCategory(cat.id);
}; };

View file

@ -0,0 +1,79 @@
/**
* confirmAction (#421): the plugin's `ask`, mocked, answers; the shared i18next instance,
* initialised by `src/i18n/config.ts` as in the app, gives the button labels.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const ask = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
ask: (...args: unknown[]) => ask(...args),
}));
const logWarn = vi.fn();
vi.mock("./logService", () => ({
logWarn: (...args: unknown[]) => logWarn(...args),
}));
import i18n from "../i18n/config";
import { confirmAction } from "./confirmService";
beforeEach(async () => {
ask.mockReset();
logWarn.mockReset();
await i18n.changeLanguage("fr");
});
afterEach(async () => {
await i18n.changeLanguage("fr");
});
describe("confirmAction", () => {
it("resolves true when the user confirms", async () => {
ask.mockResolvedValue(true);
await expect(confirmAction("Supprimer ce profil ?")).resolves.toBe(true);
expect(logWarn).not.toHaveBeenCalled();
});
it("resolves false when the user cancels or closes the dialog", async () => {
ask.mockResolvedValue(false);
await expect(confirmAction("Supprimer ce profil ?")).resolves.toBe(false);
});
it("resolves false and logs a warning when the dialog fails", async () => {
ask.mockRejectedValue(new Error("dialog unavailable"));
await expect(confirmAction("Supprimer ce profil ?")).resolves.toBe(false);
expect(logWarn).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(
"Confirmation dialog failed; the action was not carried out",
"Error: dialog unavailable",
);
});
it("asks with the message, a warning icon and French buttons in French", async () => {
ask.mockResolvedValue(true);
await confirmAction("Supprimer ce profil ?");
expect(ask).toHaveBeenCalledWith("Supprimer ce profil ?", {
kind: "warning",
okLabel: "Confirmer",
cancelLabel: "Annuler",
});
});
it("labels the buttons in English once the app is in English", async () => {
ask.mockResolvedValue(true);
await i18n.changeLanguage("en");
await confirmAction("Delete this profile?");
expect(ask).toHaveBeenCalledWith("Delete this profile?", {
kind: "warning",
okLabel: "Confirm",
cancelLabel: "Cancel",
});
});
});

View file

@ -0,0 +1,31 @@
import { ask } from "@tauri-apps/plugin-dialog";
import i18n from "i18next";
import { logWarn } from "./logService";
/**
* Ask the user to confirm an action, in a native dialog whose buttons follow the app language
* (#421). Resolves `true` only on the confirm button; cancelling, closing the dialog or a failed
* call resolve `false`: a confirmation that cannot be shown never lets the action through.
*
* Every confirmation goes through here, awaited (`if (!(await confirmAction(...))) return;`).
* The native `confirm` is not an option: on desktop, tauri-plugin-dialog replaces
* `window.confirm` with an async function, so `confirm(...)` returns a Promise, always truthy,
* and the action ran before the user answered. It replaces `window.alert` too, which no longer
* blocks. `src/__integration__/native-dialog-guard.test.ts` keeps both out of `src/`.
*
* The only service that translates: the button labels are the same for every confirmation, so
* they come from the shared i18next instance (initialised by `src/i18n/config.ts`, following
* the language chosen in the app) rather than from each caller.
*/
export async function confirmAction(message: string): Promise<boolean> {
try {
return await ask(message, {
kind: "warning",
okLabel: i18n.t("common.confirm"),
cancelLabel: i18n.t("common.cancel"),
});
} catch (error) {
logWarn("Confirmation dialog failed; the action was not carried out", String(error));
return false;
}
}