Amba-Agent-App/components/ui/pinConfirmationModal.tsx
2026-01-16 00:22:35 +03:00

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>
);
};