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));
}
+42 -6
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BannerAlert } from "@/components/auth/BannerAlert";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -13,12 +14,15 @@ export default function ForgotPasswordPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
// Mock API call - POST /api/auth/forgot-password
setTimeout(() => {
setIsLoading(false);
setIsSubmitted(true);
}, 1000);
};
// Success state - always show neutral message (don't leak account existence)
if (isSubmitted) {
return (
<div className="auth-card text-center">
@@ -30,19 +34,40 @@ export default function ForgotPasswordPage() {
Check your email
</h1>
<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>
<BannerAlert
type="info"
message="For security reasons, we don't confirm whether an account exists. Check your spam folder if you don't see the email."
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>
</div>
</div>
);
}
// Request form
return (
<div className="auth-card">
<div className="text-center mb-8">
@@ -50,7 +75,7 @@ export default function ForgotPasswordPage() {
Forgot password?
</h1>
<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>
</div>
@@ -67,13 +92,21 @@ export default function ForgotPasswordPage() {
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
autoComplete="email"
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
<Button
type="submit"
className="w-full"
disabled={isLoading || !email.trim()}
>
{isLoading ? (
"Sending..."
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
Send reset link
@@ -84,7 +117,10 @@ export default function ForgotPasswordPage() {
</form>
<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" />
Back to sign in
</Link>
+126 -12
View File
@@ -1,9 +1,13 @@
import { useState } from "react";
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 { Input } from "@/components/ui/input";
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() {
const navigate = useNavigate();
@@ -12,20 +16,114 @@ export default function RegisterPage() {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
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) => {
e.preventDefault();
if (password !== confirmPassword) {
setError(null);
if (!passwordsMatch) {
setError("Passwords do not match");
return;
}
if (!isPasswordValid(password)) {
setError("Password does not meet requirements");
return;
}
setIsLoading(true);
// Mock registration - will be replaced with actual auth
// Mock registration - will be replaced with actual API call
// POST /api/auth/register
setTimeout(() => {
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);
};
// 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 (
<div className="auth-card">
<div className="text-center mb-8">
@@ -37,9 +135,17 @@ export default function RegisterPage() {
</p>
</div>
{error && (
<BannerAlert
type="error"
message={error}
className="mb-6"
/>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full name</Label>
<Label htmlFor="name">Display name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -50,6 +156,7 @@ export default function RegisterPage() {
onChange={(e) => setName(e.target.value)}
className="pl-10"
required
autoComplete="name"
/>
</div>
</div>
@@ -66,6 +173,7 @@ export default function RegisterPage() {
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
autoComplete="email"
/>
</div>
</div>
@@ -82,12 +190,10 @@ export default function RegisterPage() {
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
<PasswordStrengthMeter password={password} />
</div>
<div className="space-y-2">
@@ -102,11 +208,19 @@ export default function RegisterPage() {
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
required
autoComplete="new-password"
/>
</div>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-destructive">Passwords do not match</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
<Button
type="submit"
className="w-full"
disabled={isLoading || !canSubmit}
>
{isLoading ? (
"Creating account..."
) : (
@@ -127,11 +241,11 @@ export default function RegisterPage() {
<p className="text-center text-xs text-muted-foreground mt-4">
By creating an account, you agree to our{" "}
<Link to="/terms" className="underline">
<Link to="/terms" className="underline hover:text-foreground">
Terms of Service
</Link>{" "}
and{" "}
<Link to="/privacy" className="underline">
<Link to="/privacy" className="underline hover:text-foreground">
Privacy Policy
</Link>
</p>
+181 -15
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 { 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() {
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 (
<div className="auth-card">
<div className="text-center mb-8">
<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">
Check your email
Resend verification
</h1>
<p className="text-muted-foreground mt-2 mb-6">
We've sent a verification link to your email address. Click the link to verify your account.
<p className="text-muted-foreground mt-2">
Enter your email to receive a new verification link
</p>
<div className="space-y-3">
<Button variant="outline" className="w-full">
<RefreshCw className="w-4 h-4 mr-2" />
Resend verification email
</Button>
</div>
<p className="text-sm text-muted-foreground mt-6">
Wrong email?{" "}
<Link to="/register" className="text-accent hover:underline font-medium">
Go back
{resendSuccess && (
<BannerAlert
type="success"
message="If an account exists with this email, you'll receive a verification link shortly."
className="mb-6"
/>
)}
<form onSubmit={handleResendVerification} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<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>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
<Link to="/login" className="text-accent hover:underline font-medium">
Back to sign in
</Link>
</p>
</div>