diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0ab0278..92f2598 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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é diff --git a/CHANGELOG.md b/CHANGELOG.md index 5922e95..996c0a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 2571560..07ab926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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_.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_` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_` = `"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 | diff --git a/package-lock.json b/package-lock.json index 71a69e7..9f6b94f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 698ccae..f55a59a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__integration__/native-dialog-guard.test.ts b/src/__integration__/native-dialog-guard.test.ts new file mode 100644 index 0000000..8bfb405 --- /dev/null +++ b/src/__integration__/native-dialog-guard.test.ts @@ -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*|(?.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 {", + "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([]); + }); +}); diff --git a/src/components/adjustments/AdjustmentDetailPanel.tsx b/src/components/adjustments/AdjustmentDetailPanel.tsx index 64db9d9..1dde454 100644 --- a/src/components/adjustments/AdjustmentDetailPanel.tsx +++ b/src/components/adjustments/AdjustmentDetailPanel.tsx @@ -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); }; diff --git a/src/components/budget/TemplateActions.tsx b/src/components/budget/TemplateActions.tsx index 56cb060..1d95cb7 100644 --- a/src/components/budget/TemplateActions.tsx +++ b/src/components/budget/TemplateActions.tsx @@ -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); } }; diff --git a/src/components/categories/CategoryDetailPanel.tsx b/src/components/categories/CategoryDetailPanel.tsx index fed8c75..7901be9 100644 --- a/src/components/categories/CategoryDetailPanel.tsx +++ b/src/components/categories/CategoryDetailPanel.tsx @@ -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) { diff --git a/src/components/profile/ProfileFormModal.delete.test.ts b/src/components/profile/ProfileFormModal.delete.test.ts new file mode 100644 index 0000000..0a281ac --- /dev/null +++ b/src/components/profile/ProfileFormModal.delete.test.ts @@ -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>(), + 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 & { children?: ReactNode; onClick?: () => Promise }; + +/** The elements of the tree whose children hold an element of the given type. */ +function holding(node: ReactNode, type: unknown): ReactElement[] { + if (Array.isArray(node)) return node.flatMap((n) => holding(n, type)); + if (!isValidElement(node)) return []; + const el = node as ReactElement; + 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"); + }); +}); diff --git a/src/components/profile/ProfileFormModal.tsx b/src/components/profile/ProfileFormModal.tsx index 760d9fd..35744b0 100644 --- a/src/components/profile/ProfileFormModal.tsx +++ b/src/components/profile/ProfileFormModal.tsx @@ -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); }; diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index de2be27..3c10bad 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -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 && (