diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index ba9eb779..93d6100b 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -48,6 +48,12 @@ import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; import { useTokenList } from "@/hooks/useTokenList"; +import { + getFromStorage, + saveToStorage, + clearStorage, + StorageKeys, +} from "@/utils/localStorage"; function CreateInvoice() { const { data: walletClient } = useWalletClient(); @@ -65,6 +71,20 @@ function CreateInvoice() { const [userCountry, setUserCountry] = useState(""); const [clientCountry, setClientCountry] = useState(""); + // User form fields + const [userFname, setUserFname] = useState(""); + const [userLname, setUserLname] = useState(""); + const [userEmail, setUserEmail] = useState(""); + const [userCity, setUserCity] = useState(""); + const [userPostalcode, setUserPostalcode] = useState(""); + + // Client form fields + const [clientFname, setClientFname] = useState(""); + const [clientLname, setClientLname] = useState(""); + const [clientEmail, setClientEmail] = useState(""); + const [clientCity, setClientCity] = useState(""); + const [clientPostalcode, setClientPostalcode] = useState(""); + // Token selection state const [selectedToken, setSelectedToken] = useState(null); const [customTokenAddress, setCustomTokenAddress] = useState(""); @@ -89,8 +109,67 @@ function CreateInvoice() { ]); const [totalAmountDue, setTotalAmountDue] = useState(0); + const [isInitialLoad, setIsInitialLoad] = useState(true); + // Load data from localStorage on mount useEffect(() => { + const savedData = getFromStorage(StorageKeys.CREATE_INVOICE); + if (savedData) { + // Restore dates + if (savedData.dueDate) { + setDueDate(new Date(savedData.dueDate)); + } + if (savedData.issueDate) { + setIssueDate(new Date(savedData.issueDate)); + } + + // Restore user info + if (savedData.userFname) setUserFname(savedData.userFname); + if (savedData.userLname) setUserLname(savedData.userLname); + if (savedData.userEmail) setUserEmail(savedData.userEmail); + if (savedData.userCountry) setUserCountry(savedData.userCountry); + if (savedData.userCity) setUserCity(savedData.userCity); + if (savedData.userPostalcode) setUserPostalcode(savedData.userPostalcode); + + // Restore client info (only if no URL params) + const urlClientAddress = searchParams.get("clientAddress"); + if (!urlClientAddress) { + if (savedData.clientAddress) setClientAddress(savedData.clientAddress); + if (savedData.clientFname) setClientFname(savedData.clientFname); + if (savedData.clientLname) setClientLname(savedData.clientLname); + if (savedData.clientEmail) setClientEmail(savedData.clientEmail); + if (savedData.clientCountry) setClientCountry(savedData.clientCountry); + if (savedData.clientCity) setClientCity(savedData.clientCity); + if (savedData.clientPostalcode) + setClientPostalcode(savedData.clientPostalcode); + } + + // Restore token selection + if (savedData.selectedToken) { + setSelectedToken(savedData.selectedToken); + setUseCustomToken(false); + } + if (savedData.useCustomToken && savedData.customTokenAddress) { + setUseCustomToken(true); + setCustomTokenAddress(savedData.customTokenAddress); + if (savedData.verifiedToken) { + setVerifiedToken(savedData.verifiedToken); + setTokenVerificationState("success"); + } + } + + // Restore item data + if (savedData.itemData && savedData.itemData.length > 0) { + setItemData(savedData.itemData); + } + } + setIsInitialLoad(false); + }, []); + + // Handle URL params (should override localStorage) + useEffect(() => { + if (isInitialLoad) return; + console.log("account address : ", account.address); const urlClientAddress = searchParams.get("clientAddress"); const urlTokenAddress = searchParams.get("tokenAddress"); @@ -157,7 +236,7 @@ function CreateInvoice() { }; processUrlToken(); - }, [searchParams, walletClient, tokens, loadingTokens, account.address]); + }, [searchParams, walletClient, tokens, loadingTokens, account.address, isInitialLoad]); useEffect(() => { const total = itemData.reduce((sum, item) => { @@ -192,6 +271,64 @@ function CreateInvoice() { setShowWalletAlert(!isConnected); }, [isConnected]); + // Save form data to localStorage (debounced) + useEffect(() => { + if (isInitialLoad) return; + + const saveData = () => { + const dataToSave = { + dueDate: dueDate?.toISOString(), + issueDate: issueDate?.toISOString(), + clientAddress, + userFname, + userLname, + userEmail, + userCountry, + userCity, + userPostalcode, + clientFname, + clientLname, + clientEmail, + clientCountry, + clientCity, + clientPostalcode, + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + itemData, + }; + + saveToStorage(StorageKeys.CREATE_INVOICE, dataToSave); + }; + + // Debounce save operations + const timeoutId = setTimeout(saveData, 500); + return () => clearTimeout(timeoutId); + }, [ + dueDate, + issueDate, + clientAddress, + userFname, + userLname, + userEmail, + userCountry, + userCity, + userPostalcode, + clientFname, + clientLname, + clientEmail, + clientCountry, + clientCity, + clientPostalcode, + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + itemData, + isInitialLoad, + ]); + const handleItemData = (e, index) => { const { name, value } = e.target; @@ -417,6 +554,7 @@ const verifyToken = async (address, targetChainId = null) => { throw new Error("Missing chainId: wallet connected but chain not configured"); } + const chainId = account?.chainId; const contractAddress = import.meta.env[ `VITE_CONTRACT_ADDRESS_${account.chainId}` ]; @@ -436,6 +574,10 @@ const verifyToken = async (address, targetChainId = null) => { ); const receipt = await tx.wait(); + + // Clear localStorage on successful submission + clearStorage(StorageKeys.CREATE_INVOICE); + setTimeout(() => navigate("/dashboard/sent"), 4000); } catch (err) { console.error("Encryption or transaction failed:", err); @@ -450,20 +592,20 @@ const verifyToken = async (address, targetChainId = null) => { const formData = new FormData(e.target); const data = { - userAddress: formData.get("userAddress"), - userFname: formData.get("userFname"), - userLname: formData.get("userLname"), - userEmail: formData.get("userEmail"), - userCountry: userCountry || formData.get("userCountry") || "", - userCity: formData.get("userCity"), - userPostalcode: formData.get("userPostalcode"), - clientAddress: formData.get("clientAddress"), - clientFname: formData.get("clientFname"), - clientLname: formData.get("clientLname"), - clientEmail: formData.get("clientEmail"), - clientCountry: clientCountry || formData.get("clientCountry") || "", - clientCity: formData.get("clientCity"), - clientPostalcode: formData.get("clientPostalcode"), + userAddress: account?.address?.toString(), + userFname, + userLname, + userEmail, + userCountry, + userCity, + userPostalcode, + clientAddress, + clientFname, + clientLname, + clientEmail, + clientCountry, + clientCity, + clientPostalcode, itemData, }; await createInvoiceRequest(data); @@ -595,6 +737,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Your First Name" className="w-full mt-1 border-gray-300 text-black " name="userFname" + value={userFname} + onChange={(e) => setUserFname(e.target.value)} />
@@ -606,6 +750,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Your Last Name" className="w-full mt-1 border-gray-300 text-black" name="userLname" + value={userLname} + onChange={(e) => setUserLname(e.target.value)} />
@@ -620,6 +766,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Email" className="w-full mt-1 border-gray-300 text-black" name="userEmail" + value={userEmail} + onChange={(e) => setUserEmail(e.target.value)} />
@@ -653,6 +801,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="City" className="w-full mt-1 border-gray-300 text-black" name="userCity" + value={userCity} + onChange={(e) => setUserCity(e.target.value)} />
@@ -664,6 +814,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Postal Code" className="w-full mt-1 border-gray-300 text-black" name="userPostalcode" + value={userPostalcode} + onChange={(e) => setUserPostalcode(e.target.value)} />
@@ -693,6 +845,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Client First Name" className="w-full mt-1 border-gray-300 text-black" name="clientFname" + value={clientFname} + onChange={(e) => setClientFname(e.target.value)} />
@@ -704,6 +858,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Client Last Name" className="w-full mt-1 border-gray-300 text-black" name="clientLname" + value={clientLname} + onChange={(e) => setClientLname(e.target.value)} />
@@ -718,6 +874,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Email" className="w-full mt-1 border-gray-300 text-black" name="clientEmail" + value={clientEmail} + onChange={(e) => setClientEmail(e.target.value)} />
@@ -751,6 +909,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="City" className="w-full mt-1 border-gray-300 text-black" name="clientCity" + value={clientCity} + onChange={(e) => setClientCity(e.target.value)} />
@@ -762,6 +922,8 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Postal Code" className="w-full mt-1 border-gray-300 text-black" name="clientPostalcode" + value={clientPostalcode} + onChange={(e) => setClientPostalcode(e.target.value)} />
@@ -1025,6 +1187,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Enter Description" className="w-full border-gray-300 text-black" name="description" + value={itemData[index].description || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1039,6 +1202,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black" name="qty" + value={itemData[index].qty || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1051,6 +1215,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black" name="unitPrice" + value={itemData[index].unitPrice || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1066,6 +1231,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black" name="discount" + value={itemData[index].discount || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1078,6 +1244,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black" name="tax" + value={itemData[index].tax || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1139,6 +1306,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="Enter Description" className="w-full border-gray-300 text-black" name="description" + value={itemData[index].description || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1148,6 +1316,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black py-2" name="qty" + value={itemData[index].qty || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1157,6 +1326,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black py-2" name="unitPrice" + value={itemData[index].unitPrice || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1166,6 +1336,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black py-2" name="discount" + value={itemData[index].discount || ""} onChange={(e) => handleItemData(e, index)} /> @@ -1175,6 +1346,7 @@ const verifyToken = async (address, targetChainId = null) => { placeholder="0" className="w-full border-gray-300 text-black py-2" name="tax" + value={itemData[index].tax || ""} onChange={(e) => handleItemData(e, index)} /> diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index fb1c9ddf..7a3385b1 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -54,6 +54,12 @@ import WalletConnectionAlert from "../components/WalletConnectionAlert"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; +import { + getFromStorage, + saveToStorage, + clearStorage, + StorageKeys, +} from "@/utils/localStorage"; function CreateInvoicesBatch() { const { data: walletClient } = useWalletClient(); @@ -75,6 +81,7 @@ function CreateInvoicesBatch() { // UI state for collapsible invoices const [expandedInvoice, setExpandedInvoice] = useState(0); + const [isInitialLoad, setIsInitialLoad] = useState(true); // Batch invoice data const [invoiceRows, setInvoiceRows] = useState([ @@ -132,6 +139,45 @@ function CreateInvoicesBatch() { ); }, [invoiceRows.map((r) => JSON.stringify(r.itemData)).join(",")]); + // Load data from localStorage on mount + useEffect(() => { + const savedData = getFromStorage(StorageKeys.CREATE_INVOICES_BATCH); + if (savedData) { + // Restore dates + if (savedData.dueDate) { + setDueDate(new Date(savedData.dueDate)); + } + if (savedData.issueDate) { + setIssueDate(new Date(savedData.issueDate)); + } + + // Restore user info + if (savedData.userInfo) { + setUserInfo(savedData.userInfo); + } + + // Restore invoice rows + if (savedData.invoiceRows && savedData.invoiceRows.length > 0) { + setInvoiceRows(savedData.invoiceRows); + } + + // Restore token selection + if (savedData.selectedToken) { + setSelectedToken(savedData.selectedToken); + setUseCustomToken(false); + } + if (savedData.useCustomToken && savedData.customTokenAddress) { + setUseCustomToken(true); + setCustomTokenAddress(savedData.customTokenAddress); + if (savedData.verifiedToken) { + setVerifiedToken(savedData.verifiedToken); + setTokenVerificationState("success"); + } + } + } + setIsInitialLoad(false); + }, []); + // Initialize Lit useEffect(() => { const initLit = async () => { @@ -151,6 +197,41 @@ function CreateInvoicesBatch() { setShowWalletAlert(!isConnected); }, [isConnected]); + // Save form data to localStorage (debounced) + useEffect(() => { + if (isInitialLoad) return; + + const saveData = () => { + const dataToSave = { + dueDate: dueDate?.toISOString(), + issueDate: issueDate?.toISOString(), + userInfo, + invoiceRows, + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + }; + + saveToStorage(StorageKeys.CREATE_INVOICES_BATCH, dataToSave); + }; + + // Debounce save operations + const timeoutId = setTimeout(saveData, 500); + return () => clearTimeout(timeoutId); + }, [ + dueDate, + issueDate, + // Use JSON.stringify to detect deep changes in nested objects + JSON.stringify(userInfo), + JSON.stringify(invoiceRows), + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + isInitialLoad, + ]); + // Invoice management const addInvoiceRow = () => { const newIndex = invoiceRows.length; @@ -480,6 +561,7 @@ function CreateInvoicesBatch() { toast.info("Submitting batch transaction to blockchain..."); // Send to contract + const chainId = account?.chainId; const contractAddress = import.meta.env[ `VITE_CONTRACT_ADDRESS_${chainId}` ]; @@ -501,6 +583,9 @@ function CreateInvoicesBatch() { toast.info("Transaction submitted! Waiting for confirmation..."); const receipt = await tx.wait(); + // Clear localStorage on successful submission + clearStorage(StorageKeys.CREATE_INVOICES_BATCH); + toast.success( `Successfully created ${validInvoices.length} invoices in batch!` ); diff --git a/frontend/src/page/GenerateLink.jsx b/frontend/src/page/GenerateLink.jsx index 40b02e9b..c19266aa 100644 --- a/frontend/src/page/GenerateLink.jsx +++ b/frontend/src/page/GenerateLink.jsx @@ -24,6 +24,12 @@ import { Badge } from "@/components/ui/badge"; import { BrowserProvider, ethers } from "ethers"; import { ERC20_ABI } from "@/contractsABI/ERC20_ABI"; import { useTokenList } from "../hooks/useTokenList"; +import { + getFromStorage, + saveToStorage, + StorageKeys, + sanitizeDataForStorage, +} from "@/utils/sessionStorage"; const GenerateLink = () => { const { address, isConnected, chainId } = useAccount(); @@ -43,6 +49,8 @@ const GenerateLink = () => { const [tokenVerificationState, setTokenVerificationState] = useState("idle"); const [verifiedToken, setVerifiedToken] = useState(null); const [loading, setLoading] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [savedTokenState, setSavedTokenState] = useState(null); // Set default token when tokens are loaded useEffect(() => { @@ -64,6 +72,139 @@ const GenerateLink = () => { setShowWalletAlert(!isConnected); }, [isConnected]); + // Load persisted data from sessionStorage on mount + useEffect(() => { + const savedData = getFromStorage(StorageKeys.GENERATE_LINK); + + if (savedData) { + if (savedData.amount) setAmount(savedData.amount); + if (savedData.description) setDescription(savedData.description); + + // Defer token restoration until we can validate against current chain/token list + const tokenState = { + selectedToken: savedData.selectedToken || null, + useCustomToken: !!savedData.useCustomToken, + customTokenAddress: savedData.customTokenAddress || "", + verifiedToken: savedData.verifiedToken || null, + savedChainId: savedData.chainId ?? null, + }; + setSavedTokenState(tokenState); + } + + setIsInitialLoad(false); + }, []); + + // Validate and restore token state against current chain/token list + useEffect(() => { + if (!savedTokenState) return; + + const clearTokenState = () => { + setSelectedToken(null); + setUseCustomToken(false); + setCustomTokenAddress(""); + setVerifiedToken(null); + setTokenVerificationState("idle"); + }; + + const currentChainId = chainId || 1; + const chainMatches = + !savedTokenState.savedChainId || + savedTokenState.savedChainId === currentChainId; + + // If the saved data was for a different chain, do not restore token state + if (!chainMatches) { + clearTokenState(); + return; + } + + // 1. Try to restore a listed token, but only if it still exists for this chain + if (savedTokenState.selectedToken) { + const savedAddress = + (savedTokenState.selectedToken.address || + savedTokenState.selectedToken.contract_address || + "").toLowerCase(); + + const matchingToken = tokens.find((token) => { + const tokenAddress = + (token.contract_address || token.address || "").toLowerCase(); + return tokenAddress === savedAddress; + }); + + if (matchingToken) { + setUseCustomToken(false); + setSelectedToken({ + address: matchingToken.contract_address, + symbol: matchingToken.symbol, + name: matchingToken.name, + logo: matchingToken.image, + decimals: matchingToken.decimals ?? 18, + }); + return; + } + + // Saved selectedToken no longer valid on this chain + clearTokenState(); + return; + } + + // 2. Try to restore a custom token if the address is valid + if (savedTokenState.useCustomToken && savedTokenState.customTokenAddress) { + const addr = savedTokenState.customTokenAddress; + + if (ethers.isAddress(addr)) { + setUseCustomToken(true); + setCustomTokenAddress(addr); + + if (savedTokenState.verifiedToken) { + setVerifiedToken(savedTokenState.verifiedToken); + setTokenVerificationState("success"); + } else { + setVerifiedToken(null); + setTokenVerificationState("idle"); + } + return; + } + + // Invalid custom token address for this chain; clear state + clearTokenState(); + return; + } + + // No valid token information to restore + clearTokenState(); + }, [savedTokenState, tokens, chainId]); + + // Persist form state to sessionStorage (debounced) + useEffect(() => { + if (isInitialLoad) return; + + const saveData = () => { + const dataToSave = sanitizeDataForStorage({ + amount, + description, + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + chainId, + }); + + saveToStorage(StorageKeys.GENERATE_LINK, dataToSave); + }; + + const timeoutId = setTimeout(saveData, 500); + return () => clearTimeout(timeoutId); + }, [ + amount, + description, + selectedToken, + customTokenAddress, + useCustomToken, + verifiedToken, + chainId, + isInitialLoad, + ]); + const generateLink = () => { const tokenToUse = useCustomToken ? verifiedToken : selectedToken; diff --git a/frontend/src/utils/localStorage.js b/frontend/src/utils/localStorage.js new file mode 100644 index 00000000..6037a164 --- /dev/null +++ b/frontend/src/utils/localStorage.js @@ -0,0 +1,202 @@ +/** + * Utility functions for sessionStorage operations with TTL and PII protection + * Uses sessionStorage (session-only, cleared on tab close) with TTL expiry + */ + +const STORAGE_KEYS = { + CREATE_INVOICE: 'chainvoice_create_invoice', + CREATE_INVOICES_BATCH: 'chainvoice_create_invoices_batch', +}; + +// Default TTL: 24 hours (in milliseconds) +const DEFAULT_TTL = 24 * 60 * 60 * 1000; + +/** + * Get storage instance (sessionStorage for session-only storage) + */ +const getStorage = () => { + if (typeof window === 'undefined') return null; + return window.sessionStorage; +}; + +/** + * Safe sessionStorage getter with TTL expiry check + * Automatically deletes expired entries on read + * Session-only: cleared when browser tab closes + */ +export const getFromStorage = (key, defaultValue = null) => { + try { + const storage = getStorage(); + if (!storage) return defaultValue; + + const item = storage.getItem(key); + if (!item) return defaultValue; + + const parsed = JSON.parse(item); + + // Check if entry has TTL and if it's expired + if (parsed.expiresAt && parsed.expiresAt < Date.now()) { + // Entry expired, delete it + storage.removeItem(key); + return defaultValue; + } + + // Return the actual data (without TTL metadata) + return parsed.data !== undefined ? parsed.data : parsed; + } catch (error) { + console.error(`Error reading from sessionStorage (${key}):`, error); + return defaultValue; + } +}; + +/** + * Safe sessionStorage setter with TTL + * Wraps data with expiry timestamp + * Session-only: cleared when browser tab closes + */ +export const saveToStorage = (key, value, ttl = DEFAULT_TTL) => { + try { + const storage = getStorage(); + if (!storage) return; + + const expiresAt = Date.now() + ttl; + const dataToStore = { + data: value, + expiresAt, + createdAt: Date.now(), + }; + + storage.setItem(key, JSON.stringify(dataToStore)); + } catch (error) { + console.error(`Error saving to sessionStorage (${key}):`, error); + // Handle quota exceeded error + if (error.name === 'QuotaExceededError') { + console.warn('sessionStorage quota exceeded. Clearing old data...'); + clearStorage(key); + // Try to clean up expired entries + cleanupExpiredEntries(); + } + } +}; + +/** + * Clear specific key from sessionStorage + */ +export const clearStorage = (key) => { + try { + const storage = getStorage(); + if (!storage) return; + storage.removeItem(key); + } catch (error) { + console.error(`Error clearing sessionStorage (${key}):`, error); + } +}; + +/** + * Cleanup expired entries from sessionStorage + */ +const cleanupExpiredEntries = () => { + try { + const storage = getStorage(); + if (!storage) return; + + const keysToRemove = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key && key.startsWith('chainvoice_')) { + try { + const item = storage.getItem(key); + if (item) { + const parsed = JSON.parse(item); + if (parsed.expiresAt && parsed.expiresAt < Date.now()) { + keysToRemove.push(key); + } + } + } catch (e) { + // Invalid entry, remove it + keysToRemove.push(key); + } + } + } + + keysToRemove.forEach((key) => storage.removeItem(key)); + } catch (error) { + console.error('Error cleaning up expired entries:', error); + } +}; + +// Maximum number of invoice rows to persist (to prevent quota issues) +const MAX_PERSISTED_INVOICES = 10; + +/** + * Remove PII (emails, names, addresses, countries) from data object before saving + * Only keeps essential non-PII data needed for form restoration + */ +export const sanitizeDataForStorage = (data) => { + if (!data || typeof data !== 'object') return data; + + const sanitized = { ...data }; + + // Remove top-level PII fields + delete sanitized.userEmail; + delete sanitized.clientEmail; + delete sanitized.userFname; + delete sanitized.userLname; + delete sanitized.userCountry; + delete sanitized.userCity; + delete sanitized.userPostalcode; + delete sanitized.clientFname; + delete sanitized.clientLname; + delete sanitized.clientCountry; + delete sanitized.clientCity; + delete sanitized.clientPostalcode; + + // Handle nested userInfo object (for batch invoices) + if (sanitized.userInfo && typeof sanitized.userInfo === 'object') { + sanitized.userInfo = { ...sanitized.userInfo }; + // Remove all PII from userInfo - only keep non-PII fields if any + delete sanitized.userInfo.userEmail; + delete sanitized.userInfo.userFname; + delete sanitized.userInfo.userLname; + delete sanitized.userInfo.userCountry; + delete sanitized.userInfo.userCity; + delete sanitized.userInfo.userPostalcode; + } + + // Handle invoiceRows array (for batch invoices) + if (sanitized.invoiceRows && Array.isArray(sanitized.invoiceRows)) { + // Limit batch size to prevent quota issues + const limitedRows = sanitized.invoiceRows.slice(0, MAX_PERSISTED_INVOICES); + + sanitized.invoiceRows = limitedRows.map((row) => { + const sanitizedRow = { ...row }; + // Remove all client PII - only keep essential non-PII data + delete sanitizedRow.clientEmail; + delete sanitizedRow.clientFname; + delete sanitizedRow.clientLname; + delete sanitizedRow.clientCountry; + delete sanitizedRow.clientCity; + delete sanitizedRow.clientPostalcode; + // Keep: clientAddress (needed for functionality), itemData, totalAmountDue + return sanitizedRow; + }); + } + + return sanitized; +}; + +/** + * Get maximum number of invoices that can be persisted + */ +export const getMaxPersistedInvoices = () => MAX_PERSISTED_INVOICES; + +/** + * Storage keys + */ +export const StorageKeys = STORAGE_KEYS; + +// Cleanup expired entries on module load +if (typeof window !== 'undefined') { + cleanupExpiredEntries(); +} + diff --git a/frontend/src/utils/sessionStorage.js b/frontend/src/utils/sessionStorage.js new file mode 100644 index 00000000..00184728 --- /dev/null +++ b/frontend/src/utils/sessionStorage.js @@ -0,0 +1,279 @@ +/** + * Utility functions for sessionStorage operations with TTL and PII protection + * Uses sessionStorage (session-only, cleared on tab close) with TTL expiry + */ + +const STORAGE_KEYS = { + CREATE_INVOICE: 'chainvoice_create_invoice', + CREATE_INVOICES_BATCH: 'chainvoice_create_invoices_batch', + GENERATE_LINK: 'chainvoice_generate_link', +}; + +// Default TTL: 24 hours (in milliseconds) +const DEFAULT_TTL = 24 * 60 * 60 * 1000; + +/** + * Get storage instance (sessionStorage for session-only storage) + */ +const getStorage = () => { + if (typeof window === 'undefined') return null; + return window.sessionStorage; +}; + +/** + * Safe sessionStorage getter with TTL expiry check + * Automatically deletes expired entries on read + * Session-only: cleared when browser tab closes + */ +export const getFromStorage = (key, defaultValue = null) => { + try { + const storage = getStorage(); + if (!storage) return defaultValue; + + const item = storage.getItem(key); + if (!item) return defaultValue; + + // Parse JSON with error handling - remove corrupted entries + let parsed; + try { + parsed = JSON.parse(item); + } catch (parseError) { + // Corrupted JSON - remove it from storage + storage.removeItem(key); + console.error(`Corrupted JSON in sessionStorage (${key}), removed:`, parseError); + return defaultValue; + } + + // Treat undefined as missing data + if (parsed === undefined) { + storage.removeItem(key); + return defaultValue; + } + + // Check if entry has TTL and if it's expired + if (parsed.expiresAt && parsed.expiresAt < Date.now()) { + // Entry expired, delete it + storage.removeItem(key); + return defaultValue; + } + + // Return parsed.data if present, otherwise defaultValue + // Treat undefined data as missing to prevent false round-trip of undefined + if (parsed.data !== undefined) { + return parsed.data; + } + + // If parsed.data is undefined, treat as missing + return defaultValue; + } catch (error) { + console.error(`Error reading from sessionStorage (${key}):`, error); + return defaultValue; + } +}; + +/** + * Check if an error is a quota exceeded error + * Handles various browser implementations and error formats + */ +const isQuotaExceededError = (error) => { + if (!error) return false; + + // Check error name (standard) + if (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') { + return true; + } + + // Check error code (some browsers) + if (error.code === 22 || error.code === 1014) { + return true; + } + + // Check error message (fallback for edge cases) + const message = String(error.message || '').toLowerCase(); + if (message.includes('quota') || message.includes('storage') || message.includes('exceeded')) { + return true; + } + + return false; +}; + +/** + * Safe sessionStorage setter with TTL + * Wraps data with expiry timestamp + * Session-only: cleared when browser tab closes + * Includes retry logic for quota exceeded errors + */ +export const saveToStorage = (key, value, ttl = DEFAULT_TTL) => { + try { + const storage = getStorage(); + if (!storage) return; + + // Validate TTL: ensure it's a positive number + const validTtl = typeof ttl === 'number' && ttl > 0 && isFinite(ttl) ? ttl : DEFAULT_TTL; + + const expiresAt = Date.now() + validTtl; + const dataToStore = { + data: value, + expiresAt, + createdAt: Date.now(), + }; + + const serialized = JSON.stringify(dataToStore); + storage.setItem(key, serialized); + } catch (error) { + console.error(`Error saving to sessionStorage (${key}):`, error); + + // Handle quota exceeded error with retry + if (isQuotaExceededError(error)) { + console.warn('sessionStorage quota exceeded. Clearing old data and retrying...'); + + // Clean up: remove current key and expired entries + clearStorage(key); + cleanupExpiredEntries(); + + // Retry the write once after cleanup + try { + const storage = getStorage(); + if (!storage) return; + + const validTtl = typeof ttl === 'number' && ttl > 0 && isFinite(ttl) ? ttl : DEFAULT_TTL; + const expiresAt = Date.now() + validTtl; + const dataToStore = { + data: value, + expiresAt, + createdAt: Date.now(), + }; + + const serialized = JSON.stringify(dataToStore); + storage.setItem(key, serialized); + console.info('Successfully saved to sessionStorage after quota cleanup'); + } catch (retryError) { + // If retry also fails, log but don't throw (graceful degradation) + console.error(`Failed to save to sessionStorage after cleanup (${key}):`, retryError); + } + } + } +}; + +/** + * Clear specific key from sessionStorage + */ +export const clearStorage = (key) => { + try { + const storage = getStorage(); + if (!storage) return; + storage.removeItem(key); + } catch (error) { + console.error(`Error clearing sessionStorage (${key}):`, error); + } +}; + +/** + * Cleanup expired entries from sessionStorage + */ +const cleanupExpiredEntries = () => { + try { + const storage = getStorage(); + if (!storage) return; + + const keysToRemove = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key && key.startsWith('chainvoice_')) { + try { + const item = storage.getItem(key); + if (item) { + const parsed = JSON.parse(item); + if (parsed.expiresAt && parsed.expiresAt < Date.now()) { + keysToRemove.push(key); + } + } + } catch (e) { + // Invalid entry, remove it + keysToRemove.push(key); + } + } + } + + keysToRemove.forEach((key) => storage.removeItem(key)); + } catch (error) { + console.error('Error cleaning up expired entries:', error); + } +}; + +// Maximum number of invoice rows to persist (to prevent quota issues) +const MAX_PERSISTED_INVOICES = 10; + +/** + * Remove *some* PII (emails, names, countries, etc.) from data object before saving. + * Note: `clientAddress` is intentionally retained for form restoration. + */ +export const sanitizeDataForStorage = (data) => { + if (!data || typeof data !== 'object') return data; + + const sanitized = { ...data }; + + // Remove top-level PII fields + // Note: clientAddress is intentionally retained for form restoration + delete sanitized.userEmail; + delete sanitized.clientEmail; + delete sanitized.userFname; + delete sanitized.userLname; + delete sanitized.userCountry; + delete sanitized.userCity; + delete sanitized.userPostalcode; + delete sanitized.clientFname; + delete sanitized.clientLname; + delete sanitized.clientCountry; + delete sanitized.clientCity; + delete sanitized.clientPostalcode; + + // Handle nested userInfo object (for batch invoices) + if (sanitized.userInfo && typeof sanitized.userInfo === 'object') { + sanitized.userInfo = { ...sanitized.userInfo }; + // Remove all PII from userInfo - only keep non-PII fields if any + delete sanitized.userInfo.userEmail; + delete sanitized.userInfo.userFname; + delete sanitized.userInfo.userLname; + delete sanitized.userInfo.userCountry; + delete sanitized.userInfo.userCity; + delete sanitized.userInfo.userPostalcode; + } + + // Handle invoiceRows array (for batch invoices) + if (sanitized.invoiceRows && Array.isArray(sanitized.invoiceRows)) { + // Limit batch size to prevent quota issues + const limitedRows = sanitized.invoiceRows.slice(0, MAX_PERSISTED_INVOICES); + + sanitized.invoiceRows = limitedRows.map((row) => { + const sanitizedRow = { ...row }; + // Remove all client PII - only keep essential non-PII data + delete sanitizedRow.clientEmail; + delete sanitizedRow.clientFname; + delete sanitizedRow.clientLname; + delete sanitizedRow.clientCountry; + delete sanitizedRow.clientCity; + delete sanitizedRow.clientPostalcode; + // Keep: clientAddress (needed for functionality), itemData, totalAmountDue + return sanitizedRow; + }); + } + + return sanitized; +}; + +/** + * Get maximum number of invoices that can be persisted + */ +export const getMaxPersistedInvoices = () => MAX_PERSISTED_INVOICES; + +/** + * Storage keys + */ +export const StorageKeys = STORAGE_KEYS; + +// Cleanup expired entries on module load +if (typeof window !== 'undefined') { + cleanupExpiredEntries(); +} +