Enhance auth pages

Extend registration, verify email, and forgot password flows with richer UI components, client-side validation, and placeholder behaviors. Add PasswordStrengthMeter and BannerAlert components, integrate updated styling, and align forms with new spec (registration, email verification, and password reset). Update ForgotPassword, Register, and VerifyEmail pages to reflect improved UX and mock flows.

X-Lovable-Edit-ID: edt-140074ae-8fad-4c73-81c7-dd8a6399b195
This commit is contained in:
gpt-engineer-app[bot]
2026-01-06 14:59:48 +00:00
5 changed files with 496 additions and 43 deletions
+43
View File
@@ -0,0 +1,43 @@
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface BannerAlertProps {
type: "info" | "success" | "warning" | "error";
title?: string;
message: string;
className?: string;
}
const icons = {
info: Info,
success: CheckCircle,
warning: AlertCircle,
error: XCircle,
};
const styles = {
info: "bg-accent/10 border-accent/20 text-accent",
success: "bg-success/10 border-success/20 text-success",
warning: "bg-warning/10 border-warning/20 text-warning",
error: "bg-destructive/10 border-destructive/20 text-destructive",
};
export function BannerAlert({ type, title, message, className }: BannerAlertProps) {
const Icon = icons[type];
return (
<div
className={cn(
"flex gap-3 p-4 rounded-lg border",
styles[type],
className
)}
>
<Icon className="w-5 h-5 shrink-0 mt-0.5" />
<div className="space-y-1">
{title && <p className="font-medium text-sm">{title}</p>}
<p className="text-sm opacity-90">{message}</p>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useMemo } from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface PasswordStrengthMeterProps {
password: string;
}
interface PasswordRequirement {
label: string;
test: (password: string) => boolean;
}
const requirements: PasswordRequirement[] = [
{ label: "At least 8 characters", test: (p) => p.length >= 8 },
{ label: "Contains uppercase letter", test: (p) => /[A-Z]/.test(p) },
{ label: "Contains lowercase letter", test: (p) => /[a-z]/.test(p) },
{ label: "Contains number", test: (p) => /\d/.test(p) },
{ label: "Contains special character", test: (p) => /[!@#$%^&*(),.?":{}|<>]/.test(p) },
];
export function PasswordStrengthMeter({ password }: PasswordStrengthMeterProps) {
const results = useMemo(() => {
return requirements.map((req) => ({
...req,
met: req.test(password),
}));
}, [password]);
const strength = useMemo(() => {
const metCount = results.filter((r) => r.met).length;
if (metCount === 0) return { level: 0, label: "", color: "bg-muted" };
if (metCount <= 2) return { level: 1, label: "Weak", color: "bg-destructive" };
if (metCount <= 3) return { level: 2, label: "Fair", color: "bg-warning" };
if (metCount <= 4) return { level: 3, label: "Good", color: "bg-accent" };
return { level: 4, label: "Strong", color: "bg-success" };
}, [results]);
if (!password) return null;
return (
<div className="space-y-3">
{/* Strength bar */}
<div className="space-y-1.5">
<div className="flex gap-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
level <= strength.level ? strength.color : "bg-muted"
)}
/>
))}
</div>
{strength.label && (
<p className={cn(
"text-xs font-medium",
strength.level <= 1 && "text-destructive",
strength.level === 2 && "text-warning",
strength.level === 3 && "text-accent",
strength.level === 4 && "text-success"
)}>
{strength.label}
</p>
)}
</div>
{/* Requirements checklist */}
<ul className="space-y-1">
{results.map((req, index) => (
<li
key={index}
className={cn(
"flex items-center gap-2 text-xs transition-colors",
req.met ? "text-success" : "text-muted-foreground"
)}
>
{req.met ? (
<Check className="w-3.5 h-3.5" />
) : (
<X className="w-3.5 h-3.5" />
)}
{req.label}
</li>
))}
</ul>
</div>
);
}
export function isPasswordValid(password: string): boolean {
return requirements.every((req) => req.test(password));
}
+47 -11
View File
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Mail, ArrowLeft, ArrowRight } from "lucide-react"; import { Mail, ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { BannerAlert } from "@/components/auth/BannerAlert";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -13,12 +14,15 @@ export default function ForgotPasswordPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
// Mock API call - POST /api/auth/forgot-password
setTimeout(() => { setTimeout(() => {
setIsLoading(false); setIsLoading(false);
setIsSubmitted(true); setIsSubmitted(true);
}, 1000); }, 1000);
}; };
// Success state - always show neutral message (don't leak account existence)
if (isSubmitted) { if (isSubmitted) {
return ( return (
<div className="auth-card text-center"> <div className="auth-card text-center">
@@ -30,19 +34,40 @@ export default function ForgotPasswordPage() {
Check your email Check your email
</h1> </h1>
<p className="text-muted-foreground mt-2 mb-6"> <p className="text-muted-foreground mt-2 mb-6">
If an account exists for {email}, you'll receive a password reset link shortly. If an account exists for <span className="font-medium text-foreground">{email}</span>,
you'll receive a password reset link shortly.
</p> </p>
<Link to="/login"> <BannerAlert
<Button variant="outline" className="w-full"> type="info"
<ArrowLeft className="w-4 h-4 mr-2" /> message="For security reasons, we don't confirm whether an account exists. Check your spam folder if you don't see the email."
Back to sign in className="mb-6 text-left"
/>
<div className="space-y-3">
<Link to="/login">
<Button variant="outline" className="w-full">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to sign in
</Button>
</Link>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setIsSubmitted(false);
setEmail("");
}}
>
Try a different email
</Button> </Button>
</Link> </div>
</div> </div>
); );
} }
// Request form
return ( return (
<div className="auth-card"> <div className="auth-card">
<div className="text-center mb-8"> <div className="text-center mb-8">
@@ -50,7 +75,7 @@ export default function ForgotPasswordPage() {
Forgot password? Forgot password?
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Enter your email and we'll send you a reset link No worries, we'll send you reset instructions
</p> </p>
</div> </div>
@@ -67,13 +92,21 @@ export default function ForgotPasswordPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="pl-10" className="pl-10"
required required
autoComplete="email"
/> />
</div> </div>
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button
type="submit"
className="w-full"
disabled={isLoading || !email.trim()}
>
{isLoading ? ( {isLoading ? (
"Sending..." <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : ( ) : (
<> <>
Send reset link Send reset link
@@ -84,7 +117,10 @@ export default function ForgotPasswordPage() {
</form> </form>
<p className="text-center text-sm text-muted-foreground mt-6"> <p className="text-center text-sm text-muted-foreground mt-6">
<Link to="/login" className="text-accent hover:underline font-medium inline-flex items-center"> <Link
to="/login"
className="text-accent hover:underline font-medium inline-flex items-center"
>
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Back to sign in Back to sign in
</Link> </Link>
+126 -12
View File
@@ -1,9 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Mail, Lock, User, ArrowRight } from "lucide-react"; import { Mail, Lock, User, ArrowRight, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter";
import { BannerAlert } from "@/components/auth/BannerAlert";
type RegistrationState = "form" | "success" | "disabled";
export default function RegisterPage() { export default function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -12,20 +16,114 @@ export default function RegisterPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<RegistrationState>("form");
const passwordsMatch = password === confirmPassword;
const canSubmit =
name.trim() &&
email.trim() &&
isPasswordValid(password) &&
passwordsMatch;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password !== confirmPassword) { setError(null);
if (!passwordsMatch) {
setError("Passwords do not match");
return; return;
} }
if (!isPasswordValid(password)) {
setError("Password does not meet requirements");
return;
}
setIsLoading(true); setIsLoading(true);
// Mock registration - will be replaced with actual auth
// Mock registration - will be replaced with actual API call
// POST /api/auth/register
setTimeout(() => { setTimeout(() => {
setIsLoading(false); setIsLoading(false);
navigate("/verify-email");
// Simulate different responses
const mockResponse = "success" as RegistrationState | "error";
if (mockResponse === "disabled") {
setState("disabled");
} else if (mockResponse === "error") {
setError("An error occurred. Please try again.");
} else {
setState("success");
}
}, 1000); }, 1000);
}; };
// Registration disabled state
if (state === "disabled") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-warning/10 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-warning" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Registration unavailable
</h1>
<p className="text-muted-foreground mt-2 mb-6">
New account registration is currently invite-only. Please contact your administrator for an invitation.
</p>
<Link to="/login">
<Button variant="outline" className="w-full">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to sign in
</Button>
</Link>
</div>
);
}
// Success state - email sent
if (state === "success") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-6">
<Mail className="w-8 h-8 text-success" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Check your email
</h1>
<p className="text-muted-foreground mt-2 mb-6">
We've sent a verification link to <span className="font-medium text-foreground">{email}</span>.
Click the link to verify your account and get started.
</p>
<div className="space-y-3">
<Link to="/login">
<Button className="w-full">
Continue to sign in
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
<p className="text-sm text-muted-foreground mt-6">
Didn't receive the email?{" "}
<button
onClick={() => setState("form")}
className="text-accent hover:underline font-medium"
>
Try again
</button>
</p>
</div>
);
}
// Registration form
return ( return (
<div className="auth-card"> <div className="auth-card">
<div className="text-center mb-8"> <div className="text-center mb-8">
@@ -37,9 +135,17 @@ export default function RegisterPage() {
</p> </p>
</div> </div>
{error && (
<BannerAlert
type="error"
message={error}
className="mb-6"
/>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Full name</Label> <Label htmlFor="name">Display name</Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -50,6 +156,7 @@ export default function RegisterPage() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="pl-10" className="pl-10"
required required
autoComplete="name"
/> />
</div> </div>
</div> </div>
@@ -66,6 +173,7 @@ export default function RegisterPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="pl-10" className="pl-10"
required required
autoComplete="email"
/> />
</div> </div>
</div> </div>
@@ -82,12 +190,10 @@ export default function RegisterPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10" className="pl-10"
required required
minLength={8} autoComplete="new-password"
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <PasswordStrengthMeter password={password} />
Must be at least 8 characters
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -102,11 +208,19 @@ export default function RegisterPage() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10" className="pl-10"
required required
autoComplete="new-password"
/> />
</div> </div>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-destructive">Passwords do not match</p>
)}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button
type="submit"
className="w-full"
disabled={isLoading || !canSubmit}
>
{isLoading ? ( {isLoading ? (
"Creating account..." "Creating account..."
) : ( ) : (
@@ -127,11 +241,11 @@ export default function RegisterPage() {
<p className="text-center text-xs text-muted-foreground mt-4"> <p className="text-center text-xs text-muted-foreground mt-4">
By creating an account, you agree to our{" "} By creating an account, you agree to our{" "}
<Link to="/terms" className="underline"> <Link to="/terms" className="underline hover:text-foreground">
Terms of Service Terms of Service
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link to="/privacy" className="underline"> <Link to="/privacy" className="underline hover:text-foreground">
Privacy Policy Privacy Policy
</Link> </Link>
</p> </p>
+186 -20
View File
@@ -1,32 +1,198 @@
import { Mail, RefreshCw } from "lucide-react"; import { useState, useEffect } from "react";
import { useSearchParams, Link } from "react-router-dom";
import { Mail, CheckCircle, XCircle, Loader2, ArrowRight, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BannerAlert } from "@/components/auth/BannerAlert";
type VerificationState = "verifying" | "success" | "error" | "resend";
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [state, setState] = useState<VerificationState>(token ? "verifying" : "resend");
const [errorMessage, setErrorMessage] = useState<string>("");
const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
useEffect(() => {
if (token && state === "verifying") {
verifyToken(token);
}
}, [token, state]);
const verifyToken = async (verificationToken: string) => {
// Mock verification - POST /api/auth/verify-email?token=...
setTimeout(() => {
// Simulate different responses
const mockSuccess = Math.random() > 0.3; // 70% success rate for demo
if (mockSuccess) {
setState("success");
} else {
setState("error");
setErrorMessage("This verification link has expired or is invalid.");
}
}, 1500);
};
const handleResendVerification = async (e: React.FormEvent) => {
e.preventDefault();
setIsResending(true);
setResendSuccess(false);
// Mock resend - POST /api/auth/request-email-verification
setTimeout(() => {
setIsResending(false);
setResendSuccess(true);
}, 1000);
};
// Loading / Verifying state
if (state === "verifying") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-6">
<Loader2 className="w-8 h-8 text-accent animate-spin" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Verifying your email
</h1>
<p className="text-muted-foreground mt-2">
Please wait while we confirm your email address...
</p>
</div>
);
}
// Success state
if (state === "success") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Email verified
</h1>
<p className="text-muted-foreground mt-2 mb-6">
Your email has been successfully verified. You can now sign in to your account.
</p>
<Link to="/login">
<Button className="w-full">
Continue to sign in
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
);
}
// Error state
if (state === "error") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
<XCircle className="w-8 h-8 text-destructive" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Verification failed
</h1>
<p className="text-muted-foreground mt-2 mb-6">
{errorMessage}
</p>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
onClick={() => setState("resend")}
>
<RefreshCw className="w-4 h-4 mr-2" />
Resend verification email
</Button>
<Link to="/login">
<Button variant="ghost" className="w-full">
Back to sign in
</Button>
</Link>
</div>
</div>
);
}
// Resend verification form
return ( return (
<div className="auth-card text-center"> <div className="auth-card">
<div className="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-6"> <div className="text-center mb-8">
<Mail className="w-8 h-8 text-accent" /> <div className="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-6">
<Mail className="w-8 h-8 text-accent" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Resend verification
</h1>
<p className="text-muted-foreground mt-2">
Enter your email to receive a new verification link
</p>
</div> </div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight"> {resendSuccess && (
Check your email <BannerAlert
</h1> type="success"
<p className="text-muted-foreground mt-2 mb-6"> message="If an account exists with this email, you'll receive a verification link shortly."
We've sent a verification link to your email address. Click the link to verify your account. className="mb-6"
</p> />
)}
<div className="space-y-3"> <form onSubmit={handleResendVerification} className="space-y-4">
<Button variant="outline" className="w-full"> <div className="space-y-2">
<RefreshCw className="w-4 h-4 mr-2" /> <Label htmlFor="email">Email</Label>
Resend verification email <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={resendEmail}
onChange={(e) => setResendEmail(e.target.value)}
className="pl-10"
required
autoComplete="email"
/>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isResending || !resendEmail.trim()}
>
{isResending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
Send verification link
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button> </Button>
</div> </form>
<p className="text-sm text-muted-foreground mt-6"> <p className="text-center text-sm text-muted-foreground mt-6">
Wrong email?{" "} <Link to="/login" className="text-accent hover:underline font-medium">
<Link to="/register" className="text-accent hover:underline font-medium"> Back to sign in
Go back
</Link> </Link>
</p> </p>
</div> </div>