diff --git a/frontend/src/components/InvoiceBackupSettings.jsx b/frontend/src/components/InvoiceBackupSettings.jsx new file mode 100644 index 00000000..0d0c253e --- /dev/null +++ b/frontend/src/components/InvoiceBackupSettings.jsx @@ -0,0 +1,106 @@ +import { useRef } from "react"; +import { Download, Upload, Loader2, DatabaseBackup } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useInvoiceStorage } from "@/hooks/useInvoiceStorage"; +import toast from "react-hot-toast"; + +export default function InvoiceBackupSettings() { + const fileInputRef = useRef(null); + const { exportBackup, importBackup, isExporting, isImporting } = + useInvoiceStorage(); + + const handleExport = async () => { + try { + await exportBackup(); + toast.success("Invoice backup exported successfully."); + } catch (error) { + console.error("Failed to export invoice backup:", error); + toast.error("Failed to export invoice backup."); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event) => { + const file = event.target.files?.[0]; + + if (!file) return; + + try { + await importBackup(file); + toast.success("Invoice backup imported successfully."); + } catch (error) { + console.error("Failed to import invoice backup:", error); + toast.error(error?.message || "Failed to import invoice backup."); + } finally { + event.target.value = ""; + } + }; + + return ( +
+
+ +

+ Invoice Backup +

+
+ +

+ Back up your locally stored invoices or restore them on another + browser or device. +

+ +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx index 09f2da81..93934c62 100644 --- a/frontend/src/page/ReceivedInvoice.jsx +++ b/frontend/src/page/ReceivedInvoice.jsx @@ -93,6 +93,24 @@ function ReceivedInvoice() { const [paymentLoading, setPaymentLoading] = useState({}); const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); const [refreshTrigger, setRefreshTrigger] = useState(0); + + useEffect(() => { + const handleStorageUpdate = () => { + setRefreshTrigger((previous) => previous + 1); + }; + + window.addEventListener( + "chainvoice:invoice-storage-updated", + handleStorageUpdate + ); + + return () => { + window.removeEventListener( + "chainvoice:invoice-storage-updated", + handleStorageUpdate + ); + }; + }, []); const { keys, isRegistered, diff --git a/frontend/src/page/SentInvoice.jsx b/frontend/src/page/SentInvoice.jsx index cdd31134..7b67a8fd 100644 --- a/frontend/src/page/SentInvoice.jsx +++ b/frontend/src/page/SentInvoice.jsx @@ -85,6 +85,23 @@ function SentInvoice() { const [invoiceToCancel, setInvoiceToCancel] = useState(null); const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); const [refreshTrigger, setRefreshTrigger] = useState(0); + useEffect(() => { + const handleStorageUpdate = () => { + setRefreshTrigger((previous) => previous + 1); + }; + + window.addEventListener( + "chainvoice:invoice-storage-updated", + handleStorageUpdate + ); + + return () => { + window.removeEventListener( + "chainvoice:invoice-storage-updated", + handleStorageUpdate + ); + }; +}, []); const [resending, setResending] = useState({}); // Get tokens from the hook diff --git a/frontend/src/page/Settings.jsx b/frontend/src/page/Settings.jsx index 26d04064..cce5503f 100644 --- a/frontend/src/page/Settings.jsx +++ b/frontend/src/page/Settings.jsx @@ -1,3 +1,4 @@ +import InvoiceBackupSettings from "../components/InvoiceBackupSettings"; import ProductCatalogImport from "../components/ProductCatalogImport"; import UserProfileSettings from "../components/UserProfileSettings"; @@ -16,6 +17,13 @@ const SETTINGS_SECTIONS = [ "Manage your products for quick access when creating invoices.", Content: ProductCatalogImport, }, + { + id: "invoice-backup", + title: "Invoice Backup", + description: + "Export your locally stored invoices or restore them from a backup file.", + Content: InvoiceBackupSettings, + }, ]; function Settings() { diff --git a/frontend/src/services/invoiceStorage/invoiceBackup.js b/frontend/src/services/invoiceStorage/invoiceBackup.js index 24d32d8e..c5a8d377 100644 --- a/frontend/src/services/invoiceStorage/invoiceBackup.js +++ b/frontend/src/services/invoiceStorage/invoiceBackup.js @@ -1,49 +1,269 @@ import { + BACKUP_VERSION, exportDB, importDB, downloadJSON, readFileAsJSON, -} from '@aossie-org/idb-backup'; -import { DB_NAME, getAllInvoices } from './invoiceDB.js'; +} from "@aossie-org/idb-backup"; +import { + DB_NAME, + STORE_NAME, + getAllInvoices, +} from "./invoiceDB.js"; + +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isSerializedInteger(value) { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0; + } + + if (typeof value === "string") { + return /^(0|[1-9]\d*)$/.test(value); + } + + return ( + isObject(value) && + value.__type === "bigint" && + typeof value.value === "string" && + /^(0|[1-9]\d*)$/.test(value.value) + ); +} + +function validateInvoiceRecord(record, index) { + if (!isObject(record)) { + throw new Error(`Invoice record ${index + 1} is not a valid object.`); + } + + if (!isSerializedInteger(record.invoiceId)) { + throw new Error( + `Invoice record ${index + 1} has an invalid invoiceId.` + ); + } + + if (!isSerializedInteger(record.chainId)) { + throw new Error( + `Invoice record ${index + 1} has an invalid chainId.` + ); + } + + if (typeof record.from !== "string" || !ADDRESS_PATTERN.test(record.from)) { + throw new Error( + `Invoice record ${index + 1} has an invalid sender address.` + ); + } + + if (typeof record.to !== "string" || !ADDRESS_PATTERN.test(record.to)) { + throw new Error( + `Invoice record ${index + 1} has an invalid receiver address.` + ); + } + + if (!isObject(record.data)) { + throw new Error( + `Invoice record ${index + 1} is missing its invoice payload.` + ); +} + +const payload = record.data; + +if ( + typeof payload.amountDue !== "string" || + payload.amountDue.trim() === "" +) { + throw new Error( + `Invoice record ${index + 1} has an invalid invoice payload amount.` + ); +} + +if ( + !isObject(payload.paymentToken) || + typeof payload.paymentToken.address !== "string" +) { + throw new Error( + `Invoice record ${index + 1} has an invalid payment token.` + ); +} + +if (!isObject(payload.user) || typeof payload.user.address !== "string") { + throw new Error( + `Invoice record ${index + 1} has an invalid sender information.` + ); +} + +if ( + !isObject(payload.client) || + typeof payload.client.address !== "string" +) { + throw new Error( + `Invoice record ${index + 1} has an invalid client information.` + ); +} + +if (!Array.isArray(payload.items)) { + throw new Error( + `Invoice record ${index + 1} has an invalid invoice items list.` + ); +} + + if ( + record.compositeKey !== undefined && + typeof record.compositeKey !== "string" + ) { + throw new Error( + `Invoice record ${index + 1} has an invalid composite key.` + ); + } + + if ( + record.isPaid !== undefined && + typeof record.isPaid !== "boolean" + ) { + throw new Error( + `Invoice record ${index + 1} has an invalid payment status.` + ); + } + + if ( + record.isCancelled !== undefined && + typeof record.isCancelled !== "boolean" + ) { + throw new Error( + `Invoice record ${index + 1} has an invalid cancellation status.` + ); + } +} + +function validateBackup(backupData) { + if (!isObject(backupData)) { + throw new Error( + "Invalid backup file. The selected file is not a Chainvoice backup." + ); + } + + if (backupData.backupVersion !== BACKUP_VERSION) { + throw new Error( + `Unsupported backup version. Expected version ${BACKUP_VERSION}.` + ); + } + + if (backupData.databaseName !== DB_NAME) { + throw new Error( + "Invalid backup file. This backup does not belong to Chainvoice." + ); + } + + if ( + !Number.isInteger(backupData.databaseVersion) || + backupData.databaseVersion < 1 + ) { + throw new Error("Invalid backup database version."); + } + + if ( + typeof backupData.exportedAt !== "string" || + Number.isNaN(Date.parse(backupData.exportedAt)) + ) { + throw new Error("Invalid backup export timestamp."); + } + + if (!isObject(backupData.schema) || !isObject(backupData.stores)) { + throw new Error("Invalid backup structure."); + } + + const invoiceSchema = backupData.schema[STORE_NAME]; + const invoiceEntries = backupData.stores[STORE_NAME]; + + if (!isObject(invoiceSchema) || !Array.isArray(invoiceEntries)) { + throw new Error( + "Invalid Chainvoice backup. The invoices store is missing." + ); + } + + if ( + invoiceSchema.keyPath !== "compositeKey" || + invoiceSchema.autoIncrement !== false + ) { + throw new Error( + "Invalid Chainvoice backup. The invoice store schema is incompatible." + ); + } + + invoiceEntries.forEach((entry, index) => { + if (!isObject(entry) || !("value" in entry)) { + throw new Error(`Backup entry ${index + 1} is malformed.`); + } + + validateInvoiceRecord(entry.value, index); + }); + + return backupData; +} /** - * Export all local invoice data as a downloadable JSON backup file. - * Uses @aossie-org/idb-backup for type-safe IndexedDB export. - * @returns {Promise} the backup data object + * Export all locally cached invoices as a downloadable JSON backup. */ export async function exportInvoiceBackup() { - const backup = await exportDB({ dbName: DB_NAME }); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + // Force Chainvoice to initialize its versioned IndexedDB and indexes before + // idb-backup opens the database. Without this, exporting on a fresh browser + // can create/open an empty version-1 database. + await getAllInvoices(); + + const backup = await exportDB({ + dbName: DB_NAME, + storeNames: [STORE_NAME], + }); + + validateBackup(backup); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); downloadJSON(backup, `chainvoice-backup-${timestamp}.json`); + return backup; } /** - * Import invoice data from a backup JSON file. - * Uses 'merge' strategy to avoid overwriting existing data. + * Validate and merge invoice data from a Chainvoice backup. * - * @param {File} file - the uploaded backup file + * @param {File} file */ export async function importInvoiceBackup(file) { - const backupData = await readFileAsJSON(file); + if (!(file instanceof File)) { + throw new Error("Please select a valid JSON backup file."); + } - // Validate it's a valid backup - if (!backupData || backupData.databaseName !== DB_NAME) { - throw new Error( - 'Invalid backup file format. Please select a valid ChainVoice backup.' - ); + if ( + file.type && + file.type !== "application/json" && + !file.name.toLowerCase().endsWith(".json") + ) { + throw new Error("Please select a JSON backup file."); } + const backupData = await readFileAsJSON(file); + validateBackup(backupData); + + // Ensure Chainvoice's current DB schema and indexes exist before merging. + await getAllInvoices(); + await importDB({ dbName: DB_NAME, backupData, - strategy: 'merge', + strategy: "merge", }); + + // Allows any mounted invoice views to refresh immediately. + window.dispatchEvent(new Event("chainvoice:invoice-storage-updated")); + + return backupData; } /** - * Get the count of locally stored invoices (for UI display). - * @returns {Promise} + * Get the count of locally stored invoices. */ export async function getLocalInvoiceCount() { try { @@ -52,4 +272,4 @@ export async function getLocalInvoiceCount() { } catch { return 0; } -} +} \ No newline at end of file