import { useMemo } from 'preact/hooks'; import { changeMasterPassword, deleteAllAuthorizedDevices, deleteAuthorizedDevice, deriveLoginHash, deleteAccountPasskey as deleteAccountPasskeyApi, enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi, getCurrentDeviceIdentifier, getApiKey, getAccountPasskeyAttestationOptions, getAccountPasskeyUpdateAssertionOptions, getTotpRecoveryCode, listAccountPasskeys, rotateApiKey, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, saveAccountPasskey, setTotp, trustAuthorizedDevicePermanently, updateAuthorizedDeviceName, updateProfile, } from '@/lib/api/auth'; import { AccountPasskeyPrfUnavailableError, assertAccountPasskey, buildAccountPasskeyPrfKeySet, buildAccountPasskeyPrfKeySetFromPrfKey, createAccountPasskeyCredential, } from '@/lib/account-passkeys'; import { t } from '@/lib/i18n'; import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AuthedFetch } from '@/lib/api/shared'; import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types'; type Notify = (type: 'success' | 'error' | 'warning', text: string) => void; interface UseAccountSecurityActionsOptions { authedFetch: AuthedFetch; profile: Profile | null; session: SessionState | null; defaultKdfIterations: number; disableTotpPassword: string; clearDisableTotpDialog: () => void; onLogoutNow: () => void; onNotify: Notify; onProfileUpdated: (profile: Profile) => void; onSetConfirm: (next: AppConfirmState | null) => void; refetchTotpStatus: () => Promise; refetchAuthorizedDevices: () => Promise; } export default function useAccountSecurityActions(options: UseAccountSecurityActionsOptions) { const { authedFetch, profile, session, defaultKdfIterations, disableTotpPassword, clearDisableTotpDialog, onLogoutNow, onNotify, onProfileUpdated, onSetConfirm, refetchTotpStatus, refetchAuthorizedDevices, } = options; return useMemo( () => { function confirmSaveLoginOnlyAccountPasskey(): Promise { return new Promise((resolve) => { let settled = false; const finish = (shouldSave: boolean) => { if (settled) return; settled = true; onSetConfirm(null); resolve(shouldSave); }; onSetConfirm({ title: t('txt_account_passkey_direct_unlock_unavailable_title'), message: t('txt_account_passkey_direct_unlock_unavailable_message'), confirmText: t('txt_save_login_only_passkey'), cancelText: t('txt_do_not_save'), showIcon: true, onConfirm: () => finish(true), onCancel: () => finish(false), }); }); } return ({ async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) { if (!profile) return; if (!currentPassword || !nextPassword) { onNotify('error', t('txt_current_new_password_is_required')); return; } if (nextPassword.length < 12) { onNotify('error', t('txt_new_password_must_be_at_least_12_chars')); return; } if (nextPassword !== nextPassword2) { onNotify('error', t('txt_new_passwords_do_not_match')); return; } onSetConfirm({ title: t('txt_change_master_password'), message: t('txt_change_password_confirm_and_sign_out_all_devices'), danger: true, onConfirm: () => { onSetConfirm(null); void (async () => { try { await changeMasterPassword(authedFetch, { email: profile.email, currentPassword, newPassword: nextPassword, currentIterations: defaultKdfIterations, profileKey: profile.key, }); onNotify('success', t('txt_master_password_changed_signing_out_everywhere')); onLogoutNow(); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed')); } })(); }, }); }, async savePasswordHint(masterPasswordHint: string) { if (!profile) return; const normalized = String(masterPasswordHint || '').trim(); if (normalized.length > 120) { onNotify('error', t('txt_password_hint_too_long')); return; } try { const nextProfile = await updateProfile(authedFetch, { masterPasswordHint: normalized }); onProfileUpdated(nextProfile); onNotify('success', t('txt_profile_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_save_profile_failed')); } }, async enableTotp(secret: string, token: string) { if (!secret.trim() || !token.trim()) { const error = new Error(t('txt_secret_and_code_are_required')); onNotify('error', error.message); throw error; } try { await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); onNotify('success', t('txt_totp_enabled')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); throw error; } }, async disableTotp() { if (!profile) return; if (!disableTotpPassword) { onNotify('error', t('txt_please_input_master_password')); return; } try { const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); clearDisableTotpDialog(); await refetchTotpStatus(); onNotify('success', t('txt_totp_disabled')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed')); } }, async getRecoveryCode(masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalized = String(masterPassword || ''); if (!normalized) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); const code = await getTotpRecoveryCode(authedFetch, derived.hash); if (!code) throw new Error(t('txt_recovery_code_is_empty')); return code; }, async getApiKey(masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalized = String(masterPassword || ''); if (!normalized) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); const key = await getApiKey(authedFetch, derived.hash); if (!key) throw new Error(t('txt_api_key_is_empty')); return key; }, async rotateApiKey(masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalized = String(masterPassword || ''); if (!normalized) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); const key = await rotateApiKey(authedFetch, derived.hash); if (!key) throw new Error(t('txt_api_key_is_empty')); return key; }, async listAccountPasskeys(): Promise { return listAccountPasskeys(authedFetch); }, async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalizedPassword = String(masterPassword || ''); if (!normalizedPassword) throw new Error(t('txt_master_password_is_required')); 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); 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; const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey(); if (!shouldSaveLoginOnly) { onNotify('warning', t('txt_account_passkey_not_saved')); return null; } savedWithoutDirectUnlock = true; } } const credential = await saveAccountPasskey(authedFetch, { name: normalizedName, token: pending.token, deviceResponse: pending.request, supportsPrf: keySet ? true : savedWithoutDirectUnlock ? false : pending.supportsPrf, keySet, }); onNotify('success', savedWithoutDirectUnlock ? t('txt_account_passkey_saved_login_only') : t('txt_account_passkey_saved')); return credential; }, async enableAccountPasskeyDirectUnlock(id: string, masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!String(id || '').trim()) throw new Error(t('txt_account_passkey_not_found')); const normalizedPassword = String(masterPassword || ''); if (!normalizedPassword) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); const options = await getAccountPasskeyUpdateAssertionOptions(authedFetch, derived.hash, id); const assertion = await assertAccountPasskey(options); if (!assertion.prfKey) throw new Error(t('txt_account_passkey_prf_not_available')); const keySet = await buildAccountPasskeyPrfKeySetFromPrfKey(assertion.prfKey, { symEncKey: session.symEncKey, symMacKey: session.symMacKey, }); await enableAccountPasskeyDirectUnlockApi(authedFetch, { token: assertion.token, deviceResponse: assertion.deviceResponse, keySet, }); onNotify('success', t('txt_account_passkey_direct_unlock_enabled')); }, async deleteAccountPasskey(id: string, masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalizedPassword = String(masterPassword || ''); if (!normalizedPassword) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); await deleteAccountPasskeyApi(authedFetch, id, derived.hash); onNotify('success', t('txt_account_passkey_deleted')); }, async refreshAuthorizedDevices() { await refetchAuthorizedDevices(); }, async renameAuthorizedDevice(device: AuthorizedDevice, name: string) { const normalized = String(name || '').trim(); if (!normalized) { onNotify('error', t('txt_device_note_required')); return; } try { await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized); await refetchAuthorizedDevices(); onNotify('success', t('txt_device_note_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed')); } }, openRevokeDeviceTrust(device: AuthorizedDevice) { onSetConfirm({ title: t('txt_revoke_device_authorization'), message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), danger: true, onConfirm: () => { onSetConfirm(null); void (async () => { try { await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); await refetchAuthorizedDevices(); onNotify('success', t('txt_device_authorization_revoked')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed')); } })(); }, }); }, openTrustDevicePermanently(device: AuthorizedDevice) { onSetConfirm({ title: t('txt_trust_device_permanently'), message: t('txt_trust_device_permanently_for_name', { name: device.name }), danger: false, onConfirm: () => { onSetConfirm(null); void (async () => { try { await trustAuthorizedDevicePermanently(authedFetch, device.identifier); await refetchAuthorizedDevices(); onNotify('success', t('txt_device_trusted_permanently')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_trust_device_permanently_failed')); } })(); }, }); }, openRemoveDevice(device: AuthorizedDevice) { onSetConfirm({ title: t('txt_remove_device'), message: t('txt_remove_device_and_sign_out_name', { name: device.name }), danger: true, onConfirm: () => { onSetConfirm(null); void (async () => { try { await deleteAuthorizedDevice(authedFetch, device.identifier); if (device.identifier === getCurrentDeviceIdentifier()) { onNotify('success', t('txt_device_removed')); onLogoutNow(); return; } await refetchAuthorizedDevices(); onNotify('success', t('txt_device_removed')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed')); } })(); }, }); }, openRevokeAllDeviceTrust() { onSetConfirm({ title: t('txt_revoke_all_trusted_devices'), message: t('txt_revoke_30_day_totp_trust_from_all_devices'), danger: true, onConfirm: () => { onSetConfirm(null); void (async () => { try { await revokeAllAuthorizedDeviceTrust(authedFetch); await refetchAuthorizedDevices(); onNotify('success', t('txt_all_device_authorizations_revoked')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed')); } })(); }, }); }, openRemoveAllDevices() { onSetConfirm({ title: t('txt_remove_all_devices'), message: t('txt_remove_all_devices_and_sign_out_all_sessions'), danger: true, onConfirm: () => { onSetConfirm(null); void (async () => { try { await deleteAllAuthorizedDevices(authedFetch); onNotify('success', t('txt_all_devices_removed')); onLogoutNow(); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed')); } })(); }, }); }, }); }, [ authedFetch, clearDisableTotpDialog, defaultKdfIterations, disableTotpPassword, onLogoutNow, onNotify, onProfileUpdated, onSetConfirm, profile, session?.symEncKey, session?.symMacKey, refetchAuthorizedDevices, refetchTotpStatus, ] ); }