feat(ui): premium auth pages polish
AuthLayout: - Full-screen gradient background with animated pulse blobs - Glass-morphism card (bg-card/80, backdrop-blur-md, shadow-2xl) - New animate-auth-enter animation (fade + scale + translateY) OAuth buttons: real provider icons (Google SVG, GitHub, Discord) Password strength: 4-segment bar, color-coded labels, checklist icons Login: Checkbox component for Remember Me, animated error alerts Register: migrated to AuthInput, username check with spinner/icons Verification notice: Mail icon, success-tinted circle, AuthButton Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cfe1fce255
commit
9f108e2430
7 changed files with 371 additions and 258 deletions
|
|
@ -28,13 +28,22 @@ export function AuthLayout({
|
|||
role="main"
|
||||
aria-label="Page d'authentification"
|
||||
>
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-cyan/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-magenta/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
{/* Background gradient */}
|
||||
<div className="fixed inset-0 bg-background">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5" />
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div
|
||||
className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/5 rounded-full blur-3xl animate-pulse"
|
||||
style={{ animationDelay: '2s' }}
|
||||
/>
|
||||
{/* Subtle secondary accent blob */}
|
||||
<div
|
||||
className="absolute top-2/3 left-1/2 w-72 h-72 bg-secondary/5 rounded-full blur-3xl animate-pulse"
|
||||
style={{ animationDelay: '4s' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full mx-auto space-y-8 relative z-10 animate-fade-in">
|
||||
<div className="max-w-md w-full mx-auto space-y-8 relative z-10 animate-auth-enter">
|
||||
{/* Logo and Title */}
|
||||
<header className="text-center">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
|
|
@ -42,7 +51,7 @@ export function AuthLayout({
|
|||
className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center shadow-button-primary-glow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-background font-bold text-2xl">V</span>
|
||||
<span className="text-primary-foreground font-bold text-2xl">V</span>
|
||||
</div>
|
||||
<span className="ml-3 font-bold text-3xl text-foreground">Veza</span>
|
||||
</div>
|
||||
|
|
@ -59,11 +68,11 @@ export function AuthLayout({
|
|||
)}
|
||||
</header>
|
||||
|
||||
{/* Content Card */}
|
||||
{/* Content Card — glass effect */}
|
||||
<Card
|
||||
variant="surface"
|
||||
padding="lg"
|
||||
className="w-full"
|
||||
className="w-full bg-card/80 backdrop-blur-md border-border/50 shadow-2xl"
|
||||
aria-labelledby="auth-form-title"
|
||||
>
|
||||
{children}
|
||||
|
|
@ -79,7 +88,7 @@ export function AuthLayout({
|
|||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background rounded"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,72 @@
|
|||
import { AuthButton } from './AuthButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
interface OAuthButtonProps {
|
||||
provider: 'google' | 'github' | 'discord';
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OAuthButton({ provider, onClick }: OAuthButtonProps) {
|
||||
const labels = {
|
||||
google: 'Continuer avec Google',
|
||||
github: 'Continuer avec GitHub',
|
||||
discord: 'Continuer avec Discord',
|
||||
};
|
||||
const providerConfig = {
|
||||
google: {
|
||||
label: 'Google',
|
||||
ariaLabel: 'Se connecter avec Google',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
ariaLabel: 'Se connecter avec GitHub',
|
||||
icon: <Github className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
ariaLabel: 'Se connecter avec Discord',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ariaLabels = {
|
||||
google: 'Se connecter avec Google',
|
||||
github: 'Se connecter avec GitHub',
|
||||
discord: 'Se connecter avec Discord',
|
||||
};
|
||||
export function OAuthButton({ provider, onClick, className }: OAuthButtonProps) {
|
||||
const config = providerConfig[provider];
|
||||
|
||||
return (
|
||||
<AuthButton
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabels[provider]}
|
||||
onClick={onClick}
|
||||
aria-label={config.ariaLabel}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-3 px-4 py-2.5 rounded-lg',
|
||||
'bg-muted/50 border border-border text-foreground',
|
||||
'hover:bg-muted hover:border-border/80 transition-all duration-[var(--duration-fast)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 focus:ring-offset-background',
|
||||
'text-sm font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{labels[provider]}
|
||||
</AuthButton>
|
||||
{config.icon}
|
||||
<span>{config.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +1,138 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
interface PasswordStrengthIndicatorProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface StrengthResult {
|
||||
level: number;
|
||||
label: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
requirements: Array<{ text: string; met: boolean }>;
|
||||
}
|
||||
|
||||
function getStrength(pwd: string): StrengthResult {
|
||||
const requirements: Array<{ text: string; met: boolean }> = [];
|
||||
let strength = 0;
|
||||
|
||||
// Minimum 12 caractères (requis)
|
||||
const hasLength = pwd.length >= 12;
|
||||
if (hasLength) strength++;
|
||||
requirements.push({
|
||||
text: `Au moins 12 caractères (${pwd.length}/12)`,
|
||||
met: hasLength,
|
||||
});
|
||||
|
||||
// Majuscule et minuscule
|
||||
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
|
||||
if (hasCase) strength++;
|
||||
requirements.push({
|
||||
text: 'Majuscule et minuscule',
|
||||
met: hasCase,
|
||||
});
|
||||
|
||||
// Chiffre
|
||||
const hasDigit = /\d/.test(pwd);
|
||||
if (hasDigit) strength++;
|
||||
requirements.push({
|
||||
text: 'Un chiffre',
|
||||
met: hasDigit,
|
||||
});
|
||||
|
||||
// Caractère spécial
|
||||
const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
|
||||
if (hasSpecial) strength++;
|
||||
requirements.push({
|
||||
text: 'Un caractère spécial (!@#$%^&*...)',
|
||||
met: hasSpecial,
|
||||
});
|
||||
|
||||
let label: string;
|
||||
let color: string;
|
||||
let textColor: string;
|
||||
if (strength <= 1) {
|
||||
label = 'Weak';
|
||||
color = 'bg-destructive';
|
||||
textColor = 'text-destructive';
|
||||
} else if (strength === 2) {
|
||||
label = 'Fair';
|
||||
color = 'bg-warning';
|
||||
textColor = 'text-warning';
|
||||
} else if (strength === 3) {
|
||||
label = 'Good';
|
||||
color = 'bg-warning';
|
||||
textColor = 'text-warning';
|
||||
} else {
|
||||
label = 'Strong';
|
||||
color = 'bg-success';
|
||||
textColor = 'text-success';
|
||||
}
|
||||
|
||||
return { level: strength, label, color, textColor, requirements };
|
||||
}
|
||||
|
||||
export function PasswordStrengthIndicator({
|
||||
password,
|
||||
}: PasswordStrengthIndicatorProps) {
|
||||
const getStrength = (
|
||||
pwd: string,
|
||||
): {
|
||||
level: number;
|
||||
label: string;
|
||||
color: string;
|
||||
requirements: string[];
|
||||
} => {
|
||||
const requirements: string[] = [];
|
||||
let strength = 0;
|
||||
|
||||
// Minimum 12 caractères (requis)
|
||||
if (pwd.length >= 12) {
|
||||
strength++;
|
||||
} else {
|
||||
requirements.push(`Au moins 12 caractères (${pwd.length}/12)`);
|
||||
}
|
||||
|
||||
// Majuscule et minuscule
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) {
|
||||
strength++;
|
||||
} else {
|
||||
if (!/[a-z]/.test(pwd)) requirements.push('Une minuscule');
|
||||
if (!/[A-Z]/.test(pwd)) requirements.push('Une majuscule');
|
||||
}
|
||||
|
||||
// Chiffre
|
||||
if (/\d/.test(pwd)) {
|
||||
strength++;
|
||||
} else {
|
||||
requirements.push('Un chiffre');
|
||||
}
|
||||
|
||||
// Caractère spécial
|
||||
if (/[^a-zA-Z\d]/.test(pwd)) {
|
||||
strength++;
|
||||
} else {
|
||||
requirements.push('Un caractère spécial (!@#$%^&*...)');
|
||||
}
|
||||
|
||||
let label: string;
|
||||
let color: string;
|
||||
if (strength <= 1) {
|
||||
label = 'Très faible';
|
||||
color = 'bg-destructive';
|
||||
} else if (strength === 2) {
|
||||
label = 'Faible';
|
||||
color = 'bg-warning';
|
||||
} else if (strength === 3) {
|
||||
label = 'Moyen';
|
||||
color = 'bg-warning';
|
||||
} else if (strength === 4) {
|
||||
label = 'Fort';
|
||||
color = 'bg-success';
|
||||
} else {
|
||||
label = 'Très fort';
|
||||
color = 'bg-success';
|
||||
}
|
||||
|
||||
return { level: strength, label, color, requirements };
|
||||
};
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
const { level, label, color, requirements } = getStrength(password);
|
||||
const width = (level / 4) * 100;
|
||||
const { level, label, color, textColor, requirements } = getStrength(password);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-2 space-y-2"
|
||||
className="mt-3 space-y-2.5 animate-fade-in"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{/* Strength bar with segmented design */}
|
||||
<div>
|
||||
<div
|
||||
className="w-full bg-muted rounded-full h-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={level}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={4}
|
||||
aria-label={`Force du mot de passe: ${label}`}
|
||||
>
|
||||
<div
|
||||
className={`${color} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${width}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex gap-1.5 mb-1.5">
|
||||
{[1, 2, 3, 4].map((segment) => (
|
||||
<div
|
||||
key={segment}
|
||||
className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden"
|
||||
role={segment === 1 ? 'progressbar' : undefined}
|
||||
aria-valuenow={segment === 1 ? level : undefined}
|
||||
aria-valuemin={segment === 1 ? 0 : undefined}
|
||||
aria-valuemax={segment === 1 ? 4 : undefined}
|
||||
aria-label={segment === 1 ? `Force du mot de passe: ${label}` : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-[var(--duration-normal)] ease-out',
|
||||
level >= segment ? color : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1" id="password-strength-label">
|
||||
Force: <span aria-live="polite">{label}</span>
|
||||
<p className={cn('text-xs font-medium transition-colors duration-[var(--duration-fast)]', textColor)}>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
{requirements.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-1">Requis :</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{requirements.map((req, index) => (
|
||||
<li key={index} className="text-destructive">
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements checklist */}
|
||||
<ul className="space-y-1">
|
||||
{requirements.map((req) => (
|
||||
<li
|
||||
key={req.text}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs transition-colors duration-[var(--duration-fast)]',
|
||||
req.met ? 'text-success' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{req.met ? (
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AlertCircle, Loader2, Check, X } from 'lucide-react';
|
||||
import { AuthInput } from '../AuthInput';
|
||||
import { AuthButton } from '../AuthButton';
|
||||
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
|
||||
import type { RegisterFormData } from '../../types';
|
||||
import type { FormErrors } from './useRegisterPage';
|
||||
|
|
@ -45,123 +44,116 @@ export function RegisterPageForm({
|
|||
>
|
||||
{error && (
|
||||
<div
|
||||
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded-lg"
|
||||
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{error.message}
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="register-username">Nom d'utilisateur *</Label>
|
||||
<Input
|
||||
id="register-username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => onFieldChange('username', e.target.value)}
|
||||
onBlur={() => onFieldBlur('username')}
|
||||
required
|
||||
autoComplete="username"
|
||||
aria-invalid={errors.username ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-destructive" role="alert">
|
||||
{errors.username}
|
||||
</p>
|
||||
)}
|
||||
{formData.username.length >= 3 && (
|
||||
<div className="mt-1" aria-live="polite" aria-atomic="true">
|
||||
{checkingUsername ? (
|
||||
<p className="text-xs text-muted-foreground" role="status">
|
||||
<span className="sr-only">Vérification en cours</span>
|
||||
<span aria-hidden>Vérification en cours...</span>
|
||||
</p>
|
||||
) : usernameAvailable === true ? (
|
||||
<p className="text-xs text-success" role="status">
|
||||
<span className="sr-only">Disponible:</span>
|
||||
<span aria-hidden>✓</span> Ce nom d'utilisateur est disponible
|
||||
</p>
|
||||
) : usernameAvailable === false ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
<span className="sr-only">Indisponible:</span>
|
||||
<span aria-hidden>✗</span> Ce nom d'utilisateur est déjà pris
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="register-email">Email *</Label>
|
||||
<Input
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<AuthInput
|
||||
id="register-username"
|
||||
type="text"
|
||||
label="Nom d'utilisateur"
|
||||
value={formData.username}
|
||||
onChange={(e) => onFieldChange('username', e.target.value)}
|
||||
onBlur={() => onFieldBlur('username')}
|
||||
required
|
||||
autoComplete="username"
|
||||
error={errors.username}
|
||||
/>
|
||||
{formData.username.length >= 3 && (
|
||||
<div className="mt-1.5" aria-live="polite" aria-atomic="true">
|
||||
{checkingUsername ? (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status">
|
||||
<span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
|
||||
<span>Vérification...</span>
|
||||
</p>
|
||||
) : usernameAvailable === true ? (
|
||||
<p className="text-xs text-success flex items-center gap-1.5" role="status">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>Ce nom d'utilisateur est disponible</span>
|
||||
</p>
|
||||
) : usernameAvailable === false ? (
|
||||
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
|
||||
<X className="h-3 w-3" />
|
||||
<span>Ce nom d'utilisateur est déjà pris</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<AuthInput
|
||||
id="register-email"
|
||||
type="email"
|
||||
label="Email"
|
||||
value={formData.email}
|
||||
onChange={(e) => onFieldChange('email', e.target.value)}
|
||||
onBlur={() => onFieldBlur('email')}
|
||||
required
|
||||
autoComplete="email"
|
||||
aria-invalid={errors.email ? 'true' : 'false'}
|
||||
error={errors.email}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-destructive" role="alert">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="register-password">Mot de passe *</Label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => onFieldChange('password', e.target.value)}
|
||||
onBlur={() => onFieldBlur('password')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-destructive" role="alert">
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
<PasswordStrengthIndicator password={formData.password} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="register-password_confirm">Confirmer le mot de passe *</Label>
|
||||
<Input
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<AuthInput
|
||||
id="register-password"
|
||||
type="password"
|
||||
label="Mot de passe"
|
||||
value={formData.password}
|
||||
onChange={(e) => onFieldChange('password', e.target.value)}
|
||||
onBlur={() => onFieldBlur('password')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
showPasswordToggle
|
||||
error={errors.password}
|
||||
/>
|
||||
<PasswordStrengthIndicator password={formData.password} />
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<AuthInput
|
||||
id="register-password_confirm"
|
||||
type="password"
|
||||
label="Confirmer le mot de passe"
|
||||
value={formData.password_confirm}
|
||||
onChange={(e) => onFieldChange('password_confirm', e.target.value)}
|
||||
onBlur={() => onFieldBlur('password_confirm')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password_confirm ? 'true' : 'false'}
|
||||
showPasswordToggle
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
{errors.password_confirm && (
|
||||
<p className="mt-1 text-sm text-destructive" role="alert">
|
||||
{errors.password_confirm}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<Checkbox
|
||||
id="register-terms"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => {
|
||||
onAcceptedTermsChange(checked as boolean);
|
||||
if (errors.terms) onErrorsChange((prev) => ({ ...prev, terms: undefined }));
|
||||
}}
|
||||
required
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
aria-describedby={errors.terms ? 'terms-error' : 'terms-description'}
|
||||
/>
|
||||
<label htmlFor="register-terms" className="ml-2 block text-sm text-foreground">
|
||||
|
||||
{/* Terms */}
|
||||
<div className="flex items-start gap-3 pt-1">
|
||||
<div className="pt-0.5">
|
||||
<Checkbox
|
||||
id="register-terms"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => {
|
||||
onAcceptedTermsChange(checked as boolean);
|
||||
if (errors.terms) onErrorsChange((prev) => ({ ...prev, terms: undefined }));
|
||||
}}
|
||||
required
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
aria-describedby={errors.terms ? 'terms-error' : 'terms-description'}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer">
|
||||
J'accepte les{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
aria-label="Lire les conditions d'utilisation"
|
||||
>
|
||||
conditions d'utilisation
|
||||
|
|
@ -169,7 +161,7 @@ export function RegisterPageForm({
|
|||
et la{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
aria-label="Lire la politique de confidentialité"
|
||||
>
|
||||
politique de confidentialité
|
||||
|
|
@ -180,11 +172,16 @@ export function RegisterPageForm({
|
|||
Vous devez accepter les conditions d'utilisation et la politique de confidentialité pour créer un compte
|
||||
</p>
|
||||
{errors.terms && (
|
||||
<p id="terms-error" className="text-sm text-destructive" role="alert">
|
||||
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
|
||||
{errors.terms}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
|
||||
<AuthButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-button-primary-glow"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
|
@ -193,7 +190,7 @@ export function RegisterPageForm({
|
|||
) : (
|
||||
"S'inscrire"
|
||||
)}
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AuthButton } from '../AuthButton';
|
||||
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface RegisterPageVerificationNoticeProps {
|
||||
email: string;
|
||||
|
|
@ -15,42 +15,53 @@ export function RegisterPageVerificationNotice({
|
|||
onResend,
|
||||
}: RegisterPageVerificationNoticeProps) {
|
||||
return (
|
||||
<div className="text-center space-y-4" role="status" aria-live="polite">
|
||||
<div
|
||||
className="bg-success/10 border border-success text-success px-4 py-4 rounded-lg"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<p className="font-medium">Inscription réussie !</p>
|
||||
<p className="text-sm mt-1">
|
||||
Un email de vérification a été envoyé à {email}
|
||||
<div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite">
|
||||
{/* Success icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<Mail className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-foreground">Inscription réussie !</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Un email de vérification a été envoyé à{' '}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Veuillez vérifier votre boîte mail et cliquer sur le lien de vérification.
|
||||
</p>
|
||||
|
||||
{resendSuccess && (
|
||||
<p className="text-sm text-success" role="status" aria-live="polite">
|
||||
Email de vérification renvoyé avec succès !
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-sm text-success animate-fade-in"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>Email de vérification renvoyé avec succès !</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
|
||||
<AuthButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
onClick={onResend}
|
||||
disabled={resendLoading}
|
||||
className="text-primary hover:underline text-sm disabled:opacity-50"
|
||||
aria-label="Renvoyer l'email de vérification"
|
||||
>
|
||||
{resendLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : (
|
||||
"Renvoyer l'email de vérification"
|
||||
)}
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { LoginFormData } from '../types';
|
|||
import { logger } from '@/utils/logger';
|
||||
import { formatErrorMessage as formatApiErrorMessage } from '@/utils/apiErrorHandler';
|
||||
import type { ApiError } from '@/schemas/apiSchemas';
|
||||
import { CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { AuthLayout } from '../components/AuthLayout';
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export function LoginPage() {
|
|||
if (parsed.state?.user && parsed.state?.isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Continue, pas encore persisté
|
||||
}
|
||||
}
|
||||
|
|
@ -97,8 +97,8 @@ export function LoginPage() {
|
|||
};
|
||||
|
||||
const handleBlur = (field: keyof LoginFormData) => {
|
||||
const error = validateField(field, formData[field]);
|
||||
setErrors({ ...errors, [field]: error });
|
||||
const fieldError = validateField(field, formData[field]);
|
||||
setErrors({ ...errors, [field]: fieldError });
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof LoginFormData, value: string) => {
|
||||
|
|
@ -141,20 +141,29 @@ export function LoginPage() {
|
|||
footerLinks={[{ label: "Don't have an account? Sign up", to: '/register' }]}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* OAuth providers */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<OAuthButton provider="google" onClick={() => handleOAuthLogin('google')} />
|
||||
<OAuthButton provider="github" onClick={() => handleOAuthLogin('github')} />
|
||||
<OAuthButton provider="discord" onClick={() => handleOAuthLogin('discord')} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t border-white/10" /></div>
|
||||
<span className="bg-background px-2 text-muted-foreground relative z-10">Or continue with</span>
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-card px-3 text-muted-foreground">or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2">
|
||||
<div
|
||||
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<p>{getLoginErrorMessage(error)}</p>
|
||||
</div>
|
||||
|
|
@ -180,24 +189,31 @@ export function LoginPage() {
|
|||
onBlur={() => handleBlur('password')}
|
||||
error={errors.password}
|
||||
required
|
||||
showPasswordToggle
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember me & Forgot password */}
|
||||
<div className="flex items-center justify-between gap-3 text-sm min-w-0">
|
||||
<label htmlFor="remember_me" className="flex items-center gap-2 text-muted-foreground cursor-pointer min-w-0 flex-shrink">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_me"
|
||||
checked={remember_me}
|
||||
onChange={(e) => setRemember_me(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/10 bg-black/20 text-primary focus:ring-primary/50 flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">Remember me</span>
|
||||
</label>
|
||||
<Link to="/forgot-password" className="text-primary hover:underline flex-shrink-0">Forgot password?</Link>
|
||||
<Checkbox
|
||||
id="remember_me"
|
||||
checked={remember_me}
|
||||
onCheckedChange={(checked) => setRemember_me(checked)}
|
||||
label="Remember me"
|
||||
/>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline underline-offset-4 transition-colors duration-[var(--duration-fast)] flex-shrink-0"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AuthButton type="submit" loading={loading} className="w-full bg-gradient-to-r from-cyan-600 to-magenta-600 hover:from-cyan-500 hover:to-magenta-500 text-white border-0 shadow-lg shadow-cyan-900/20">
|
||||
<AuthButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-button-primary-glow"
|
||||
>
|
||||
Sign In
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -952,6 +952,11 @@
|
|||
animation: empty-state-in var(--duration-normal) var(--ease-out) both;
|
||||
}
|
||||
|
||||
/* Auth page entry — fade-in + subtle scale for premium feel */
|
||||
.animate-auth-enter {
|
||||
animation: auth-enter var(--duration-slow) var(--ease-out) both;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 10s linear infinite;
|
||||
}
|
||||
|
|
@ -1325,6 +1330,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes auth-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bar-fill {
|
||||
from {
|
||||
width: 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue