feat(auth): LoginPage + RegisterPage (Sprint 1 étape 3)
This commit is contained in:
parent
38777796aa
commit
464eb27f1e
2 changed files with 336 additions and 0 deletions
135
src/features/auth/pages/LoginPage.tsx
Normal file
135
src/features/auth/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Page de connexion.
|
||||
*
|
||||
* Formulaire email + mot de passe, appel de `signIn` (Supabase via auth-client).
|
||||
* Si la session devient active (arrivée déjà connecté OU succès de signIn),
|
||||
* `useAuth` propage l'état et le useEffect redirige vers /dashboard. On évite
|
||||
* ainsi un double `navigate` concurrent depuis le handler de submit.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { Input } from '@/shared/components/ui/input'
|
||||
import { Label } from '@/shared/components/ui/label'
|
||||
import { signIn } from '@/shared/lib/auth-client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
function mapSignInError(message: string | undefined): string {
|
||||
if (!message) return 'Connexion impossible. Réessayez dans quelques instants.'
|
||||
if (message === 'Invalid login credentials') {
|
||||
return 'Email ou mot de passe incorrect.'
|
||||
}
|
||||
if (message.includes('Email not confirmed')) {
|
||||
return 'Email non confirmé. Vérifiez votre boîte mail.'
|
||||
}
|
||||
return 'Connexion impossible. Réessayez dans quelques instants.'
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
navigate('/dashboard', { replace: true })
|
||||
}
|
||||
}, [isLoading, isAuthenticated, navigate])
|
||||
|
||||
if (isLoading || isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center bg-canvas text-ink-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Chargement de la session"
|
||||
>
|
||||
<Loader2 className="size-6 animate-spin" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { error: signInError } = await signIn(email, password)
|
||||
if (signInError) {
|
||||
setError(mapSignInError(signInError.message))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
|
||||
<section className="w-full max-w-sm rounded-lg border border-line bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-ink-1">Se connecter</h1>
|
||||
<p className="mt-1 text-sm text-ink-3">Accédez à votre espace Expria.</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-ink-3">
|
||||
Pas encore de compte ?{' '}
|
||||
<Link to="/register" className="text-expria underline-offset-4 hover:underline">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
201
src/features/auth/pages/RegisterPage.tsx
Normal file
201
src/features/auth/pages/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Page d'inscription.
|
||||
*
|
||||
* Formulaire email + mot de passe + confirmation, validation Zod côté client,
|
||||
* appel de `signUp` (Supabase via auth-client). Supabase envoie un email de
|
||||
* confirmation par défaut : on n'a donc pas de session active après succès.
|
||||
* On affiche un message de confirmation invitant à vérifier la boîte mail,
|
||||
* puis à revenir sur /login.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { Input } from '@/shared/components/ui/input'
|
||||
import { Label } from '@/shared/components/ui/label'
|
||||
import { signUp } from '@/shared/lib/auth-client'
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
email: z.string().email('Email invalide'),
|
||||
password: z.string().min(8, 'Le mot de passe doit contenir au moins 8 caractères'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ['confirmPassword'],
|
||||
message: 'Les mots de passe ne correspondent pas',
|
||||
})
|
||||
|
||||
type FieldErrors = Partial<Record<'email' | 'password' | 'confirmPassword', string>>
|
||||
|
||||
function mapSignUpError(message: string | undefined): string {
|
||||
if (!message) return 'Inscription impossible. Réessayez dans quelques instants.'
|
||||
if (message.toLowerCase().includes('already registered')) {
|
||||
return 'Un compte existe déjà avec cet email.'
|
||||
}
|
||||
if (/password/i.test(message)) {
|
||||
return 'Mot de passe refusé par le serveur. Choisissez un mot de passe plus robuste.'
|
||||
}
|
||||
return 'Inscription impossible. Réessayez dans quelques instants.'
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setFieldErrors({})
|
||||
setFormError(null)
|
||||
|
||||
const parsed = registerSchema.safeParse({ email, password, confirmPassword })
|
||||
if (!parsed.success) {
|
||||
const flat = parsed.error.flatten().fieldErrors
|
||||
setFieldErrors({
|
||||
email: flat.email?.[0],
|
||||
password: flat.password?.[0],
|
||||
confirmPassword: flat.confirmPassword?.[0],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { error: signUpError } = await signUp(parsed.data.email, parsed.data.password)
|
||||
if (signUpError) {
|
||||
setFormError(mapSignUpError(signUpError.message))
|
||||
return
|
||||
}
|
||||
setSuccessMessage(
|
||||
'Compte créé. Vérifiez votre email pour confirmer votre inscription, puis connectez-vous.',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
|
||||
<section className="w-full max-w-sm rounded-lg border border-line bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-ink-1">Créer un compte</h1>
|
||||
<p className="mt-1 text-sm text-ink-3">Commencez votre préparation TCF Canada.</p>
|
||||
|
||||
{successMessage ? (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="mt-6 rounded-md border border-success/40 bg-success-bg px-3 py-3 text-sm text-success"
|
||||
>
|
||||
{successMessage}
|
||||
</div>
|
||||
<p className="mt-6 text-center text-sm text-ink-3">
|
||||
<Link to="/login" className="text-expria underline-offset-4 hover:underline">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={!!fieldErrors.email}
|
||||
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p id="email-error" className="text-sm text-danger">
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p id="password-error" className="text-sm text-danger">
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={!!fieldErrors.confirmPassword}
|
||||
aria-describedby={
|
||||
fieldErrors.confirmPassword ? 'confirmPassword-error' : undefined
|
||||
}
|
||||
/>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<p id="confirmPassword-error" className="text-sm text-danger">
|
||||
{fieldErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
'Créer mon compte'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-ink-3">
|
||||
Déjà un compte ?{' '}
|
||||
<Link to="/login" className="text-expria underline-offset-4 hover:underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterPage
|
||||
Loading…
Add table
Add a link
Reference in a new issue