Add Reset all data to Settings > Data and release 1.2.0
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 40s
CI / lint (pull_request) Successful in 2m50s
CI / e2e (pull_request) Successful in 3m3s
CI / ai-review (pull_request) Failing after 10m2s

Reset all data stages a wipe behind a typed RESET confirmation and applies it
on the next start, before the database is opened. It deletes the database,
assets, PDF archive, imported fonts and every backup, then the setup wizard
shows again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:29:52 +05:30
co-authored by Claude Sonnet 5.5
parent 93a4ece886
commit 8122129a19
15 changed files with 405 additions and 21 deletions
+73 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
@@ -11,6 +11,7 @@ import {
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTyped, setResetTyped] = useState("");
const [resetting, setResetting] = useState(false);
useReturnFocus(restorePath !== null || resetOpen);
const rerunSetup = async () => {
const ok = await confirm(
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
}
};
const closeReset = () => {
if (resetting) return;
setResetOpen(false);
setResetTyped("");
};
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
const resetAll = async () => {
setResetting(true);
try {
await api.resetAllData();
} catch (e) {
setResetting(false);
toast.error("Could not reset", String(e));
return;
}
clearVoicedLocalState();
try {
await api.restartApp();
} catch (e) {
setResetting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
}
};
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Reset all data</h2>
<p className="voiced-muted">
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
as if it were new.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
Reset all data…
</Button>
</div>
</Tile>
<Modal
open={resetOpen}
danger
modalHeading="Reset all data?"
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
onRequestClose={closeReset}
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
onSecondarySubmit={closeReset}
>
<div className="voiced-stack">
<p>
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
every backup (automatic, manual copies kept in Voiced&rsquo;s folder, and safety copies) will be permanently deleted. This
cannot be undone.
</p>
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
<TextInput
id="resetConfirm"
labelText={`Type ${RESET_WORD} to confirm`}
value={resetTyped}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
autoComplete="off"
/>
</div>
</Modal>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
+2
View File
@@ -122,6 +122,8 @@ export const api = {
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
resetAllData: () => invoke<void>("reset_all_data"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
describe("isResetConfirmed", () => {
it("accepts the word in any case with surrounding spaces", () => {
expect(isResetConfirmed("RESET")).toBe(true);
expect(isResetConfirmed(" reset ")).toBe(true);
});
it("rejects anything else", () => {
expect(isResetConfirmed("")).toBe(false);
expect(isResetConfirmed("rese")).toBe(false);
expect(isResetConfirmed("reset all")).toBe(false);
});
});
describe("clearVoicedLocalState", () => {
// The node test environment has no localStorage, so stub a minimal one.
beforeEach(() => {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
});
});
afterEach(() => vi.unstubAllGlobals());
it("removes only the app's own keys", () => {
localStorage.setItem("voiced.theme", "g100");
localStorage.setItem("voiced.settingsTab", "data");
localStorage.setItem("other.key", "keep");
clearVoicedLocalState();
expect(localStorage.getItem("voiced.theme")).toBeNull();
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
expect(localStorage.getItem("other.key")).toBe("keep");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The word typed to enable "Reset all data". */
export const RESET_WORD = "RESET";
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
export function clearVoicedLocalState(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key && key.startsWith("voiced.")) keys.push(key);
}
keys.forEach((key) => localStorage.removeItem(key));
} catch {
// Storage can be blocked; there is then nothing to clear.
}
}