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:
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
<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">
|
<Link to="/login">
|
||||||
<Button variant="outline" className="w-full">
|
<Button variant="outline" className="w-full">
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setEmail("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try a different email
|
||||||
|
</Button>
|
||||||
|
</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
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="auth-card text-center">
|
<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">
|
<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" />
|
<Mail className="w-8 h-8 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
Check your email
|
Resend verification
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2 mb-6">
|
<p className="text-muted-foreground mt-2">
|
||||||
We've sent a verification link to your email address. Click the link to verify your account.
|
Enter your email to receive a new verification link
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mt-6">
|
{resendSuccess && (
|
||||||
Wrong email?{" "}
|
<BannerAlert
|
||||||
<Link to="/register" className="text-accent hover:underline font-medium">
|
type="success"
|
||||||
Go back
|
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>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user