feat(auth): implement forgot-password flow with Firebase Auth
This commit is contained in:
240
apps/web/src/features/auth/ForgotPassword.tsx
Normal file
240
apps/web/src/features/auth/ForgotPassword.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2, AlertCircle, CheckCircle, ArrowLeft } from "lucide-react";
|
||||
import loginHero from "../../assets/login-hero.png";
|
||||
import logo from "../../assets/logo.png";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Input } from "../../common/components/ui/input";
|
||||
import { Button } from "../../common/components/ui/button";
|
||||
import { sendPasswordReset } from "../../services/authService";
|
||||
import { FirebaseError } from "firebase/app";
|
||||
|
||||
/**
|
||||
* ForgotPassword Component
|
||||
* Allows users to request a password reset by entering their email address.
|
||||
* Firebase will send a reset link to the provided email.
|
||||
*/
|
||||
|
||||
const ForgotPassword: React.FC = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [isFormValid, setIsFormValid] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(validateEmail(email));
|
||||
}, [email]);
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (value: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
};
|
||||
|
||||
// Handle email input change
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEmail(value);
|
||||
|
||||
if (value.trim() && !validateEmail(value)) {
|
||||
setEmailError("Please enter a valid email address");
|
||||
} else {
|
||||
setEmailError("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate before submission
|
||||
if (!isFormValid) {
|
||||
if (!validateEmail(email)) {
|
||||
setEmailError("Please enter a valid email address");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await sendPasswordReset(email);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setEmail("");
|
||||
// Automatically redirect after 5 seconds
|
||||
setTimeout(() => {
|
||||
navigate("/login");
|
||||
}, 5000);
|
||||
} else {
|
||||
setError(result.error || "Failed to send reset email. Please try again.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
let message = "Failed to send reset email. Please try again.";
|
||||
|
||||
if (err instanceof FirebaseError) {
|
||||
message = err.message;
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50/50">
|
||||
{/* Left Side: Hero Image (Hidden on Mobile) */}
|
||||
<div className="hidden lg:flex lg:w-3/5 xl:w-[65%] relative overflow-hidden">
|
||||
<img
|
||||
src={loginHero}
|
||||
alt="Modern workspace"
|
||||
className="absolute inset-0 w-full h-full object-cover transform scale-105 hover:scale-100 transition-transform duration-10000 ease-in-out"
|
||||
/>
|
||||
{/* Cinematic Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-black/20 to-transparent z-10" />
|
||||
<div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
|
||||
|
||||
{/* Top Left Logo */}
|
||||
<div className="absolute top-12 left-12 z-20">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Krow Logo"
|
||||
className="h-10 w-auto brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-12 left-12 right-12 text-white z-10">
|
||||
<h1 className="text-5xl font-bold mb-4 tracking-tight leading-tight">
|
||||
Streamline your workforce <br /> with{" "}
|
||||
<span className="text-secondary underline decoration-4 underline-offset-8">
|
||||
KROW
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg text-slate-100/90 max-w-lg leading-relaxed">
|
||||
The all-in-one platform for managing staff, orders, and professional
|
||||
relationships with precision and ease.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Forgot Password Form */}
|
||||
<div className="w-full lg:w-2/5 xl:w-[35%] flex flex-col justify-center items-center p-8 sm:p-12 md:p-16 lg:p-20 bg-white border-l border-slate-200 z-10">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header with back button */}
|
||||
<div className="flex flex-col items-center lg:items-start space-y-4">
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="flex items-center text-sm font-semibold text-primary hover:text-primary/80 transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Login
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={logo}
|
||||
alt="Krow Logo"
|
||||
className="h-10 w-auto lg:hidden mb-2"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-primary-text">
|
||||
Reset Password
|
||||
</h2>
|
||||
<p className="text-secondary-text font-medium">
|
||||
Enter your email to receive a password reset link
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="flex flex-col items-center p-6 text-sm text-green-700 bg-green-50 border border-green-200 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
||||
<CheckCircle className="w-5 h-5 mb-2 flex-shrink-0" />
|
||||
<span className="font-semibold mb-1">Check your email</span>
|
||||
<span className="text-xs text-center text-green-600">
|
||||
We've sent you a link to reset your password. Please check your email and follow the link.
|
||||
You'll be redirected to login in a moment...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && !success && (
|
||||
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
||||
<AlertCircle className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{!success && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-semibold">
|
||||
Work Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isLoading}
|
||||
required
|
||||
aria-describedby={emailError ? "email-error" : undefined}
|
||||
className={
|
||||
emailError
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: "mt-2"
|
||||
}
|
||||
/>
|
||||
{emailError && (
|
||||
<p
|
||||
id="email-error"
|
||||
className="text-xs text-destructive font-medium"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !isFormValid}
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
|
||||
Sending Reset Link...
|
||||
</>
|
||||
) : (
|
||||
"Send Reset Link"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="p-4 bg-slate-50/80 rounded-xl border border-slate-100 text-xs text-secondary-text">
|
||||
<p className="font-semibold mb-2">Didn't receive the email?</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Check your spam/junk folder</li>
|
||||
<li>Make sure the email is correct</li>
|
||||
<li>Reset links expire after 1 hour</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -247,8 +247,8 @@ const Login: React.FC = () => {
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/forgot-password")}
|
||||
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
|
||||
onClick={() => console.log("TODO: Implement forgot password")}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './features/auth/Login';
|
||||
import ForgotPassword from './features/auth/ForgotPassword';
|
||||
import Dashboard from './features/dashboard/Dashboard';
|
||||
|
||||
|
||||
@@ -20,6 +21,12 @@ const AppRoutes: React.FC = () => {
|
||||
<Login />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
element={
|
||||
<ForgotPassword />
|
||||
}
|
||||
/>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
setPersistence,
|
||||
browserLocalPersistence,
|
||||
sendPasswordResetEmail,
|
||||
confirmPasswordReset,
|
||||
} from "firebase/auth";
|
||||
import type { User, AuthError } from "firebase/auth";
|
||||
import { app} from "../features/auth/firebase"
|
||||
@@ -70,6 +71,20 @@ export const sendPasswordReset = async (email: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset password with code and new password
|
||||
* Used after user clicks the link in the reset email
|
||||
*/
|
||||
export const resetPassword = async (code: string, newPassword: string) => {
|
||||
try {
|
||||
await confirmPasswordReset(auth, code, newPassword);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
return { success: false, error: getAuthErrorMessage(authError.code) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to auth state changes
|
||||
* Returns unsubscribe function
|
||||
@@ -92,15 +107,18 @@ const getAuthErrorMessage = (errorCode: string): string => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
"auth/invalid-email": "Invalid email address format.",
|
||||
"auth/user-disabled": "This user account has been disabled.",
|
||||
"auth/user-not-found": "Invalid email or password.",
|
||||
"auth/user-not-found": "No account found with this email address.",
|
||||
"auth/wrong-password": "Invalid email or password.",
|
||||
"auth/invalid-credential": "Invalid email or password.",
|
||||
"auth/too-many-requests": "Too many login attempts. Please try again later.",
|
||||
"auth/operation-not-allowed": "Login is currently disabled. Please try again later.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection.",
|
||||
"auth/invalid-action-code": "This password reset link is invalid or has expired.",
|
||||
"auth/expired-action-code": "This password reset link has expired. Please request a new one.",
|
||||
"auth/weak-password": "Password is too weak. Please choose a stronger password.",
|
||||
};
|
||||
|
||||
return errorMessages[errorCode] || "An error occurred during login. Please try again.";
|
||||
return errorMessages[errorCode] || "An error occurred. Please try again.";
|
||||
};
|
||||
export { app };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user