Refactor all vault mutate calls to use async method (#1404)

This commit is contained in:
Leendert de Borst
2025-12-11 23:28:47 +01:00
parent 644794944a
commit c2ebbe2ad3
6 changed files with 161 additions and 334 deletions

View File

@@ -1,5 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';
@@ -8,18 +7,13 @@ import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { useVaultSync } from './useVaultSync';
type VaultMutationOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
/**
* Hook to execute a vault mutation.
*
* Flow:
* 1. Execute the mutation on local database
* 2. Save encrypted vault locally and mark as dirty (increments mutation sequence)
* 3. Trigger sync which handles: upload, merge if needed, offline mode
* 3. Trigger sync in background which handles: upload, merge if needed, offline mode
*
* The mutation sequence is used for race detection:
* - Each mutation increments the sequence
@@ -27,14 +21,8 @@ type VaultMutationOptions = {
* - This ensures we never lose local changes during concurrent operations
*/
export function useVaultMutate(): {
executeVaultMutation: (operation: () => Promise<void>, options?: VaultMutationOptions) => Promise<void>;
executeVaultMutationAsync: (operation: () => Promise<void>) => Promise<void>;
isLoading: boolean;
syncStatus: string;
} {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [syncStatus, setSyncStatus] = useState('');
const dbContext = useDb();
const { syncVault } = useVaultSync();
@@ -131,67 +119,7 @@ export function useVaultMutate(): {
void triggerSync();
}, [saveLocally, triggerSync]);
/**
* Execute a vault mutation: save locally, then sync with server.
*
* The sync handles all scenarios:
* - Online + same revision + isDirty → upload
* - Online + server newer + isDirty → merge + upload
* - Offline → changes are safe locally, will sync when back online
*/
const executeVaultMutation = useCallback(async (
operation: () => Promise<void>,
options: VaultMutationOptions = {}
) => {
try {
setIsLoading(true);
setSyncStatus(t('common.savingChangesToVault'));
// 1. Execute mutation and save locally (always succeeds if no exceptions)
await saveLocally(operation);
// 2. Sync with server (handles upload, merge, offline - everything)
await syncVault({
/**
* Handle status updates during sync.
* @param message - Status message to display
*/
onStatus: (message) => setSyncStatus(message),
/**
* Handle successful sync completion.
*/
onSuccess: async () => {
// Refresh state from storage
await dbContext.refreshSyncState();
options.onSuccess?.();
},
/**
* Handle offline mode - local save succeeded.
*/
onOffline: () => {
// Local save succeeded, user can continue working offline
setSyncStatus(t('common.offlineModeSaved'));
options.onSuccess?.();
},
/**
* Handle sync errors.
* @param error - Error message from sync
*/
onError: (error) => options.onError?.(new Error(error))
});
} catch (error) {
console.error('Error during vault mutation:', error);
options.onError?.(error instanceof Error ? error : new Error(t('common.errors.unknownError')));
} finally {
setIsLoading(false);
setSyncStatus('');
}
}, [dbContext, saveLocally, syncVault, t]);
return {
executeVaultMutation,
executeVaultMutationAsync,
isLoading,
syncStatus,
};
}

View File

@@ -36,7 +36,7 @@ const Upgrade: React.FC = () => {
const [showVersionInfo, setShowVersionInfo] = useState(false);
const { setIsInitialLoading } = useLoading();
const webApi = useWebApi();
const { executeVaultMutation, isLoading: isVaultMutationLoading, syncStatus } = useVaultMutate();
const { executeVaultMutationAsync } = useVaultMutate();
const { syncVault } = useVaultSync();
const navigate = useNavigate();
@@ -127,7 +127,7 @@ const Upgrade: React.FC = () => {
}
// Use the useVaultMutate hook to handle the upgrade and vault upload
await executeVaultMutation(async () => {
await executeVaultMutationAsync(async () => {
// Begin transaction
sqliteClient.beginTransaction();
@@ -146,21 +146,9 @@ const Upgrade: React.FC = () => {
// Commit transaction
sqliteClient.commitTransaction();
}, {
/**
* Handle successful upgrade completion.
*/
onSuccess: () => {
void handleUpgradeSuccess();
},
/**
* Handle upgrade error.
*/
onError: (error: Error) => {
console.error('Upgrade failed:', error);
setError(error.message);
}
});
await handleUpgradeSuccess();
} catch (error) {
console.error('Upgrade failed:', error);
setError(error instanceof Error ? error.message : t('common.errors.unknownError'));
@@ -217,11 +205,11 @@ const Upgrade: React.FC = () => {
return (
<div>
{/* Full loading screen overlay */}
{(isLoading || isVaultMutationLoading) && (
{isLoading && (
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
<LoadingSpinner />
<div className="text-sm text-gray-500 mt-2">
{syncStatus || t('upgrade.upgrading')}
{t('upgrade.upgrading')}
</div>
</div>
)}
@@ -313,13 +301,13 @@ const Upgrade: React.FC = () => {
id="upgrade-button"
onClick={handleUpgrade}
>
{isLoading || isVaultMutationLoading ? (syncStatus || t('upgrade.upgrading')) : t('upgrade.upgrade')}
{isLoading ? t('upgrade.upgrading') : t('upgrade.upgrade')}
</Button>
<button
type="button"
onClick={handleLogout}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium py-2"
disabled={isLoading || isVaultMutationLoading}
disabled={isLoading}
>
{t('upgrade.logout')}
</button>

View File

@@ -91,7 +91,7 @@ const CredentialAddEdit: React.FC = () => {
Notes: Yup.string().nullable().optional()
}), [t]);
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const { executeVaultMutationAsync } = useVaultMutate();
const [mode, setMode] = useState<CredentialMode>('random');
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
@@ -381,18 +381,13 @@ const CredentialAddEdit: React.FC = () => {
return;
}
executeVaultMutation(async () => {
await executeVaultMutationAsync(async () => {
dbContext.sqliteClient!.deleteCredentialById(id);
}, {
/**
* Navigate to the credentials list page on success.
*/
onSuccess: () => {
void clearPersistedValues();
navigate('/credentials');
}
});
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate, clearPersistedValues]);
void clearPersistedValues();
navigate('/credentials');
}, [id, executeVaultMutationAsync, dbContext.sqliteClient, navigate, clearPersistedValues]);
/**
* Initialize the identity and password generators with settings from user's vault.
@@ -595,7 +590,7 @@ const CredentialAddEdit: React.FC = () => {
}
}
executeVaultMutation(async () => {
await executeVaultMutationAsync(async () => {
setLocalLoading(false);
if (isEditMode) {
@@ -609,23 +604,18 @@ const CredentialAddEdit: React.FC = () => {
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments, totpCodes);
data.Id = credentialId.toString();
}
}, {
/**
* 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.
navigate(`/credentials/${data.Id}`, { replace: true });
} else {
// If in edit mode, pop the current page from the history stack to end up on details page as well.
navigate(-1);
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]);
void clearPersistedValues();
// If in add mode, navigate to the credential details page.
if (!isEditMode) {
// Navigate to the credential details page.
navigate(`/credentials/${data.Id}`, { replace: true });
} else {
// If in edit mode, pop the current page from the history stack to end up on details page as well.
navigate(-1);
}
}, [isEditMode, dbContext.sqliteClient, executeVaultMutationAsync, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
@@ -664,12 +654,9 @@ const CredentialAddEdit: React.FC = () => {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<button type="submit" style={{ display: 'none' }} />
{(localLoading || isLoading) && (
{localLoading && (
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
<LoadingSpinner />
<div className="text-sm text-gray-500 mt-2">
{syncStatus}
</div>
</div>
)}

View File

@@ -84,7 +84,7 @@ const ItemsList: React.FC = () => {
const app = useApp();
const navigate = useNavigate();
const { syncVault } = useVaultSync();
const { executeVaultMutation } = useVaultMutate();
const { executeVaultMutationAsync } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const [items, setItems] = useState<Item[]>([]);
const [searchTerm, setSearchTerm] = useState('');
@@ -136,28 +136,14 @@ const ItemsList: React.FC = () => {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.createFolder(folderName, currentFolderId);
},
{
/**
* On success.
*/
onSuccess: () => {
// Refresh items to show the new folder
const results = dbContext.sqliteClient!.getAllItems();
setItems(results);
},
/**
* On error.
*/
onError: (error) => {
throw error;
}
}
);
}, [dbContext, currentFolderId, executeVaultMutation]);
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.createFolder(folderName, currentFolderId);
});
// Refresh items to show the new folder
const results = dbContext.sqliteClient!.getAllItems();
setItems(results);
}, [dbContext, currentFolderId, executeVaultMutationAsync]);
/**
* Retrieve latest vault and refresh the items list.

View File

@@ -30,7 +30,7 @@ const getDaysRemaining = (deletedAt: string, retentionDays: number = 30): number
const RecentlyDeleted: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const { executeVaultMutation } = useVaultMutate();
const { executeVaultMutationAsync } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const [items, setItems] = useState<Item[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
@@ -60,27 +60,12 @@ const RecentlyDeleted: React.FC = () => {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error restoring item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
});
loadItems();
}, [dbContext?.sqliteClient, executeVaultMutationAsync, loadItems]);
/**
* Permanently delete an item.
@@ -90,29 +75,14 @@ const RecentlyDeleted: React.FC = () => {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmDelete(false);
setSelectedItemId(null);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error permanently deleting item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
await executeVaultMutationAsync(async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(itemId);
});
loadItems();
setShowConfirmDelete(false);
setSelectedItemId(null);
}, [dbContext?.sqliteClient, executeVaultMutationAsync, loadItems]);
/**
* Empty all items from Recently Deleted (permanent delete all).
@@ -122,30 +92,15 @@ const RecentlyDeleted: React.FC = () => {
return;
}
await executeVaultMutation(
async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
}
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmEmptyAll(false);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error emptying recently deleted:', error);
}
await executeVaultMutationAsync(async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, items, loadItems]);
});
loadItems();
setShowConfirmEmptyAll(false);
}, [dbContext?.sqliteClient, executeVaultMutationAsync, items, loadItems]);
// Clear header buttons on mount
useEffect((): (() => void) => {

View File

@@ -32,7 +32,7 @@ const PasskeyCreate: React.FC = () => {
const { setIsInitialLoading } = useLoading();
const dbContext = useDb();
const webApi = useWebApi();
const { executeVaultMutation, isLoading: isMutating, syncStatus } = useVaultMutate();
const { executeVaultMutationAsync } = useVaultMutate();
const [request, setRequest] = useState<PendingPasskeyCreateRequest | null>(null);
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -156,7 +156,7 @@ const PasskeyCreate: React.FC = () => {
* Handle Enter key to submit
*/
const handleKeyDown = (e: KeyboardEvent) : void => {
if (e.key === 'Enter' && !localLoading && !isMutating) {
if (e.key === 'Enter' && !localLoading) {
if (showCreateForm) {
handleCreate();
}
@@ -166,7 +166,7 @@ const PasskeyCreate: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () : void => window.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showCreateForm, localLoading, isMutating]);
}, [showCreateForm, localLoading]);
/**
* Handle when user clicks "Create New Passkey" button
@@ -259,69 +259,15 @@ const PasskeyCreate: React.FC = () => {
const { credential, stored, prfEnabled, prfResults } = result;
// Use vault mutation to store both credential and passkey
await executeVaultMutation(
async () => {
if (selectedPasskeyToReplace) {
// Replace existing passkey: update the credential and passkey
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
if (existingPasskey) {
// Update the parent credential with new favicon and user-provided display name
await dbContext.sqliteClient!.updateCredentialById(
{
Id: existingPasskey.CredentialId,
ServiceName: displayName,
ServiceUrl: request.origin,
Username: request.publicKey.user.name,
Password: '',
Notes: '',
Logo: faviconLogo ?? undefined,
Alias: {
FirstName: '',
LastName: '',
NickName: '',
BirthDate: '0001-01-01 00:00:00',
Gender: '',
Email: ''
},
},
[],
[]
);
// Delete the old passkey
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
/**
* Create new passkey with same credential
* Convert userId from base64 string to byte array for database storage
*/
let userHandleBytes: Uint8Array | null = null;
if (stored.userId) {
try {
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
} catch {
// If conversion fails, store as null
userHandleBytes = null;
}
}
await dbContext.sqliteClient!.createPasskey({
Id: newPasskeyGuid,
CredentialId: existingPasskey.CredentialId,
RpId: stored.rpId,
UserHandle: userHandleBytes,
PublicKey: JSON.stringify(stored.publicKey),
PrivateKey: JSON.stringify(stored.privateKey),
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
AdditionalData: null
});
}
} else {
// Create new credential and passkey
const credentialId = await dbContext.sqliteClient!.createCredential(
await executeVaultMutationAsync(async () => {
if (selectedPasskeyToReplace) {
// Replace existing passkey: update the credential and passkey
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
if (existingPasskey) {
// Update the parent credential with new favicon and user-provided display name
await dbContext.sqliteClient!.updateCredentialById(
{
Id: '',
Id: existingPasskey.CredentialId,
ServiceName: displayName,
ServiceUrl: request.origin,
Username: request.publicKey.user.name,
@@ -332,17 +278,20 @@ const PasskeyCreate: React.FC = () => {
FirstName: '',
LastName: '',
NickName: '',
BirthDate: '0001-01-01 00:00:00', // TODO: once birthdate is made nullable in datamodel refactor, remove this.
BirthDate: '0001-01-01 00:00:00',
Gender: '',
Email: ''
}
},
},
[],
[]
);
// Delete the old passkey
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
/**
* Create the Passkey linked to the credential
* Note: We let the database generate a GUID for Id, which we'll convert to base64url for the RP
* Create new passkey with same credential
* Convert userId from base64 string to byte array for database storage
*/
let userHandleBytes: Uint8Array | null = null;
@@ -357,7 +306,7 @@ const PasskeyCreate: React.FC = () => {
await dbContext.sqliteClient!.createPasskey({
Id: newPasskeyGuid,
CredentialId: credentialId,
CredentialId: existingPasskey.CredentialId,
RpId: stored.rpId,
UserHandle: userHandleBytes,
PublicKey: JSON.stringify(stored.publicKey),
@@ -367,54 +316,91 @@ const PasskeyCreate: React.FC = () => {
AdditionalData: null
});
}
},
{
} else {
// Create new credential and passkey
const credentialId = await dbContext.sqliteClient!.createCredential(
{
Id: '',
ServiceName: displayName,
ServiceUrl: request.origin,
Username: request.publicKey.user.name,
Password: '',
Notes: '',
Logo: faviconLogo ?? undefined,
Alias: {
FirstName: '',
LastName: '',
NickName: '',
BirthDate: '0001-01-01 00:00:00', // TODO: once birthdate is made nullable in datamodel refactor, remove this.
Gender: '',
Email: ''
}
},
[]
);
/**
* Wait for vault mutation to have synced with server, then send passkey create success response
* with the GUID-based credential ID.
* Create the Passkey linked to the credential
* Note: We let the database generate a GUID for Id, which we'll convert to base64url for the RP
* Convert userId from base64 string to byte array for database storage
*/
onSuccess: async () => {
// Prepare PRF extension response if PRF was enabled
let prfExtensionResponse;
if (prfEnabled) {
prfExtensionResponse = {
prf: {
enabled: true,
results: prfResults ? {
first: PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.first)),
second: prfResults.second ? PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.second)) : undefined
} : undefined
}
};
let userHandleBytes: Uint8Array | null = null;
if (stored.userId) {
try {
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
} catch {
// If conversion fails, store as null
userHandleBytes = null;
}
// Use the GUID-based credential ID instead of the random one from the provider
const flattenedCredential: PasskeyCreateCredentialResponse = {
id: newPasskeyGuidBase64url,
rawId: newPasskeyGuidBase64url,
clientDataJSON: credential.response.clientDataJSON,
attestationObject: credential.response.attestationObject,
extensions: prfExtensionResponse
};
/*
* Send response back to background
* The background script will close the window (Safari-compatible)
*/
await sendMessage('PASSKEY_POPUP_RESPONSE', {
requestId: request.requestId,
credential: flattenedCredential
}, 'background');
},
/**
* onError
*/
onError: (err) => {
console.error('PasskeyCreate: Error storing passkey', err);
setError(t('common.errors.unknownError'));
}
await dbContext.sqliteClient!.createPasskey({
Id: newPasskeyGuid,
CredentialId: credentialId,
RpId: stored.rpId,
UserHandle: userHandleBytes,
PublicKey: JSON.stringify(stored.publicKey),
PrivateKey: JSON.stringify(stored.privateKey),
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
AdditionalData: null
});
}
);
});
// Prepare PRF extension response if PRF was enabled
let prfExtensionResponse;
if (prfEnabled) {
prfExtensionResponse = {
prf: {
enabled: true,
results: prfResults ? {
first: PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.first)),
second: prfResults.second ? PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.second)) : undefined
} : undefined
}
};
}
// Use the GUID-based credential ID instead of the random one from the provider
const flattenedCredential: PasskeyCreateCredentialResponse = {
id: newPasskeyGuidBase64url,
rawId: newPasskeyGuidBase64url,
clientDataJSON: credential.response.clientDataJSON,
attestationObject: credential.response.attestationObject,
extensions: prfExtensionResponse
};
/*
* Send response back to background
* The background script will close the window (Safari-compatible)
*/
await sendMessage('PASSKEY_POPUP_RESPONSE', {
requestId: request.requestId,
credential: flattenedCredential
}, 'background');
setLocalLoading(false);
} catch (error) {
console.error('PasskeyCreate: Error creating passkey', error);
setError(t('common.errors.unknownError'));
@@ -495,12 +481,9 @@ const PasskeyCreate: React.FC = () => {
/>
)}
{(localLoading || isMutating) && (
{localLoading && (
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
<LoadingSpinner />
<div className="text-sm text-gray-500 mt-2">
{syncStatus}
</div>
</div>
)}