From 9a7fbe7d2a487ce62c92f9a7efe4d3202dc72cbc Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 18 Jun 2025 11:28:33 +0200 Subject: [PATCH] Add form persist and restore logic (#935) --- .../src/entrypoints/background.ts | 6 +- .../background/VaultMessageHandler.ts | 21 +++ .../popup/pages/CredentialAddEdit.tsx | 120 +++++++++++++++++- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index f9ad3426a..b86548fb8 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -2,7 +2,7 @@ import { onMessage, sendMessage } from "webext-bridge/background"; import { setupContextMenus } from '@/entrypoints/background/ContextMenu'; import { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler'; -import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler'; +import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler'; import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants'; @@ -30,6 +30,10 @@ export default defineBackground({ onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data)); onMessage('TOGGLE_CONTEXT_MENU', ({ data }) => handleToggleContextMenu(data)); + onMessage('PERSIST_FORM_VALUES', ({ data }) => handlePersistFormValues(data)); + onMessage('GET_PERSISTED_FORM_VALUES', () => handleGetPersistedFormValues()); + onMessage('CLEAR_PERSISTED_FORM_VALUES', () => handleClearPersistedFormValues()); + // Setup context menus const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true; if (isContextMenuEnabled) { diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index 85b86e965..9802b8cc9 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -306,6 +306,27 @@ export async function handleUploadVault( } } +/** + * Handle persisting form values to storage. + */ +export async function handlePersistFormValues(data: any): Promise { + await storage.setItem('session:persistedFormValues', data); +} + +/** + * Handle retrieving persisted form values from storage. + */ +export async function handleGetPersistedFormValues(): Promise { + return await storage.getItem('session:persistedFormValues'); +} + +/** + * Handle clearing persisted form values from storage. + */ +export async function handleClearPersistedFormValues(): Promise { + await storage.removeItem('session:persistedFormValues'); +} + /** * Upload a new version of the vault to the server using the provided sqlite client. */ diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx index 1af637655..cdfd893db 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx @@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; +import { sendMessage } from 'webext-bridge/popup'; import * as Yup from 'yup'; import { FormInput } from '@/entrypoints/popup/components/FormInput'; @@ -24,6 +25,13 @@ import { useLoading } from '../context/LoadingContext'; type CredentialMode = 'random' | 'manual'; +// Persisted form data type used for JSON serialization. +type PersistedFormData = { + credentialId: string | null; + mode: CredentialMode; + formValues: Omit & { Logo?: string | null }; +} + /** * Validation schema for the credential form. */ @@ -67,7 +75,7 @@ const CredentialAddEdit: React.FC = () => { const [mode, setMode] = useState('random'); const { setHeaderButtons } = useHeaderButtons(); const { setIsInitialLoading } = useLoading(); - const [localLoading, setLocalLoading] = useState(false); + const [localLoading, setLocalLoading] = useState(true); const [showPassword, setShowPassword] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const webApi = useWebApi(); @@ -97,16 +105,104 @@ const CredentialAddEdit: React.FC = () => { // If we received an ID, we're in edit mode const isEditMode = id !== undefined && id.length > 0; + /** + * Persists the current form values to storage + * @returns Promise that resolves when the form values are persisted + */ + const persistFormValues = useCallback(async (): Promise => { + if (localLoading) { + // Do not persist values if the page is still loading. + return; + } + + const formValues = watch(); + const persistedData: PersistedFormData = { + credentialId: id || null, + mode, + formValues: { + ...formValues, + Logo: null // Don't persist the Logo field as it can't be user modified in the UI. + } + }; + await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background'); + }, [watch, id, mode, localLoading]); + + /** + * Loads persisted form values from storage. This is used to keep track of form changes + * and restore them when the page is reloaded. The browser extension popup will close + * automatically by clicking outside of the popup, but with this logic we can restore + * the form values when the page is reloaded so the user can continue their mutation operation. + * + * @returns Promise that resolves when the form values are loaded + */ + const loadPersistedValues = useCallback(async (): Promise => { + if (localLoading) { + // Do not load persisted values if the page is still loading. + return; + } + + const persistedData = await sendMessage('GET_PERSISTED_FORM_VALUES', null, 'background') as string | null; + + // Try to parse the persisted data as a JSON object. + try { + let persistedDataObject: PersistedFormData | null = null; + try { + if (persistedData) { + persistedDataObject = JSON.parse(persistedData) as PersistedFormData; + } + } catch (error) { + console.error('Error parsing persisted data:', error); + } + + // Check if the persisted credential ID matches the current page ID (equal value or both null) + const isCurrentPage = persistedDataObject?.credentialId == id; + if (persistedDataObject && isCurrentPage) { + // Only restore if the persisted credential ID matches current page + setMode(persistedDataObject.mode); + Object.entries(persistedDataObject.formValues).forEach(([key, value]) => { + setValue(key as keyof Credential, value as Credential[keyof Credential]); + }); + } else { + console.error('Persisted values do not match current page'); + } + } catch (error) { + console.error('Error loading persisted data:', error); + } + }, [setValue, id, setMode, localLoading]); + + /** + * Clears persisted form values from storage + * @returns Promise that resolves when the form values are cleared + */ + const clearPersistedValues = useCallback(async (): Promise => { + await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'); + }, []); + + // Clear persisted values when the page is unmounted. + useEffect(() => { + return (): void => { + void clearPersistedValues(); + }; + }, [clearPersistedValues]); + /** * Load an existing credential from the database in edit mode. */ useEffect(() => { - if (!dbContext?.sqliteClient || !id) { + if (!dbContext?.sqliteClient) { + return; + } + + if (!id) { // On create mode, focus the service name field after a short delay to ensure the component is mounted. setTimeout(() => { serviceNameRef.current?.focus(); }, 100); setIsInitialLoading(false); + setLocalLoading(false); + + // Load persisted form values if they exist. + void loadPersistedValues(); return; } @@ -123,8 +219,10 @@ const CredentialAddEdit: React.FC = () => { setMode('manual'); setIsInitialLoading(false); + setLocalLoading(false); - // On create mode, focus the service name field after a short delay to ensure the component is mounted + // Check for persisted values that might override the loaded values if they exist. + void loadPersistedValues(); } else { console.error('Credential not found'); navigate('/credentials'); @@ -133,7 +231,15 @@ const CredentialAddEdit: React.FC = () => { console.error('Error loading credential:', err); setIsInitialLoading(false); } - }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue]); + }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]); + + // Watch for form changes and persist them + useEffect(() => { + const subscription = watch(() => { + void persistFormValues(); + }); + return (): void => subscription.unsubscribe(); + }, [watch, persistFormValues]); /** * Handle the delete button click. @@ -150,10 +256,11 @@ const CredentialAddEdit: React.FC = () => { * Navigate to the credentials list page on success. */ onSuccess: () => { + void clearPersistedValues(); navigate('/credentials'); } }); - }, [id, executeVaultMutation, dbContext.sqliteClient, navigate]); + }, [id, executeVaultMutation, dbContext.sqliteClient, navigate, clearPersistedValues]); /** * Initialize the identity and password generators with settings from user's vault. @@ -314,6 +421,7 @@ const CredentialAddEdit: React.FC = () => { * Navigate to the credential details page on success. */ onSuccess: () => { + void clearPersistedValues(); // If in add mode, navigate to the credential details page. if (!isEditMode) { // Navigate to the credential details page. @@ -324,7 +432,7 @@ const CredentialAddEdit: React.FC = () => { } }, }); - }, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi]); + }, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues]); // Set header buttons on mount and clear on unmount useEffect((): (() => void) => {