0

I am building a web app using React for my frontend and Supabase for the database, auth and also edge functions. Using Supabase auth, I have implemented reset password logic which works well when I test from Chrome on Windows and also Android devices. However, when it comes to iPhones and Safari, things start falling apart.

First, on Mac books, when using Safari browser, when you initiate the password reset process using a form input where you enter your email and send, everything works fine. The request is received by Supabase and an email is sent. When a user opens the email on their Safari browser on Mac book and click the link, they are supposed to be redirected to an input where they are to input their new password and confirm it. On submitting, it just loads there forever and never actually submits the passwords. On iPhones, it never even gets past the first stage. On loading the email input form, you put your email and hit send, it just loads there forever.

This is the code I have for the ForgotPassword.jsx form:

import React, { useState, useRef, useCallback } from "react";
import Seo from "@/components/Seo";
import { motion } from "framer-motion";
import { Link, useNavigate } from "react-router-dom";
import { supabase } from "@/lib/customSupabaseClient";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  CardDescription,
} from "@/components/ui/card";
import { Mail, ArrowLeft, Loader2, CheckCircle } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import DOMPurify from "dompurify";
import { handlePasswordResetError } from "@/utils/authUtils";

const ForgotPasswordPage = () => {
  const [email, setEmail] = useState("");
  const [loading, setLoading] = useState(false);
  const [messageSent, setMessageSent] = useState(false);
  const { toast } = useToast();
  const navigate = useNavigate();
  const isSubmitting = useRef(false);

  console.log("Window location: ", window.location.origin);

  const handleSubmit = useCallback(
    async (e) => {
      e.preventDefault();
      if (isSubmitting.current) return;

      const sanitizedEmail = DOMPurify.sanitize(email.trim().toLowerCase());

      if (!sanitizedEmail) {
        toast({
          title: "Missing information",
          description: "Please enter your email address.",
          variant: "destructive",
        });
        return;
      }

      isSubmitting.current = true;
      setLoading(true);

      try {
        const { data, error } = await supabase.auth.resetPasswordForEmail(
          sanitizedEmail,
          {
            redirectTo: `${window.location.origin}/reset-password`,
          }
        );

        if (error) {
          // Even if there's an error (e.g., user not found), show a generic success message
          // to prevent user enumeration attacks.
          console.error(
            "Password reset request error (suppressed for user):",
            error
          );
          if (error.message.includes("rate limit")) {
            const friendlyError = handlePasswordResetError(error);
            toast({
              title: "Error",
              description: friendlyError,
              variant: "destructive",
            });
          }
        }

        setMessageSent(true);
      } catch (error) {
        console.error("Unhandled password reset request error:", error);
        const friendlyError = handlePasswordResetError(error);
        toast({
          title: "Error",
          description: friendlyError,
          variant: "destructive",
        });
      } finally {
        setLoading(false);
        isSubmitting.current = false;
      }
    },
    [email, toast]
  );

  return (
    <>
      <Seo
        title="Forgot Password"
        description="Reset your account password. We'll send a reset link to your email address."
        url="/forgot-password"
        keywords="password reset, forgot password, account recovery"
      />
      <div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-primary/5 to-secondary/20 relative">
        <div className="absolute top-4 right-4">
          <ThemeToggle />
        </div>
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5 }}
          className="w-full max-w-md z-10"
        >
          <Card className="backdrop-blur-xl bg-white/80 dark:bg-gray-900/80 border border-white/20 dark:border-gray-700/20 shadow-2xl">
            <CardHeader className="text-center">
              <CardTitle className="text-2xl md:text-3xl font-bold">
                Forgot Your Password?
              </CardTitle>
              <CardDescription className="text-base">
                Don’t worry! Enter your email address to reset your password.
              </CardDescription>
            </CardHeader>
            <CardContent>
              {messageSent ? (
                <div className="text-center p-4 rounded-lg bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700">
                  <CheckCircle className="mx-auto h-12 w-12 text-green-500 mb-4" />
                  <h3 className="text-xl font-semibold text-green-800 dark:text-green-200">
                    Request Received!
                  </h3>
                  <p className="text-muted-foreground mt-2">
                    If your email address is registered with us, a password reset
                    link will be sent. Check your inbox and spam folder.
                  </p>
                  <Button
                    onClick={() => navigate("/login")}
                    className="mt-6 w-full"
                  >
                    Return to Login Page
                  </Button>
                </div>
              ) : (
                <form onSubmit={handleSubmit} className="space-y-6">
                  <div className="space-y-2">
                    <Label htmlFor="email">Email Address</Label>
                    <div className="relative group">
                      <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
                      <Input
                        id="email"
                        type="email"
                        placeholder="[email protected]"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        required
                        disabled={loading}
                        className="pl-10 h-10 text-sm"
                      />
                    </div>
                  </div>
                  <Button type="submit" className="w-full" disabled={loading}>
                    {loading ? (
                      <>
                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                        Sending...
                      </>
                    ) : (
                      "Send Password Reset Link"
                    )}
                  </Button>
                </form>
              )}
              <div className="mt-6 text-center text-sm">
                <Link
                  to="/login"
                  className="font-medium text-primary hover:underline inline-flex items-center"
                >
                  <ArrowLeft className="mr-1 h-4 w-4" />
                  Back to Login Screen
                </Link>
              </div>
            </CardContent>
          </Card>
        </motion.div>
      </div>
    </>
  );
};

export default ForgotPasswordPage;

This is the code I have for the ResetPasswordPage.jsx

import React, { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/lib/customSupabaseClient";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  CardDescription,
} from "@/components/ui/card";
import { Lock, Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import Seo from "@/components/Seo";

// Simple error handler for Supabase errors
const handlePasswordResetError = (error) => {
  if (error.message.includes("password should be at least")) {
    return "Password must be at least 8 characters long.";
  }
  if (error.message.includes("invalid") || error.message.includes("expired")) {
    return "This password reset link is invalid or has expired. Please request a new one.";
  }
  return error.message || "An error occurred. Please try again.";
};

const ResetPasswordPage = () => {
  const [password, setPassword] = useState("");
  const [confirmPassword, setConfirmPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const [showConfirmPassword, setShowConfirmPassword] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [isRecoveryMode, setIsRecoveryMode] = useState(false);
  const [checkingSession, setCheckingSession] = useState(true);
  const { toast } = useToast();
  const navigate = useNavigate();

  useEffect(() => {
    const init = async () => {
      // Grab tokens from URL hash
      const hashParams = new URLSearchParams(window.location.hash.substring(1));
      const access_token = hashParams.get("access_token");
      const refresh_token = hashParams.get("refresh_token");
      const type = hashParams.get("type");

      console.log("Access token: ", access_token);
      console.log("Refresh token: ", refresh_token);
      console.log("Type: ", type);

      if (access_token && refresh_token && type === "recovery") {
        // Manually set session (important for mobile/in-app browsers)
        const { data, error } = await supabase.auth.setSession({
          access_token,
          refresh_token,
        });

        if (error) {
          console.error("Error setting session:", error);
        } else {
          setIsRecoveryMode(true);
        }
        // Clear hash from URL after processing (optional, cleaner URL)
        window.history.replaceState(
          {},
          document.title,
          window.location.pathname
        );
      } else {
        // Fallback: check if session already exists
        const {
          data: { session },
        } = await supabase.auth.getSession();

        if (session) {
          setIsRecoveryMode(true);
        }
      }

      setCheckingSession(false);

      // Listen for Supabase auth events
      const {
        data: { subscription },
      } = supabase.auth.onAuthStateChange((event) => {
        if (event === "PASSWORD_RECOVERY") {
          setIsRecoveryMode(true);
          setCheckingSession(false);
        } else if (event === "USER_UPDATED") {
          toast({
            title: "Password updated successfully!",
            description: "You can now log in with your new password.",
            variant: "success",
          });
          navigate("/login");
        }
      });

      return () => subscription.unsubscribe();
    };

    init();
  }, [navigate, toast]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");

    if (password !== confirmPassword) {
      setError("Passwords do not match.");
      toast({
        title: "Error",
        description: "Passwords do not match.",
        variant: "destructive",
      });
      return;
    }

    if (password.length < 8) {
      setError("Password must be at least 8 characters long.");
      toast({
        title: "Error",
        description: "Password must be at least 8 characters long.",
        variant: "destructive",
      });
      return;
    }

    setLoading(true);
    try {
      const { error: updateError } = await supabase.auth.updateUser({
        password,
      });

      if (updateError) throw updateError;

      // Success handled by USER_UPDATED event
    } catch (err) {
      const friendlyError = handlePasswordResetError(err);
      setError(friendlyError);
      toast({
        title: "Error",
        description: friendlyError,
        variant: "destructive",
      });
      if (
        friendlyError.includes("invalid") ||
        friendlyError.includes("expired")
      ) {
        setTimeout(() => navigate("/forgot-password"), 3000);
      }
    } finally {
      setLoading(false);
    }
  };

  const renderContent = () => {
    if (checkingSession) {
      return (
        <div className='flex justify-center items-center p-8'>
          <Loader2 className='h-8 w-8 animate-spin' />
        </div>
      );
    }

    if (!isRecoveryMode) {
      return (
        <div className='text-center p-4 rounded-lg bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700/20'>
          <AlertCircle className='mx-auto h-12 w-12 text-destructive mb-4' />
          <h3 className='text-xl font-semibold text-destructive'>Error</h3>
          <p className='text-muted-foreground mt-2'>
            {error || "This password reset link is invalid."}
          </p>
          <Button
            onClick={() => navigate("/forgot-password")}
            className='mt-6 w-full'
          >
            Request New Link
          </Button>
        </div>
      );
    }

    return (
      <form onSubmit={handleSubmit} className='space-y-6'>
        <div className='space-y-2'>
          <Label htmlFor='password'>New Password</Label>
          <div className='relative group'>
            <Lock className='absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors' />
            <Input
              id='password'
              type={showPassword ? "text" : "password"}
              placeholder='At least 8 characters'
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              disabled={loading}
              className='pl-10 pr-10 h-10 text-sm'
            />
            <button
              type='button'
              onClick={() => setShowPassword(!showPassword)}
              className='absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary transition-colors'
            >
              {showPassword ? (
                <EyeOff className='h-4 w-4' />
              ) : (
                <Eye className='h-4 w-4' />
              )}
            </button>
          </div>
        </div>
        <div className='space-y-2'>
          <Label htmlFor='confirmPassword'>Confirm New Password</Label>
          <div className='relative group'>
            <Lock className='absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors' />
            <Input
              id='confirmPassword'
              type={showConfirmPassword ? "text" : "password"}
              placeholder='Re-enter your password'
              value={confirmPassword}
              onChange={(e) => setConfirmPassword(e.target.value)}
              required
              disabled={loading}
              className='pl-10 pr-10 h-10 text-sm'
            />
            <button
              type='button'
              onClick={() => setShowConfirmPassword(!showConfirmPassword)}
              className='absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary transition-colors'
            >
              {showConfirmPassword ? (
                <EyeOff className='h-4 w-4' />
              ) : (
                <Eye className='h-4 w-4' />
              )}
            </button>
          </div>
        </div>
        {error && (
          <p className='text-xs text-destructive flex items-center'>
            <AlertCircle className='h-3 w-3 mr-1' />
            {error}
          </p>
        )}
        <Button type='submit' className='w-full' disabled={loading}>
          {loading ? (
            <>
              <Loader2 className='mr-2 h-4 w-4 animate-spin' />
              Updating...
            </>
          ) : (
            "Update Password"
          )}
        </Button>
      </form>
    );
  };

  return (
    <>
      <Seo
        title='Set New Password'
        description='Create a new password for your account.'
        url='/reset-password'
        keywords='new password, reset password, account security'
      />
      <div className='min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-primary/5 to-secondary/20 relative'>
        <div className='absolute top-4 right-4'>
          <ThemeToggle />
        </div>
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5 }}
          className='w-full max-w-md z-10'
        >
          <Card className='backdrop-blur-xl bg-white/80 dark:bg-gray-900/80 border border-white/20 dark:border-gray-700/20 shadow-2xl'>
            <CardHeader className='text-center'>
              <CardTitle className='text-2xl md:text-3xl font-bold'>
                Set New Password
              </CardTitle>
              <CardDescription className='text-base'>
                Create a new password for your account.
              </CardDescription>
            </CardHeader>
            <CardContent>{renderContent()}</CardContent>
          </Card>
        </motion.div>
      </div>
    </>
  );
};

export default ResetPasswordPage;

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.