Compare commits

...

2 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
17 changed files with 384 additions and 20 deletions

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).
- 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).
- 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é

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).
- 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).
- 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

View file

@ -43,7 +43,7 @@ simpl-resultat/
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── 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)
│ ├── 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
@ -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`).
## Services TypeScript (20)
## Services TypeScript (21)
| 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 » |
| `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) |
| `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 |
| `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) |
@ -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-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-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 |

10
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.2",
@ -1767,6 +1768,15 @@
"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": {
"version": "2.5.3",
"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/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@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 { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import { confirmAction } from "../../services/confirmService";
import AdjustmentForm from "./AdjustmentForm";
interface Props {
@ -34,9 +35,9 @@ export default function AdjustmentDetailPanel({
}: Props) {
const { t } = useTranslation();
const handleDelete = () => {
const handleDelete = async () => {
if (!selectedAdjustment) return;
if (!confirm(t("adjustments.deleteConfirm"))) return;
if (!(await confirmAction(t("adjustments.deleteConfirm")))) return;
onDelete(selectedAdjustment.id);
};

View file

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

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil } from "lucide-react";
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
import { confirmAction } from "../../services/confirmService";
import CategoryForm from "./CategoryForm";
import KeywordList from "./KeywordList";
@ -41,7 +42,7 @@ export default function CategoryDetailPanel({
const handleDelete = async () => {
if (!selectedCategory) return;
if (!confirm(t("categories.deleteConfirm"))) return;
if (!(await confirmAction(t("categories.deleteConfirm")))) return;
setDeleteError(null);
const result = await onDelete(selectedCategory.id);
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 { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate";
import { confirmAction } from "../../services/confirmService";
import UpsellGate from "../shared/UpsellGate";
const PRESET_COLORS = [
@ -88,7 +89,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const handleDelete = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
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);
};

View file

@ -7,6 +7,7 @@ import { useLicenseMachines } from "../../hooks/useLicenseMachines";
import { parseLicenseError, type LicenseError } from "../../services/licenseService";
import { purchaseUrl } from "../../shared/purchaseUrl";
import { licenseExpiryDisplay } from "../../shared/licenseExpiry";
import { confirmAction } from "../../services/confirmService";
import { logError } from "../../services/logService";
import LicenseKeyForm from "./LicenseKeyForm";
@ -158,7 +159,7 @@ function LicenseCardBody() {
{info !== null && (
<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.
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"

View file

@ -637,7 +637,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
vi.mocked(invoke).mockResolvedValue(null);
log.logInfo.mockClear();
context.revoked = true;
const confirm = vi.fn(() => true);
const confirm = vi.fn(async () => true);
const view = renderHook();
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 () => {
vi.mocked(invoke).mockResolvedValue(null);
const confirm = vi.fn(() => false);
const confirm = vi.fn(async () => false);
const view = renderHook();
await view.removeKey({ confirm, t: markedT });
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)");
@ -736,7 +736,7 @@ describe("performRemoveLicense", () => {
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>[]) {
let state = initialLicenseMachinesState;
// Confirmations, calls and reloads in the order they happen.
@ -744,7 +744,9 @@ describe("performRemoveLicense", () => {
const confirmations = [...answers];
const outcomes = [...results];
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}`);
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. */
export interface RemoveLicenseUi {
/** `window.confirm` in the card: true to go on. */
confirm: (message: string) => boolean;
/** `confirmAction` in the card: resolves true to go on, false on a refusal (#421). */
confirm: (message: string) => Promise<boolean>;
/** `t` of react-i18next. */
t: (key: string, options?: Record<string, unknown>) => string;
}
@ -380,7 +380,7 @@ export async function performRemoveLicense(
deps: RemoveLicenseDeps,
state: RemoveLicenseState,
): Promise<void> {
if (!deps.confirm(deps.t(removeLicenseConfirmKey(state)))) return;
if (!(await deps.confirm(deps.t(removeLicenseConfirmKey(state))))) return;
deps.dispatch({ type: "REMOVE_START" });
const releaseSlot = releasesSlot(state);
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)) {
deps.logWarn(REMOVE_LOG.releaseFailed, result.error.code);
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" });
return;
}

View file

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