integratedhub/islands/WelcomeBar/useEncryption.ts

666 lines
20 KiB
TypeScript

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,
getUserSkipValue,
tryDecryptDataWithPin,
} from "../../helpers.ts";
// Store encryption password in localStorage
function setEncryptionPassword(userId: string, password: string): boolean {
if (!userId) return false;
try {
const userKey = getUserEncryptionKey(userId);
if (userKey) {
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 {
return getUserSkipValue(userId);
} 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 <EncryptionModal {...encryption} />;
* }
* ```
*/
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<AbortController | null>(null);
// Ref for persist-across-devices so submit sees latest value (avoids stale state when user checks box then clicks Continue immediately)
const persistAcrossDevicesRef = useRef(false);
// 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<typeof state>) => {
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",
cache: "no-store",
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) {
const localPin = getEncryptionPassword(userId);
if (localPin) {
const decryptedData = await decryptDataWithStoredPassword(
userId,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
if (decryptedUserData.id === userData.id) {
updateState({
isPasswordSet: true,
isSkipped: false,
showPasswordModal: false,
});
return;
}
}
} else {
const response = await fetch("/api/sync-pin/get", {
credentials: "include",
cache: "no-store",
signal: abortController.signal,
});
// Check if aborted before processing response
if (abortController.signal.aborted) return;
if (response.ok) {
const serverPinData = await response.json();
// Server has a pin, store it in localStorage for local usage
if (serverPinData.pin) {
const decryptedData = await tryDecryptDataWithPin(
serverPinData.pin,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
if (decryptedUserData.id === userData.id) {
const localSetSuccess = setEncryptionPassword(
userId,
serverPinData.pin,
);
if (localSetSuccess) {
persistAcrossDevicesRef.current = true;
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",
cache: "no-store",
});
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",
cache: "no-store",
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 persist across devices was checked, sync to server (use ref so we see latest value even if user checked then clicked Continue immediately)
const shouldPersist = persistAcrossDevicesRef.current;
if (shouldPersist) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
cache: "no-store",
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
persistAcrossDevicesRef.current = false;
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 (sync ref from state so submit uses current persist choice)
const handleChangePin = () => {
persistAcrossDevicesRef.current = state.persistAcrossDevices;
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);
if (storageKey) {
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",
cache: "no-store",
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 persist across devices is checked, sync to server (use ref for same-tick checkbox + submit)
const shouldPersist = persistAcrossDevicesRef.current;
if (shouldPersist) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
cache: "no-store",
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
persistAcrossDevicesRef.current = false;
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 (ref updated synchronously so submit sees it even if user clicks Continue immediately)
const handlePersistAcrossDevicesChange = (persist: boolean) => {
persistAcrossDevicesRef.current = persist;
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,
};
}