Files
RN_Template/RN_TEMPLATE/app/screens/ForgotPasswordScreen.tsx
2026-02-05 13:16:05 +08:00

352 lines
11 KiB
TypeScript

import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
// eslint-disable-next-line no-restricted-imports
import { TextInput, TextStyle, View, ViewStyle } from "react-native"
import { useFocusEffect } from "@react-navigation/native"
import { Button } from "@/components/Button"
import { PressableIcon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface ForgotPasswordScreenProps extends AppStackScreenProps<"ForgotPassword"> {}
export const ForgotPasswordScreen: FC<ForgotPasswordScreenProps> = ({ navigation }) => {
const emailInput = useRef<TextInput>(null)
const verificationCodeInput = useRef<TextInput>(null)
const newPasswordInput = useRef<TextInput>(null)
const [email, setEmail] = useState("")
const [verificationCode, setVerificationCode] = useState("")
const [newPassword, setNewPassword] = useState("")
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
const [resendCountdown, setResendCountdown] = useState(0)
const {
forgotPasswordStep,
pendingEmail,
isLoading,
error,
forgotPassword,
resendForgotPasswordCode,
resetPassword,
resetForgotPasswordStep,
clearError,
} = useAuth()
const {
themed,
theme: { colors },
} = useAppTheme()
// Email validation
const emailError = useMemo(() => {
if (!email || email.length === 0) return ""
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
return ""
}, [email])
// New password validation - requires uppercase, lowercase, number, and special character
const newPasswordError = useMemo(() => {
if (!newPassword || newPassword.length === 0) return ""
if (newPassword.length < 8) return "Password must be at least 8 characters"
if (!/[A-Z]/.test(newPassword)) return "Password must contain an uppercase letter"
if (!/[a-z]/.test(newPassword)) return "Password must contain a lowercase letter"
if (!/[0-9]/.test(newPassword)) return "Password must contain a number"
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(newPassword))
return "Password must contain a special character"
return ""
}, [newPassword])
// Countdown timer for resend button
useEffect(() => {
if (resendCountdown > 0) {
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
return () => clearTimeout(timer)
}
return undefined
}, [resendCountdown])
// Clear error when screen comes into focus
useFocusEffect(
useCallback(() => {
clearError()
}, [clearError]),
)
// Clear error when inputs change (not when error changes)
const prevInputsRef = useRef({ email, verificationCode, newPassword })
useEffect(() => {
const prev = prevInputsRef.current
// Only clear error if one of the input fields actually changed
if (
prev.email !== email ||
prev.verificationCode !== verificationCode ||
prev.newPassword !== newPassword
) {
if (error) clearError()
prevInputsRef.current = { email, verificationCode, newPassword }
}
}, [email, verificationCode, newPassword, error, clearError])
// Handle forgot password (step 1)
const handleForgotPassword = useCallback(async () => {
if (!email || emailError) return
const success = await forgotPassword(email)
if (success) {
setResendCountdown(60)
}
}, [email, emailError, forgotPassword])
// Handle reset password (step 2)
const handleResetPassword = useCallback(async () => {
if (!verificationCode || verificationCode.length !== 6 || !newPassword || newPasswordError)
return
const success = await resetPassword(verificationCode, newPassword)
if (success) {
// Reset state and navigate back to login
resetForgotPasswordStep()
navigation.navigate("Login")
}
}, [
verificationCode,
newPassword,
newPasswordError,
resetPassword,
resetForgotPasswordStep,
navigation,
])
// Handle resend code
const handleResendCode = useCallback(async () => {
if (resendCountdown > 0) return
const success = await resendForgotPasswordCode()
if (success) {
setResendCountdown(60)
}
}, [resendCountdown, resendForgotPasswordCode])
// Handle back to email step
const handleBackToEmail = useCallback(() => {
resetForgotPasswordStep()
setVerificationCode("")
setNewPassword("")
}, [resetForgotPasswordStep])
// Navigate to login
const handleGoToLogin = useCallback(() => {
resetForgotPasswordStep()
navigation.navigate("Login")
}, [navigation, resetForgotPasswordStep])
// Password visibility toggle
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
() =>
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
return (
<PressableIcon
icon={isPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
/>
)
},
[isPasswordHidden, colors.palette.neutral800],
)
// Render email step (step 1)
const renderEmailStep = () => (
<>
<Text
testID="forgot-password-heading"
text="Forgot Password"
preset="heading"
style={themed($title)}
/>
<Text
text="Enter your email address and we'll send you a verification code"
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={emailInput}
value={email}
onChangeText={setEmail}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
keyboardType="email-address"
label="Email"
placeholder="Enter your email"
helper={emailError}
status={emailError ? "error" : undefined}
onSubmitEditing={handleForgotPassword}
/>
<Button
testID="send-code-button"
text="Send Verification Code"
style={themed($button)}
preset="reversed"
onPress={handleForgotPassword}
disabled={isLoading || !email || !!emailError}
loading={isLoading}
/>
<View style={themed($loginLinkContainer)}>
<Text text="Remember your password? " size="sm" />
<Button testID="go-to-login-button" text="Log In" preset="link" onPress={handleGoToLogin} />
</View>
</>
)
// Render reset step (step 2)
const renderResetStep = () => (
<>
<Text
testID="reset-password-heading"
text="Reset Password"
preset="heading"
style={themed($title)}
/>
<Text
text={`We've sent a verification code to ${pendingEmail}`}
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={verificationCodeInput}
value={verificationCode}
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="one-time-code"
autoCorrect={false}
keyboardType="number-pad"
label="Verification Code"
placeholder="Enter 6-digit code"
maxLength={6}
onSubmitEditing={() => newPasswordInput.current?.focus()}
/>
<TextField
ref={newPasswordInput}
value={newPassword}
onChangeText={setNewPassword}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="password-new"
autoCorrect={false}
secureTextEntry={isPasswordHidden}
label="New Password"
placeholder="Enter your new password"
helper={newPasswordError}
status={newPasswordError ? "error" : undefined}
onSubmitEditing={handleResetPassword}
RightAccessory={PasswordRightAccessory}
/>
<Button
testID="reset-password-button"
text="Reset Password"
style={themed($button)}
preset="reversed"
onPress={handleResetPassword}
disabled={isLoading || verificationCode.length !== 6 || !newPassword || !!newPasswordError}
loading={isLoading}
/>
<View style={themed($resendContainer)}>
<Text text="Didn't receive the code? " size="sm" />
<Button
testID="resend-button"
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
preset="link"
onPress={handleResendCode}
disabled={isLoading || resendCountdown > 0}
/>
</View>
<Button
testID="back-button"
text="Back"
preset="default"
style={themed($backButton)}
onPress={handleBackToEmail}
disabled={isLoading}
/>
</>
)
return (
<Screen
preset="auto"
contentContainerStyle={themed($screenContentContainer)}
safeAreaEdges={["top", "bottom"]}
>
{forgotPasswordStep === "email" ? renderEmailStep() : renderResetStep()}
</Screen>
)
}
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
})
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $subtitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $button: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $loginLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
})
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.lg,
})
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.lg,
})