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:
senke 2026-02-10 00:33:35 +01:00
parent cfe1fce255
commit 9f108e2430
7 changed files with 371 additions and 258 deletions

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 é 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 é 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>
);
}

View file

@ -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>

View file

@ -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;