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>
|
</Label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => navigate("/forgot-password")}
|
||||||
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
|
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
|
||||||
onClick={() => console.log("TODO: Implement forgot password")}
|
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Login from './features/auth/Login';
|
import Login from './features/auth/Login';
|
||||||
|
import ForgotPassword from './features/auth/ForgotPassword';
|
||||||
import Dashboard from './features/dashboard/Dashboard';
|
import Dashboard from './features/dashboard/Dashboard';
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +21,12 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Login />
|
<Login />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/forgot-password"
|
||||||
|
element={
|
||||||
|
<ForgotPassword />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
setPersistence,
|
setPersistence,
|
||||||
browserLocalPersistence,
|
browserLocalPersistence,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
|
confirmPasswordReset,
|
||||||
} from "firebase/auth";
|
} from "firebase/auth";
|
||||||
import type { User, AuthError } from "firebase/auth";
|
import type { User, AuthError } from "firebase/auth";
|
||||||
import { app} from "../features/auth/firebase"
|
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
|
* Subscribe to auth state changes
|
||||||
* Returns unsubscribe function
|
* Returns unsubscribe function
|
||||||
@@ -92,15 +107,18 @@ const getAuthErrorMessage = (errorCode: string): string => {
|
|||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
"auth/invalid-email": "Invalid email address format.",
|
"auth/invalid-email": "Invalid email address format.",
|
||||||
"auth/user-disabled": "This user account has been disabled.",
|
"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/wrong-password": "Invalid email or password.",
|
||||||
"auth/invalid-credential": "Invalid email or password.",
|
"auth/invalid-credential": "Invalid email or password.",
|
||||||
"auth/too-many-requests": "Too many login attempts. Please try again later.",
|
"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/operation-not-allowed": "Login is currently disabled. Please try again later.",
|
||||||
"auth/network-request-failed": "Network error. Please check your connection.",
|
"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 };
|
export { app };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user