696 lines
22 KiB
TypeScript
696 lines
22 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) {
|
|
console.log(
|
|
"[Encryption] useEncryption: local PIN already set for userId",
|
|
);
|
|
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) {
|
|
console.log(
|
|
"[Encryption] useEncryption: user has encrypted_data, checking local pin",
|
|
);
|
|
const localPin = getEncryptionPassword(userId);
|
|
if (localPin) {
|
|
console.log(
|
|
"[Encryption] useEncryption: local pin found, decrypting user data",
|
|
);
|
|
const decryptedData = await decryptDataWithStoredPassword(
|
|
userId,
|
|
userData.encrypted_data,
|
|
);
|
|
if (decryptedData) {
|
|
const decryptedUserData = JSON.parse(decryptedData);
|
|
if (decryptedUserData.id === userData.id) {
|
|
console.log(
|
|
"[Encryption] useEncryption: local decrypt ok, user id match",
|
|
);
|
|
updateState({
|
|
isPasswordSet: true,
|
|
isSkipped: false,
|
|
showPasswordModal: false,
|
|
});
|
|
return;
|
|
}
|
|
} else {
|
|
console.log(
|
|
"[Encryption] useEncryption: local decrypt returned empty",
|
|
);
|
|
}
|
|
} else {
|
|
console.log(
|
|
"[Encryption] useEncryption: no local pin, fetching from server",
|
|
);
|
|
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();
|
|
console.log(
|
|
"[Encryption] useEncryption: server pin response ok",
|
|
serverPinData.pin ? "pin present" : "no pin",
|
|
);
|
|
// 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) {
|
|
console.log(
|
|
"[Encryption] useEncryption: server pin decrypt ok, storing locally",
|
|
);
|
|
const localSetSuccess = setEncryptionPassword(
|
|
userId,
|
|
serverPinData.pin,
|
|
);
|
|
if (localSetSuccess) {
|
|
persistAcrossDevicesRef.current = true;
|
|
updateState({
|
|
isPasswordSet: true,
|
|
isSkipped: false,
|
|
showPasswordModal: false,
|
|
persistAcrossDevices: true,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
console.log(
|
|
"[Encryption] useEncryption: server pin decrypt failed or no user id match",
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|