import { useEffect, useRef, useState } from "preact/hooks"; import { UseEncryptionReturn } from "./welcome-types.ts"; import { User } from "../../db/interfaces.ts"; import { decryptDataWithStoredPassword, encryptDataWithStoredPassword, getEncryptionPassword, getUserEncryptionKey, getUserSkipKey, tryDecryptDataWith, } from "../../helpers.ts"; // Store encryption password in localStorage function setEncryptionPassword(userId: string, password: string): boolean { if (!userId) return false; try { const userKey = getUserEncryptionKey(userId); globalThis.localStorage?.setItem(userKey, password); return true; } catch (error) { console.error("Failed to store encryption password:", error); return false; } } // Set skip state for encryption setup function setEncryptionSkip(userId: string, skip: boolean): boolean { if (!userId) return false; try { const skipKey = getUserSkipKey(userId); if (skip) { globalThis.localStorage?.setItem(skipKey, "true"); } else { globalThis.localStorage?.removeItem(skipKey); } return true; } catch (error) { console.error("Failed to set skip state:", error); return false; } } // Check if user has skipped encryption setup function _hasEncryptionSkip(userId: string): boolean { if (!userId) return false; try { const skipKey = getUserSkipKey(userId); return globalThis.localStorage?.getItem(skipKey) === "true"; } catch (error) { console.error("Failed to check skip state:", error); return false; } } // Clear skip state function clearEncryptionSkip(userId: string): void { if (!userId) return; try { const skipKey = getUserSkipKey(userId); globalThis.localStorage?.removeItem(skipKey); } catch (error) { console.error("Failed to clear skip state:", error); } } /** * Custom hook to manage encryption state and operations * Centralizes all encryption-related logic for reusability * * @param userId - The current user's ID * @returns Encryption state and handlers * * @example * ```tsx * function MyComponent({ userId }) { * const encryption = useEncryption(userId); * return ; * } * ``` */ export function useEncryption( userId: string, isEncrypted: boolean, ): UseEncryptionReturn { // Track mounted state to prevent updates after unmount const isMountedRef = useRef(true); // AbortController ref for cancelling fetch operations on unmount const abortControllerRef = useRef(null); // Consolidated encryption state const [state, setState] = useState({ isPasswordSet: false, isSkipped: false, showPasswordModal: false, showChangePinModal: false, showRemovePinConfirm: false, tempPassword: "", passwordError: "", persistAcrossDevices: false, }); // Helper for partial state updates - only updates if still mounted const updateState = (partial: Partial) => { if (isMountedRef.current) { setState((prev) => ({ ...prev, ...partial })); } }; // Explicit handler to open the password modal (e.g. from LockIcon warning state) const handleOpenPasswordModal = () => { updateState({ showPasswordModal: true, isSkipped: false, }); }; // Check encryption password status on mount and when userId changes useEffect(() => { // Reset mounted state on each effect run isMountedRef.current = true; // Create new abort controller for this effect run abortControllerRef.current?.abort(); const abortController = new AbortController(); abortControllerRef.current = abortController; // Only run encryption logic if we have a valid userId (logged in user) if (typeof globalThis !== "undefined" && userId && userId.length > 0) { if ( (globalThis as { __E2E_SKIP_ENCRYPTION__?: boolean }) .__E2E_SKIP_ENCRYPTION__ ) { updateState({ isPasswordSet: false, isSkipped: true, showPasswordModal: false, }); return; } // Check localStorage directly to see if password is already set const existingPassword = getEncryptionPassword(userId); if (existingPassword) { updateState({ isPasswordSet: true, isSkipped: false, showPasswordModal: false, }); return; } const hasSkip = _hasEncryptionSkip(userId); if (hasSkip) { updateState({ isPasswordSet: false, isSkipped: true, showPasswordModal: false, }); return; } const checkEncryptionStatus = async () => { try { if (isEncrypted) { const me = await fetch("/api/user/get", { credentials: "include", signal: abortController.signal, }); // Check if aborted before processing response if (abortController.signal.aborted) return; if (me.ok) { const userData = await me.json() as User; if (userData.id && userData.encrypted_data) { //console.log("User data found, checking local pin"); const localPin = getEncryptionPassword(userId); if (localPin) { //console.log("Local pin found, decrypting data"); const decryptedData = await decryptDataWithStoredPassword( userId, userData.encrypted_data, ); if (decryptedData) { const decryptedUserData = JSON.parse(decryptedData); //console.log("Checking user data", decryptedUserData); //console.log("user id ", userData.id); if (decryptedUserData.id === userData.id) { updateState({ isPasswordSet: true, isSkipped: false, showPasswordModal: false, }); return; } } } else { //console.log("No local pin found, checking server"); const response = await fetch("/api/sync-pin/get", { credentials: "include", signal: abortController.signal, }); // Check if aborted before processing response if (abortController.signal.aborted) return; if (response.ok) { const serverPinData = await response.json(); //console.log("Server pin found, decrypting data", serverPinData); // Server has a pin, store it in localStorage for local usage if (serverPinData.pin) { const decryptedData = await tryDecryptDataWith( serverPinData.pin, userData.encrypted_data, ); if (decryptedData) { const decryptedUserData = JSON.parse(decryptedData); //console.log("Decrypted data found, checking user ID", decryptedUserData.id); //console.log("user id ", userData.id); if (decryptedUserData.id === userData.id) { const localSetSuccess = setEncryptionPassword( userId, serverPinData.pin, ); if (localSetSuccess) { updateState({ isPasswordSet: true, isSkipped: false, showPasswordModal: false, persistAcrossDevices: true, }); return; } } } // Server pin was not usable for this encrypted data, fall back to asking user updateState({ showPasswordModal: true, isSkipped: false, persistAcrossDevices: false, }); } else { updateState({ showPasswordModal: true, isSkipped: false, persistAcrossDevices: false, }); } return; } else { updateState({ showPasswordModal: true, isSkipped: false, persistAcrossDevices: false, }); } } } } } // Show modal for logged-in users without password ONLY if isEncrypted is falsy if (!isEncrypted) { updateState({ showPasswordModal: true, persistAcrossDevices: false, // Default to local only }); } } catch (error) { // Ignore abort errors - these are expected on cleanup if (error instanceof Error && error.name === "AbortError") { return; } console.error("Error during encryption password setup:", error); updateState({ showPasswordModal: false }); } }; checkEncryptionStatus(); } else { //console.log("No user ID found, skipping encryption setup"); // For guest users (no userId), ensure modal is hidden try { localStorage.removeItem( "realestate_app_skip_user_zZoSEsW5UYehfqDNkmflaM", ); localStorage.removeItem("realestate_app_user_zZoSEsW5UYehfqDNkmflaM"); } catch { // Ignore localStorage errors in SSR or private mode } updateState({ showPasswordModal: false, isPasswordSet: false, isSkipped: false, }); } // Cleanup: abort pending requests and mark as unmounted return () => { isMountedRef.current = false; abortController.abort(); }; }, [userId, isEncrypted]); // Handle encryption password input const handlePasswordChange = (password: string) => { updateState({ tempPassword: password }); // Clear error when user starts typing if (state.passwordError) { updateState({ passwordError: "" }); } }; // Handle setting the password const handleSetPassword = async () => { if (!userId) return; // If in change mode and password is empty, show confirmation dialog if (state.showChangePinModal && state.tempPassword.length === 0) { updateState({ showRemovePinConfirm: true }); return; } // Require minimum 4 characters for normal password setting if (state.tempPassword.length < 4) return; updateState({ passwordError: "" }); try { // Fetch user data to support validation and/or initial encryption //console.log(`[Encryption] Fetching user data for PIN handling`); const userResponse = await fetch("/api/user/get", { credentials: "include", }); if (!userResponse.ok) { throw new Error("Failed to fetch user data"); } // At this point, either the account was not encrypted yet, or the PIN has been validated. // Now persist the PIN locally. const localSetSuccess = setEncryptionPassword( userId, state.tempPassword, ); if (!localSetSuccess) { throw new Error("Failed to store PIN locally"); } const userData = await userResponse.json() as User; // If the account is already encrypted, validate the entered PIN against existing encrypted_data if (isEncrypted && userData.encrypted_data) { const decrypted = await decryptDataWithStoredPassword( userId, userData.encrypted_data, ); if (!decrypted) { console.warn( `[Encryption] Entered PIN failed to decrypt existing data`, ); updateState({ passwordError: "Incorrect PIN. Please try again.", }); return; } } // For first-time encryption (isEncrypted is falsy), encrypt and push encrypted_data to server if (!isEncrypted) { try { //console.log(`[Encryption] Encrypting user data with new PIN`); const encryptedData = await encryptDataWithStoredPassword( userId, JSON.stringify(userData), ); if (encryptedData) { // console.log(`[Encryption] User data encrypted successfully`); // console.log(`[Encryption] Encrypted data:`, encryptedData); const response = await fetch("/api/user/set", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ encrypted_data: encryptedData, }), }); if (!response.ok) { throw new Error("Failed to update user data"); } const result = await response.json(); //console.log(`[Encryption] User data updated successfully`); //console.log(`[Encryption] Result:`, result); } } catch (encryptionError) { console.error( `[Encryption] Error during user data encryption:`, encryptionError, ); // Continue with password setup even if encryption fails } } // If persistAcrossDevices is true, sync to server if (state.persistAcrossDevices) { try { const response = await fetch("/api/sync-pin/set", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ pin: state.tempPassword, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to sync PIN to server"); } } catch (_serverError) { // Still allow local usage even if server sync fails updateState({ passwordError: "PIN set locally but failed to sync across devices", persistAcrossDevices: false, }); return; } } updateState({ isPasswordSet: true, showPasswordModal: false, showChangePinModal: false, }); // Only redirect if everything succeeded and component is still mounted if ( isMountedRef.current && typeof globalThis !== "undefined" && globalThis.location ) { globalThis.location.href = "/"; } } catch (error) { console.error("Error setting password:", error); updateState({ passwordError: "Failed to set PIN. Please try again." }); } }; // Handle skip encryption const handleSkipEncryption = () => { if (userId) { setEncryptionSkip(userId, true); updateState({ isSkipped: true, isPasswordSet: false, showPasswordModal: false, }); } }; // Handle clearing skip state const handleClearSkip = () => { if (userId) { clearEncryptionSkip(userId); updateState({ isSkipped: false, isPasswordSet: false, showPasswordModal: true, }); } }; // Handle opening change PIN modal const handleChangePin = () => { updateState({ showChangePinModal: true, showRemovePinConfirm: false, tempPassword: "", passwordError: "", }); }; // Handle closing change PIN modal const handleCloseChangePin = () => { updateState({ showChangePinModal: false, showRemovePinConfirm: false, tempPassword: "", passwordError: "", }); }; // Handle remove PIN confirmation const handleRemovePinConfirm = async () => { if (!userId) return; try { // Remove the encryption password from localStorage const storageKey = getUserEncryptionKey(userId); globalThis.localStorage?.removeItem(storageKey); // If sync was enabled, also remove from server if (state.persistAcrossDevices) { try { const response = await fetch("/api/sync-pin/delete", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify({}), }); } catch (serverError) { console.warn( "[Pin Sync] Failed to remove PIN from server:", serverError, ); } } // Set skip state instead of clearing it setEncryptionSkip(userId, true); updateState({ isPasswordSet: false, isSkipped: true, showChangePinModal: false, showRemovePinConfirm: false, tempPassword: "", passwordError: "", persistAcrossDevices: false, }); } catch (error) { console.error("Error removing PIN:", error); updateState({ passwordError: "Failed to remove PIN. Please try again." }); } }; // Handle cancel remove PIN const handleCancelRemovePin = () => { updateState({ showRemovePinConfirm: false }); }; // Handle changing the PIN const handleChangePinSubmit = async () => { if (!userId) return; // If empty PIN, show confirmation dialog to remove existing PIN if (state.tempPassword.length === 0) { updateState({ showRemovePinConfirm: true }); return; } if (state.tempPassword.length < 4) return; updateState({ passwordError: "" }); try { // Set the new password locally first const localSetSuccess = setEncryptionPassword( userId, state.tempPassword, ); if (!localSetSuccess) { throw new Error("Failed to store PIN locally"); } // If persistAcrossDevices is true, sync to server if (state.persistAcrossDevices) { try { const response = await fetch("/api/sync-pin/set", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ pin: state.tempPassword, }), }); if (!response.ok) { const errorData = await response.json(); console.error( `[Pin Sync] Server sync failed during change:`, errorData, ); throw new Error(errorData.error || "Failed to sync PIN to server"); } } catch (serverError) { // Still allow local usage even if server sync fails updateState({ passwordError: "PIN updated locally but failed to sync across devices", persistAcrossDevices: false, }); return; } } updateState({ isPasswordSet: true, showChangePinModal: false, tempPassword: "", }); } catch (error) { console.error("Error changing PIN:", error); updateState({ passwordError: "Failed to update PIN. Please try again." }); } }; // Handle persist across devices checkbox change const handlePersistAcrossDevicesChange = (persist: boolean) => { updateState({ persistAcrossDevices: persist }); }; return { isPasswordSet: state.isPasswordSet, isSkipped: state.isSkipped, showPasswordModal: state.showPasswordModal, showChangePinModal: state.showChangePinModal, showRemovePinConfirm: state.showRemovePinConfirm, tempPassword: state.tempPassword, passwordError: state.passwordError, persistAcrossDevices: state.persistAcrossDevices, handlePasswordChange, handleSetPassword, handleSkipEncryption, handleClearSkip, handleChangePin, handleCloseChangePin, handleChangePinSubmit, handleRemovePinConfirm, handleCancelRemovePin, handlePersistAcrossDevicesChange, handleOpenPasswordModal, }; }