diff --git a/apps/mobile/app/(auth)/forgot-password.tsx b/apps/mobile/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..6d333d2 --- /dev/null +++ b/apps/mobile/app/(auth)/forgot-password.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect, useCallback } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import { router } from "expo-router"; +import * as Linking from "expo-linking"; +import Animated, { + FadeInDown, + FadeInUp, +} from "react-native-reanimated"; +import { Ionicons } from "@expo/vector-icons"; +import { supabase } from "../../src/lib/supabase"; +import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui"; +import { colors, spacing, radius, typography } from "../../src/theme"; + +export default function ForgotPasswordScreen() { + const [email, setEmail] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isEmailSent, setIsEmailSent] = useState(false); + const [isResending, setIsResending] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + const [error, setError] = useState(null); + + // Cooldown timer for resend + useEffect(() => { + if (resendCooldown <= 0) return; + + const timer = setInterval(() => { + setResendCooldown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [resendCooldown]); + + const showError = (message: string) => { + setError(message); + if (Platform.OS !== 'web') { + Alert.alert("Error", message); + } + }; + + const clearError = () => setError(null); + + const handleResetPassword = async () => { + clearError(); + if (!email.trim()) { + showError("Please enter your email"); + return; + } + + setIsLoading(true); + const { error: resetError } = await supabase.auth.resetPasswordForEmail( + email.trim(), + { + redirectTo: Linking.createURL("/(auth)/reset-password"), + } + ); + setIsLoading(false); + + if (resetError) { + showError(resetError.message); + } else { + setIsEmailSent(true); + setResendCooldown(60); + } + }; + + const handleResend = useCallback(async () => { + if (resendCooldown > 0) return; + + setIsResending(true); + const { error: resendError } = await supabase.auth.resetPasswordForEmail( + email.trim(), + { + redirectTo: Linking.createURL("/(auth)/reset-password"), + } + ); + setIsResending(false); + + if (resendError) { + if (Platform.OS !== 'web') { + Alert.alert("Error", resendError.message); + } + } else { + setResendCooldown(60); + if (Platform.OS !== 'web') { + Alert.alert("Email Sent", "A new password reset email has been sent."); + } + } + }, [email, resendCooldown]); + + // Email sent confirmation screen + if (isEmailSent) { + return ( + + + + + + + Check your email + + We sent a password reset link to{'\n'} + {email} + + + Click the link in your email to reset your password. + + 0} + style={{ marginTop: spacing.lg }} + > + {resendCooldown > 0 + ? `Resend email (${resendCooldown}s)` + : "Resend email"} + + { + setIsEmailSent(false); + setEmail(""); + setResendCooldown(0); + }} + style={{ marginTop: spacing.sm }} + > + Try a different email + + router.back()} + style={{ marginTop: spacing.sm }} + > + Back to login + + + + + ); + } + + return ( + + + + + {/* Back Button */} + + router.back()} + activeOpacity={0.7} + > + + + + + {/* Header */} + + + + + Reset Password + + Enter your email and we'll send you a link to reset your password + + + + {/* Form Card */} + + + {/* Error Message */} + {error && ( + + + {error} + + )} + + {/* Form Fields */} + + { clearError(); setEmail(text); }} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + autoFocus + icon={} + /> + + + Send Reset Link + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centeredContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + centeredContent: { + alignItems: "center", + paddingHorizontal: spacing.lg, + }, + scrollContent: { + flexGrow: 1, + }, + content: { + flex: 1, + paddingHorizontal: spacing.lg, + paddingTop: 60, + paddingBottom: spacing.xl, + }, + backButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.glass.background, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + headerContainer: { + alignItems: "center", + marginBottom: spacing.xl, + }, + logoContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + iconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + title: { + ...typography.largeTitle, + color: colors.text, + marginBottom: spacing.xs, + textAlign: "center", + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, + emailHighlight: { + color: colors.primary, + fontWeight: "600", + }, + hint: { + ...typography.subhead, + color: colors.textSecondary, + textAlign: "center", + marginTop: spacing.md, + paddingHorizontal: spacing.md, + }, + formContainer: { + marginBottom: spacing.lg, + }, + errorContainer: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.errorLight, + padding: spacing.md, + borderRadius: radius.md, + marginBottom: spacing.md, + gap: spacing.sm, + }, + errorText: { + ...typography.subhead, + color: colors.error, + flex: 1, + }, + form: { + gap: spacing.sm, + }, +}); diff --git a/apps/mobile/app/(auth)/reset-password.tsx b/apps/mobile/app/(auth)/reset-password.tsx new file mode 100644 index 0000000..8ecd218 --- /dev/null +++ b/apps/mobile/app/(auth)/reset-password.tsx @@ -0,0 +1,291 @@ +import { useState } from "react"; +import { + View, + Text, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import { router } from "expo-router"; +import Animated, { + FadeIn, + FadeInDown, + FadeInUp, +} from "react-native-reanimated"; +import { Ionicons } from "@expo/vector-icons"; +import { supabase } from "../../src/lib/supabase"; +import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui"; +import { colors, spacing, radius, typography } from "../../src/theme"; + +export default function ResetPasswordScreen() { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const showError = (message: string) => { + setError(message); + if (Platform.OS !== 'web') { + Alert.alert("Error", message); + } + }; + + const clearError = () => setError(null); + + const handleResetPassword = async () => { + clearError(); + + // Validate password + if (!password) { + showError("Please enter a new password"); + return; + } + if (password.length < 8) { + showError("Password must be at least 8 characters"); + return; + } + if (password !== confirmPassword) { + showError("Passwords do not match"); + return; + } + + setIsLoading(true); + const { error: updateError } = await supabase.auth.updateUser({ + password, + }); + setIsLoading(false); + + if (updateError) { + // Handle common errors + if (updateError.message.includes("same as")) { + showError("New password must be different from your current password"); + } else if (updateError.message.includes("session")) { + showError("Your reset link has expired. Please request a new one."); + } else { + showError(updateError.message); + } + } else { + setIsSuccess(true); + } + }; + + const handleContinue = () => { + // Navigate to main app + router.replace("/"); + }; + + // Success screen + if (isSuccess) { + return ( + + + + + + + Password Updated + + Your password has been successfully reset. You can now use your new password to sign in. + + + Continue to App + + + + + ); + } + + return ( + + + + + {/* Header */} + + + + + Create New Password + + Enter your new password below + + + + {/* Form Card */} + + + {/* Error Message */} + {error && ( + + + {error} + + )} + + {/* Form Fields */} + + { clearError(); setPassword(text); }} + secureTextEntry + autoCapitalize="none" + autoFocus + icon={} + /> + + { clearError(); setConfirmPassword(text); }} + secureTextEntry + autoCapitalize="none" + icon={} + /> + + + Password must be at least 8 characters + + + + Reset Password + + + + + + {/* Back to Login Link */} + + router.replace("/(auth)/login")} + > + Back to login + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centeredContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + centeredContent: { + alignItems: "center", + paddingHorizontal: spacing.lg, + }, + scrollContent: { + flexGrow: 1, + }, + content: { + flex: 1, + paddingHorizontal: spacing.lg, + paddingTop: 80, + paddingBottom: spacing.xl, + }, + headerContainer: { + alignItems: "center", + marginBottom: spacing.xl, + }, + logoContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + successIconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.successLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + title: { + ...typography.largeTitle, + color: colors.text, + marginBottom: spacing.xs, + textAlign: "center", + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, + formContainer: { + marginBottom: spacing.lg, + }, + errorContainer: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.errorLight, + padding: spacing.md, + borderRadius: radius.md, + marginBottom: spacing.md, + gap: spacing.sm, + }, + errorText: { + ...typography.subhead, + color: colors.error, + flex: 1, + }, + form: { + gap: spacing.sm, + }, + passwordHint: { + ...typography.caption1, + color: colors.textTertiary, + textAlign: "center", + marginTop: spacing.xs, + }, + footerContainer: { + alignItems: "center", + }, +});