diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 38bde25..2537db1 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -56,6 +56,7 @@ type PendingRestoreIntegrity = type PendingBackupVerification = | { action: 'export' } | { action: 'saveSettings' } + | { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings } | { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult } | { action: 'runRemoteBackup' } | { action: 'downloadRemote'; path: string } @@ -240,7 +241,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const backupPasswordPromptTitle = pendingBackupVerification?.action === 'export' ? t('txt_backup_export') - : pendingBackupVerification?.action === 'saveSettings' + : pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination' ? t('txt_backup_save_settings') : pendingBackupVerification?.action === 'runRemoteBackup' ? t('txt_backup_run_manual') @@ -501,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), }; + setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings }); + setBackupPasswordValue(''); + setConfirmDeleteDestinationOpen(false); + } + + async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) { setSavingSettings(true); setLocalError(''); try { - const saved = await props.onSaveSettings(nextSettings); + const saved = await props.onSaveSettings(masterPassword, payload); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); setSavedSettings(saved); @@ -865,6 +872,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { await executeExport(masterPassword); } else if (request.action === 'saveSettings') { await executeSaveSettings(masterPassword); + } else if (request.action === 'deleteDestination') { + await executeDeleteDestination(masterPassword, request.destinationId, request.settings); } else if (request.action === 'import') { await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); } else if (request.action === 'runRemoteBackup') { diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index a76dd45..a0b6738 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -234,24 +234,33 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct const normalizedName = String(name || '').trim() || t('txt_account_passkey'); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash); - const pending = await createAccountPasskeyCredential(options); + const pending = await createAccountPasskeyCredential(options, directUnlock); let keySet = null; let savedWithoutDirectUnlock = false; if (directUnlock) { if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); - try { - keySet = await buildAccountPasskeyPrfKeySet(pending, { - symEncKey: session.symEncKey, - symMacKey: session.symMacKey, - }); - } catch (error) { - if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error; + if (!pending.supportsPrf) { const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey(); if (!shouldSaveLoginOnly) { onNotify('warning', t('txt_account_passkey_not_saved')); return null; } savedWithoutDirectUnlock = true; + } else { + try { + keySet = await buildAccountPasskeyPrfKeySet(pending, { + symEncKey: session.symEncKey, + symMacKey: session.symMacKey, + }); + } catch (error) { + if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error; + const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey(); + if (!shouldSaveLoginOnly) { + onNotify('warning', t('txt_account_passkey_not_saved')); + return null; + } + savedWithoutDirectUnlock = true; + } } } const credential = await saveAccountPasskey(authedFetch, { diff --git a/webapp/src/lib/account-passkeys.ts b/webapp/src/lib/account-passkeys.ts index ccab8a1..a1efbdf 100644 --- a/webapp/src/lib/account-passkeys.ts +++ b/webapp/src/lib/account-passkeys.ts @@ -150,6 +150,17 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean { return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; } +function shouldRetryCreateWithoutPrf(error: unknown): boolean { + const name = error instanceof DOMException || error instanceof Error ? error.name : ''; + const message = error instanceof DOMException || error instanceof Error ? error.message : ''; + return ( + name === 'NotSupportedError' || + name === 'SyntaxError' || + name === 'TypeError' || + (name === 'UnknownError' && /transient/i.test(message)) + ); +} + async function getPublicKeyCredentialWithPrf( options: PublicKeyCredentialRequestOptions, salt: Uint8Array, @@ -265,17 +276,38 @@ export async function assertAccountPasskey( } export async function createAccountPasskeyCredential( - response: { options: unknown; token: string } + response: { options: unknown; token: string }, + requestPrf: boolean = false ): Promise { if (!window.PublicKeyCredential || !navigator.credentials) { throw new Error(t('txt_passkey_browser_not_supported')); } const nativeOptions = cloneCreationOptions(response.options); - (nativeOptions as any).extensions = { - ...((nativeOptions as any).extensions || {}), - prf: {}, + const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise => { + const credential = await navigator.credentials.create({ publicKey: options }); + if (!(credential instanceof PublicKeyCredential)) { + throw new Error(t('txt_no_passkey_created')); + } + return credential; }; - const credential = await navigator.credentials.create({ publicKey: nativeOptions }); + let credential: PublicKeyCredential; + if (requestPrf) { + const prfOptions: PublicKeyCredentialCreationOptions = { + ...nativeOptions, + extensions: { + ...((nativeOptions as any).extensions || {}), + prf: {}, + } as any, + }; + try { + credential = await createWithOptions(prfOptions); + } catch (error) { + if (!shouldRetryCreateWithoutPrf(error)) throw error; + credential = await createWithOptions(nativeOptions); + } + } else { + credential = await createWithOptions(nativeOptions); + } if (!(credential instanceof PublicKeyCredential)) { throw new Error(t('txt_no_passkey_created')); }