484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
import React, { useEffect, useState, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Modal,
|
|
Platform,
|
|
ActivityIndicator,
|
|
TouchableOpacity,
|
|
} from "react-native";
|
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
|
import { Button } from "~/components/ui/button";
|
|
import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { useUserProfile } from "~/lib/hooks/useUserProfile";
|
|
import { router } from "expo-router";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import BackButton from "./backButton";
|
|
import { Fingerprint, Lock } from "lucide-react-native";
|
|
import ModalToast from "~/components/ui/toast";
|
|
|
|
// Only import LocalAuthentication on native platforms
|
|
let LocalAuthentication: any = null;
|
|
if (Platform.OS !== "web") {
|
|
LocalAuthentication = require("expo-local-authentication");
|
|
}
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface PinConfirmationModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
title?: string;
|
|
}
|
|
|
|
export const PinConfirmationModal: React.FC<PinConfirmationModalProps> = ({
|
|
visible,
|
|
onClose,
|
|
onSuccess,
|
|
title,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
|
|
const effectiveTitle =
|
|
title ?? t("components.pinconfirmationmodal.titleDefault");
|
|
const { user } = useAuthWithProfile();
|
|
const { profile } = useUserProfile(user);
|
|
|
|
const [pin, setPin] = useState("");
|
|
const [isVerifying, setIsVerifying] = useState(false);
|
|
const [isAttemptingBiometric, setIsAttemptingBiometric] = useState(false);
|
|
const [biometricAttempted, setBiometricAttempted] = useState(false);
|
|
const [authMethod, setAuthMethod] = useState<"none" | "fingerprint" | "pin">(
|
|
"none"
|
|
);
|
|
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
|
const prevVisibleRef = useRef(false);
|
|
const [toastVisible, setToastVisible] = useState(false);
|
|
const [toastTitle, setToastTitle] = useState("");
|
|
const [toastDescription, setToastDescription] = useState<string | undefined>(
|
|
undefined
|
|
);
|
|
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const showToast = (title: string, description?: string) => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
|
|
setToastTitle(title);
|
|
setToastDescription(description);
|
|
setToastVisible(true);
|
|
|
|
toastTimeoutRef.current = setTimeout(() => {
|
|
setToastVisible(false);
|
|
toastTimeoutRef.current = null;
|
|
}, 2500);
|
|
};
|
|
|
|
const attemptBiometric = async () => {
|
|
// Web: No biometric support
|
|
if (Platform.OS === "web" || !LocalAuthentication) {
|
|
showToast("Error", "Biometric authentication not available on web");
|
|
setAuthMethod("none");
|
|
return;
|
|
}
|
|
|
|
if (!user) return;
|
|
|
|
try {
|
|
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
|
if (!hasHardware) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastBiometricErrorTitle"),
|
|
t(
|
|
"components.pinconfirmationmodal.toastBiometricHardwareNotAvailable"
|
|
)
|
|
);
|
|
setAuthMethod("none");
|
|
return;
|
|
}
|
|
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
|
if (!isEnrolled) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastBiometricErrorTitle"),
|
|
t("components.pinconfirmationmodal.toastBiometricNotEnrolled")
|
|
);
|
|
setAuthMethod("none");
|
|
return;
|
|
}
|
|
|
|
setIsAttemptingBiometric(true);
|
|
const result = await LocalAuthentication.authenticateAsync({
|
|
promptMessage: effectiveTitle,
|
|
cancelLabel: t("components.pinconfirmationmodal.cancelButton"),
|
|
disableDeviceFallback: true,
|
|
requireConfirmation: false,
|
|
});
|
|
setIsAttemptingBiometric(false);
|
|
if (result.success) {
|
|
setPin("");
|
|
setBiometricAttempted(false);
|
|
setAuthMethod("none");
|
|
onSuccess();
|
|
} else {
|
|
// User cancelled or failed - reset to choice screen
|
|
setAuthMethod("none");
|
|
}
|
|
} catch (e) {
|
|
setIsAttemptingBiometric(false);
|
|
setAuthMethod("none");
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastBiometricErrorTitle"),
|
|
t("components.pinconfirmationmodal.toastBiometricFailed")
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleFingerprintChoice = () => {
|
|
setAuthMethod("fingerprint");
|
|
attemptBiometric();
|
|
};
|
|
|
|
const handlePinChoice = () => {
|
|
setAuthMethod("pin");
|
|
};
|
|
|
|
// Check biometric availability on mount and when modal opens
|
|
useEffect(() => {
|
|
const checkBiometricAvailability = async () => {
|
|
// Web: Biometrics not available
|
|
if (Platform.OS === "web" || !LocalAuthentication) {
|
|
setBiometricAvailable(false);
|
|
return;
|
|
}
|
|
|
|
// Native: Check for biometric hardware and enrollment
|
|
if (Platform.OS === "android" || Platform.OS === "ios") {
|
|
try {
|
|
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
|
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
|
setBiometricAvailable(hasHardware && isEnrolled);
|
|
} catch (e) {
|
|
setBiometricAvailable(false);
|
|
}
|
|
} else {
|
|
setBiometricAvailable(false);
|
|
}
|
|
};
|
|
|
|
if (visible) {
|
|
checkBiometricAvailability();
|
|
}
|
|
}, [visible]);
|
|
|
|
useEffect(() => {
|
|
const prevVisible = prevVisibleRef.current;
|
|
prevVisibleRef.current = visible;
|
|
|
|
if (visible && !prevVisible) {
|
|
// Modal just opened (transition from false to true)
|
|
// Reset all states to ensure fresh start
|
|
setPin("");
|
|
setIsAttemptingBiometric(false);
|
|
setBiometricAttempted(false);
|
|
setAuthMethod("none");
|
|
} else if (!visible && prevVisible) {
|
|
// Modal just closed (transition from true to false)
|
|
// Reset all states for next time
|
|
setPin("");
|
|
setIsAttemptingBiometric(false);
|
|
setBiometricAttempted(false);
|
|
setAuthMethod("none");
|
|
}
|
|
}, [visible]);
|
|
|
|
// Render nothing when not requested
|
|
if (!visible) {
|
|
return null;
|
|
}
|
|
|
|
const handlePinSubmit = async () => {
|
|
if (pin.length !== 6) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastInvalidPinTitle"),
|
|
t("components.pinconfirmationmodal.toastInvalidPinDescription")
|
|
);
|
|
return;
|
|
}
|
|
setIsVerifying(true);
|
|
|
|
if (!user) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastAuthErrorTitle"),
|
|
t("components.pinconfirmationmodal.toastUserNotFound")
|
|
);
|
|
setIsVerifying(false);
|
|
return;
|
|
}
|
|
|
|
const profilePin = profile?.pin;
|
|
if (!profilePin) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastAuthErrorTitle"),
|
|
t("components.pinconfirmationmodal.toastPinNotFound")
|
|
);
|
|
setIsVerifying(false);
|
|
return;
|
|
}
|
|
|
|
// Incorrect PIN -> show inline toast instead of Alert
|
|
if (pin !== profilePin) {
|
|
showToast(
|
|
t("components.pinconfirmationmodal.toastIncorrectPinTitle"),
|
|
t("components.pinconfirmationmodal.toastIncorrectPinDescription")
|
|
);
|
|
setPin("");
|
|
setIsVerifying(false);
|
|
return;
|
|
}
|
|
|
|
// PIN is correct, call success callback
|
|
setPin("");
|
|
setIsVerifying(false);
|
|
setBiometricAttempted(false);
|
|
setIsAttemptingBiometric(false);
|
|
onSuccess();
|
|
};
|
|
|
|
const handleNumberPress = (input: string) => {
|
|
if (input === "clear") {
|
|
setPin("");
|
|
return;
|
|
}
|
|
|
|
if (input === "backspace") {
|
|
handleBackspace();
|
|
return;
|
|
}
|
|
|
|
// Handle digit input (0-9)
|
|
if (!/^[0-9]$/.test(input)) return;
|
|
|
|
// Limit to 6 digits
|
|
if (pin.length >= 6) return;
|
|
|
|
setPin(pin + input);
|
|
};
|
|
|
|
const handleBackspace = () => {
|
|
setPin((prev) => prev.slice(0, -1));
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setPin("");
|
|
setIsVerifying(false);
|
|
setIsAttemptingBiometric(false);
|
|
setBiometricAttempted(false);
|
|
setAuthMethod("none");
|
|
onClose();
|
|
};
|
|
|
|
const renderAuthMethodChoice = () => {
|
|
// On web, skip biometric option entirely and go straight to PIN
|
|
const showBiometricOption = biometricAvailable && Platform.OS !== "web";
|
|
|
|
return (
|
|
<View className="flex-1 justify-center items-center px-6 mt-[-20]">
|
|
<View className="items-center mb-0">
|
|
<Text className="text-2xl font-dmsans-bold text-gray-800 mb-2">
|
|
{showBiometricOption
|
|
? "Choose Authentication Method"
|
|
: "PIN Verification"}
|
|
</Text>
|
|
<Text className="text-gray-600 text-lg font-dmsans text-center mb-8">
|
|
{effectiveTitle}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="w-full space-y-4">
|
|
{showBiometricOption && (
|
|
<TouchableOpacity
|
|
onPress={handleFingerprintChoice}
|
|
className="flex-row items-center justify-between bg-gray-50 border-2 border-gray-200 rounded-2xl p-3 active:bg-gray-100"
|
|
>
|
|
<View className="flex-row items-center space-x-4">
|
|
<View className="bg-primary/10 p-2 mr-4 rounded-full">
|
|
<Fingerprint size={26} color="hsl(147,55%,28%)" />
|
|
</View>
|
|
<View>
|
|
<Text className="text-xl font-dmsans-bold text-gray-800">
|
|
{t("components.pinconfirmationmodal.fingerprintTitle")}
|
|
</Text>
|
|
<Text className="text-gray-500 font-dmsans text-sm">
|
|
{t("components.pinconfirmationmodal.fingerprintSubtitle")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{showBiometricOption && <View className="h-8" />}
|
|
|
|
<TouchableOpacity
|
|
onPress={handlePinChoice}
|
|
className="flex-row items-center justify-between bg-gray-50 border-2 border-gray-200 rounded-2xl p-3 active:bg-gray-100"
|
|
>
|
|
<View className="flex-row items-center space-x-4">
|
|
<View className="bg-primary/10 p-2 mr-4 rounded-full">
|
|
<Lock size={26} color="hsl(147,55%,28%)" />
|
|
</View>
|
|
<View>
|
|
<Text className="text-xl font-dmsans-bold text-gray-800">
|
|
{t("components.pinconfirmationmodal.pinTitle")}
|
|
</Text>
|
|
<Text className="text-gray-500 font-dmsans text-sm">
|
|
{t("components.pinconfirmationmodal.pinSubtitle")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderPinDots = () => {
|
|
return (
|
|
<View className="flex-row justify-around px-4 mb-6">
|
|
{[0, 1, 2, 3, 4, 5].map((index) => (
|
|
<View
|
|
key={index}
|
|
className={`w-5 h-5 rounded-full border-2 ${
|
|
index < pin.length
|
|
? "bg-primary border-primary"
|
|
: "bg-white border-gray-300"
|
|
}`}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View className="absolute inset-0 bg-white pb-14">
|
|
<View className="flex-1">
|
|
<BackButton
|
|
onPress={() => {
|
|
if (authMethod === "pin" || authMethod === "fingerprint") {
|
|
setAuthMethod("none");
|
|
setPin("");
|
|
setIsAttemptingBiometric(false);
|
|
} else {
|
|
handleClose();
|
|
router.replace(ROUTES.HOME);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{authMethod === "none" ? (
|
|
<Animated.View
|
|
key="choice"
|
|
className="flex-1"
|
|
entering={FadeIn.duration(200).delay(50)}
|
|
exiting={FadeOut.duration(150)}
|
|
>
|
|
{renderAuthMethodChoice()}
|
|
</Animated.View>
|
|
) : isAttemptingBiometric ? (
|
|
<Animated.View
|
|
key="biometric"
|
|
className="flex-1 justify-center items-center px-6"
|
|
entering={FadeIn.duration(200).delay(50)}
|
|
exiting={FadeOut.duration(150)}
|
|
>
|
|
<ActivityIndicator size="large" color="hsl(147,55%,28%)" />
|
|
<View className="h-4" />
|
|
<Text className="text-gray-600 font-dmsans text-lg">
|
|
{t("components.pinconfirmationmodal.biometricWaiting")}
|
|
</Text>
|
|
<View className="h-8" />
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setAuthMethod("none");
|
|
setIsAttemptingBiometric(false);
|
|
}}
|
|
className="px-6 py-3"
|
|
>
|
|
<Text className="text-primary font-dmsans-medium">
|
|
{t("components.pinconfirmationmodal.cancelButton")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
) : (
|
|
<Animated.View
|
|
key="pin"
|
|
className="flex-1"
|
|
entering={FadeIn.duration(200).delay(50)}
|
|
exiting={FadeOut.duration(150)}
|
|
>
|
|
<View className="flex-1">
|
|
{/* Header + title */}
|
|
<View className="px-6 pt-20 pb-4">
|
|
<View className="items-center mb-4">
|
|
<Text className="text-2xl font-dmsans-bold text-gray-800">
|
|
{t("components.pinconfirmationmodal.pinVerificationTitle")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="items-center mb-4">
|
|
<Text className="text-gray-600 text-lg font-dmsans text-center">
|
|
{effectiveTitle}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Bottom-aligned PIN area */}
|
|
<View className="flex-1 justify-end">
|
|
{/* PIN dots + Keypad - full width above button */}
|
|
<View className="pb-2">
|
|
{renderPinDots()}
|
|
<View className="mt-6">
|
|
<PhonePinKeypad onKeyPress={handleNumberPress} />
|
|
</View>
|
|
</View>
|
|
|
|
{/* Submit Button - just above bottom bar */}
|
|
<View className="px-5 py-5 bg-white border-t border-gray-100">
|
|
<Button
|
|
className={`rounded-3xl py-4 ${
|
|
pin.length === 6 ? "bg-primary" : "bg-gray-300"
|
|
}`}
|
|
onPress={handlePinSubmit}
|
|
disabled={
|
|
pin.length !== 6 || isVerifying || isAttemptingBiometric
|
|
}
|
|
>
|
|
<Text className="text-white font-dmsans-bold text-lg">
|
|
{isVerifying
|
|
? t(
|
|
"components.pinconfirmationmodal.submitButtonVerifying"
|
|
)
|
|
: t(
|
|
"components.pinconfirmationmodal.submitButtonConfirm"
|
|
)}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* Toast overlay above everything */}
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant="error"
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|