refactor(auth): extract RegisterPage into register-page module
- Add register-page/ with useRegisterPage, RegisterPageForm, RegisterPageVerificationNotice, RegisterPageSkeleton - Layout primitives (min-h-layout-page-sm), tokens (success, destructive) - Stories: Default, Loading, WithError; re-export from pages/RegisterPage Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2701ce18d2
commit
f622fad9fc
9 changed files with 537 additions and 478 deletions
|
|
@ -0,0 +1,49 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { RegisterPage } from './RegisterPage';
|
||||
import { RegisterPageSkeleton } from './RegisterPageSkeleton';
|
||||
|
||||
const meta: Meta<typeof RegisterPage> = {
|
||||
title: 'App/Pages/Auth/RegisterPage',
|
||||
component: RegisterPage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: "Page d'inscription avec validation complète et vérification email.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="min-h-layout-page-sm">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Formulaire vide prêt à être rempli. */
|
||||
export const Default: Story = { name: 'Par défaut' };
|
||||
|
||||
/** Skeleton pendant chargement (layout aligné). */
|
||||
export const Loading: Story = {
|
||||
name: 'Loading',
|
||||
render: () => <RegisterPageSkeleton />,
|
||||
};
|
||||
|
||||
/** Erreur soumission (simulée via MSW si besoin). */
|
||||
export const WithError: Story = {
|
||||
name: 'Avec erreur',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Démontre l'affichage d'erreurs de validation en temps réel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { AuthLayout } from '../AuthLayout';
|
||||
import { useRegisterPage } from './useRegisterPage';
|
||||
import { RegisterPageForm } from './RegisterPageForm';
|
||||
import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const {
|
||||
formData,
|
||||
errors,
|
||||
acceptedTerms,
|
||||
setAcceptedTerms,
|
||||
setErrors,
|
||||
showVerificationNotice,
|
||||
usernameAvailable,
|
||||
checkingUsername,
|
||||
loading,
|
||||
error,
|
||||
resendLoading,
|
||||
resendSuccess,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
onSubmit,
|
||||
handleResendVerificationEmail,
|
||||
} = useRegisterPage();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Inscription"
|
||||
subtitle="Créez votre compte"
|
||||
footerLinks={[{ label: 'Déjà un compte ? Se connecter', to: '/login' }]}
|
||||
>
|
||||
{showVerificationNotice ? (
|
||||
<RegisterPageVerificationNotice
|
||||
email={formData.email}
|
||||
resendLoading={resendLoading}
|
||||
resendSuccess={resendSuccess}
|
||||
onResend={handleResendVerificationEmail}
|
||||
/>
|
||||
) : (
|
||||
<RegisterPageForm
|
||||
formData={formData}
|
||||
errors={errors}
|
||||
acceptedTerms={acceptedTerms}
|
||||
onAcceptedTermsChange={setAcceptedTerms}
|
||||
onErrorsChange={setErrors}
|
||||
loading={loading}
|
||||
error={error instanceof Error ? error : null}
|
||||
usernameAvailable={usernameAvailable}
|
||||
checkingUsername={checkingUsername}
|
||||
onFieldChange={handleChange}
|
||||
onFieldBlur={handleBlur}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
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 { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
|
||||
import type { RegisterFormData } from '../../types';
|
||||
import type { FormErrors } from './useRegisterPage';
|
||||
|
||||
interface RegisterPageFormProps {
|
||||
formData: RegisterFormData;
|
||||
errors: FormErrors;
|
||||
acceptedTerms: boolean;
|
||||
onAcceptedTermsChange: (checked: boolean) => void;
|
||||
onErrorsChange: (updater: (prev: FormErrors) => FormErrors) => void;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
usernameAvailable: boolean | null;
|
||||
checkingUsername: boolean;
|
||||
onFieldChange: (field: keyof RegisterFormData, value: string) => void;
|
||||
onFieldBlur: (field: keyof RegisterFormData) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
export function RegisterPageForm({
|
||||
formData,
|
||||
errors,
|
||||
acceptedTerms,
|
||||
onAcceptedTermsChange,
|
||||
onErrorsChange,
|
||||
loading,
|
||||
error,
|
||||
usernameAvailable,
|
||||
checkingUsername,
|
||||
onFieldChange,
|
||||
onFieldBlur,
|
||||
onSubmit,
|
||||
}: RegisterPageFormProps) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-4"
|
||||
aria-label="Formulaire d'inscription"
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded-lg"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{error.message}
|
||||
</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
|
||||
id="register-email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => onFieldChange('email', e.target.value)}
|
||||
onBlur={() => onFieldBlur('email')}
|
||||
required
|
||||
autoComplete="email"
|
||||
aria-invalid={errors.email ? 'true' : 'false'}
|
||||
/>
|
||||
{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
|
||||
id="register-password_confirm"
|
||||
type="password"
|
||||
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'}
|
||||
/>
|
||||
{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">
|
||||
J'accepte les{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
aria-label="Lire les conditions d'utilisation"
|
||||
>
|
||||
conditions d'utilisation
|
||||
</Link>{' '}
|
||||
et la{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||
aria-label="Lire la politique de confidentialité"
|
||||
>
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
<p id="terms-description" className="sr-only">
|
||||
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">
|
||||
{errors.terms}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Inscription en cours...
|
||||
</>
|
||||
) : (
|
||||
"S'inscrire"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Skeleton aligned with RegisterPage form layout (AuthLayout + form fields).
|
||||
*/
|
||||
export function RegisterPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 min-h-layout-page-sm flex flex-col justify-center">
|
||||
<div className="h-8 w-48 rounded bg-muted animate-pulse mx-auto" />
|
||||
<div className="h-4 w-64 rounded bg-muted animate-pulse mx-auto" />
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
|
||||
<div className="h-11 w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-muted animate-pulse" />
|
||||
<div className="h-11 w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-muted animate-pulse" />
|
||||
<div className="h-11 w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-40 rounded bg-muted animate-pulse" />
|
||||
<div className="h-11 w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-10 w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface RegisterPageVerificationNoticeProps {
|
||||
email: string;
|
||||
resendLoading: boolean;
|
||||
resendSuccess: boolean;
|
||||
onResend: () => void;
|
||||
}
|
||||
|
||||
export function RegisterPageVerificationNotice({
|
||||
email,
|
||||
resendLoading,
|
||||
resendSuccess,
|
||||
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}
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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" />
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : (
|
||||
"Renvoyer l'email de vérification"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { RegisterPage } from './RegisterPage';
|
||||
export { RegisterPageSkeleton } from './RegisterPageSkeleton';
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRegister } from '../../hooks/useRegister';
|
||||
import { useUsernameAvailability } from '../../hooks/useUsernameAvailability';
|
||||
import { authApi } from '@/services/api/auth';
|
||||
import { logger } from '@/utils/logger';
|
||||
import type { RegisterFormData } from '../../types';
|
||||
|
||||
export type FormErrors = Partial<Record<keyof RegisterFormData | 'terms', string>>;
|
||||
|
||||
const initialFormData: RegisterFormData = {
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
username: '',
|
||||
};
|
||||
|
||||
export function useRegisterPage() {
|
||||
const {
|
||||
mutate: handleRegister,
|
||||
isPending: loading,
|
||||
error,
|
||||
isSuccess: success,
|
||||
} = useRegister();
|
||||
|
||||
const [formData, setFormData] = useState<RegisterFormData>(initialFormData);
|
||||
const { available: usernameAvailable, checking: checkingUsername } =
|
||||
useUsernameAvailability(formData.username);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
const [showVerificationNotice, setShowVerificationNotice] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (success) setShowVerificationNotice(true);
|
||||
}, [success]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email requis';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Email invalide';
|
||||
}
|
||||
if (!formData.username) {
|
||||
newErrors.username = "Nom d'utilisateur requis";
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username = "Le nom d'utilisateur doit contenir au moins 3 caractères";
|
||||
} else if (usernameAvailable === false) {
|
||||
newErrors.username = "Ce nom d'utilisateur est déjà pris";
|
||||
}
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Mot de passe requis';
|
||||
} else if (formData.password.length < 12) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 12 caractères';
|
||||
}
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
newErrors.password_confirm = 'Les mots de passe ne correspondent pas';
|
||||
}
|
||||
if (!acceptedTerms) {
|
||||
newErrors.terms =
|
||||
"Vous devez accepter les conditions d'utilisation et la politique de confidentialité";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof RegisterFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleBlur = (field: keyof RegisterFormData) => {
|
||||
const value = formData[field];
|
||||
let error: string | undefined;
|
||||
switch (field) {
|
||||
case 'email':
|
||||
if (!value) error = 'Email requis';
|
||||
else if (!/\S+@\S+\.\S+/.test(value)) error = 'Email invalide';
|
||||
break;
|
||||
case 'username':
|
||||
if (!value) error = "Nom d'utilisateur requis";
|
||||
else if (value.length < 3) error = "Le nom d'utilisateur doit contenir au moins 3 caractères";
|
||||
break;
|
||||
case 'password':
|
||||
if (!value) error = 'Mot de passe requis';
|
||||
else if (value.length < 12) error = 'Le mot de passe doit contenir au moins 12 caractères';
|
||||
break;
|
||||
case 'password_confirm':
|
||||
if (formData.password !== value) error = 'Les mots de passe ne correspondent pas';
|
||||
break;
|
||||
}
|
||||
setErrors((prev) => ({ ...prev, [field]: error }));
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validate()) handleRegister(formData);
|
||||
};
|
||||
|
||||
const handleResendVerificationEmail = async () => {
|
||||
try {
|
||||
setResendLoading(true);
|
||||
setResendSuccess(false);
|
||||
await authApi.resendVerification({ email: formData.email });
|
||||
setResendSuccess(true);
|
||||
} catch (err) {
|
||||
logger.error("Erreur lors du renvoi de l'email:", { error: err });
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
acceptedTerms,
|
||||
setAcceptedTerms,
|
||||
setErrors,
|
||||
showVerificationNotice,
|
||||
usernameAvailable,
|
||||
checkingUsername,
|
||||
loading,
|
||||
error,
|
||||
resendLoading,
|
||||
resendSuccess,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
onSubmit,
|
||||
handleResendVerificationEmail,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { within, userEvent } from '@storybook/test';
|
||||
import { RegisterPage } from './RegisterPage';
|
||||
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
|
||||
|
||||
const meta: Meta<typeof RegisterPage> = {
|
||||
title: 'App/Pages/Auth/RegisterPage',
|
||||
component: RegisterPage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Page d\'inscription avec validation complète et vérification email.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
withRouter,
|
||||
withQueryClient,
|
||||
withToast,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* État par défaut de la page d'inscription.
|
||||
* Formulaire vide prêt à être rempli.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
name: 'Par défaut',
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulation d'une erreur d'inscription.
|
||||
* Par exemple, nom d'utilisateur déjà pris.
|
||||
*/
|
||||
export const WithError: Story = {
|
||||
name: 'Avec erreur',
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Remplir les champs
|
||||
const usernameInput = canvas.getByLabelText(/nom d'utilisateur/i);
|
||||
const emailInput = canvas.getByLabelText(/email/i);
|
||||
const passwordInput = canvas.getByLabelText(/^mot de passe \*/i);
|
||||
const confirmPasswordInput = canvas.getByLabelText(/confirmer le mot de passe/i);
|
||||
|
||||
await userEvent.type(usernameInput, 'existinguser');
|
||||
await userEvent.type(emailInput, 'new@veza.music');
|
||||
await userEvent.type(passwordInput, 'SecurePass123!@#');
|
||||
await userEvent.type(confirmPasswordInput, 'SecurePass123!@#');
|
||||
|
||||
// L'erreur de disponibilité sera affichée via le hook useUsernameAvailability
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Démontre l\'affichage d\'erreurs de validation en temps réel.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* État de succès après inscription.
|
||||
* Affiche le message de vérification email.
|
||||
*/
|
||||
export const Success: Story = {
|
||||
name: 'Succès - Vérification email',
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Remplir tous les champs avec des données valides
|
||||
const usernameInput = canvas.getByLabelText(/nom d'utilisateur/i);
|
||||
const emailInput = canvas.getByLabelText(/email/i);
|
||||
const passwordInput = canvas.getByLabelText(/^mot de passe \*/i);
|
||||
const confirmPasswordInput = canvas.getByLabelText(/confirmer le mot de passe/i);
|
||||
const termsCheckbox = canvas.getByRole('checkbox');
|
||||
|
||||
await userEvent.type(usernameInput, 'newartist');
|
||||
await userEvent.type(emailInput, 'artist@veza.music');
|
||||
await userEvent.type(passwordInput, 'SuperSecure123!@#');
|
||||
await userEvent.type(confirmPasswordInput, 'SuperSecure123!@#');
|
||||
await userEvent.click(termsCheckbox);
|
||||
|
||||
// Le succès sera affiché après la mutation réussie
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Montre le message de succès avec instructions de vérification email.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,380 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { AuthLayout } from '../components/AuthLayout';
|
||||
import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator';
|
||||
import { useRegister } from '../hooks/useRegister';
|
||||
import { useUsernameAvailability } from '../hooks/useUsernameAvailability';
|
||||
import { authApi } from '@/services/api/auth';
|
||||
import { logger } from '@/utils/logger';
|
||||
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 type { RegisterFormData } from '../types';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const {
|
||||
mutate: handleRegister,
|
||||
isPending: loading,
|
||||
error,
|
||||
isSuccess: success,
|
||||
} = useRegister();
|
||||
const [formData, setFormData] = useState<RegisterFormData>({
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
username: '',
|
||||
});
|
||||
const { available: usernameAvailable, checking: checkingUsername } =
|
||||
useUsernameAvailability(formData.username);
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<keyof RegisterFormData | 'terms', string>>
|
||||
>({});
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
const [showVerificationNotice, setShowVerificationNotice] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
|
||||
// Détecter quand l'inscription réussit
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
setShowVerificationNotice(true);
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
// Rediriger si déjà connecté
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof RegisterFormData | 'terms', string>> =
|
||||
{};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email requis';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Email invalide';
|
||||
}
|
||||
|
||||
if (!formData.username) {
|
||||
newErrors.username = "Nom d'utilisateur requis";
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username =
|
||||
"Le nom d'utilisateur doit contenir au moins 3 caractères";
|
||||
} else if (usernameAvailable === false) {
|
||||
newErrors.username = "Ce nom d'utilisateur est déjà pris";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Mot de passe requis';
|
||||
} else if (formData.password.length < 12) {
|
||||
newErrors.password =
|
||||
'Le mot de passe doit contenir au moins 12 caractères';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
newErrors.password_confirm = 'Les mots de passe ne correspondent pas';
|
||||
}
|
||||
|
||||
if (!acceptedTerms) {
|
||||
newErrors.terms =
|
||||
"Vous devez accepter les conditions d'utilisation et la politique de confidentialité";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof RegisterFormData, value: string) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors({ ...errors, [field]: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (field: keyof RegisterFormData) => {
|
||||
const value = formData[field];
|
||||
let error: string | undefined;
|
||||
|
||||
switch (field) {
|
||||
case 'email':
|
||||
if (!value) {
|
||||
error = 'Email requis';
|
||||
} else if (!/\S+@\S+\.\S+/.test(value)) {
|
||||
error = 'Email invalide';
|
||||
}
|
||||
break;
|
||||
case 'username':
|
||||
if (!value) {
|
||||
error = "Nom d'utilisateur requis";
|
||||
} else if (value.length < 3) {
|
||||
error = "Le nom d'utilisateur doit contenir au moins 3 caractères";
|
||||
}
|
||||
break;
|
||||
case 'password':
|
||||
if (!value) {
|
||||
error = 'Mot de passe requis';
|
||||
} else if (value.length < 12) {
|
||||
error = 'Le mot de passe doit contenir au moins 12 caractères';
|
||||
}
|
||||
break;
|
||||
case 'password_confirm':
|
||||
if (formData.password !== value) {
|
||||
error = 'Les mots de passe ne correspondent pas';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setErrors({ ...errors, [field]: error });
|
||||
} else {
|
||||
setErrors({ ...errors, [field]: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validate()) {
|
||||
await handleRegister(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerificationEmail = async () => {
|
||||
try {
|
||||
setResendLoading(true);
|
||||
setResendSuccess(false);
|
||||
await authApi.resendVerification({ email: formData.email });
|
||||
setResendSuccess(true);
|
||||
} catch (err) {
|
||||
// Gérer l'erreur silencieusement ou afficher un message
|
||||
logger.error("Erreur lors du renvoi de l'email:", { error: err });
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Inscription"
|
||||
subtitle="Créez votre compte"
|
||||
footerLinks={[{ label: 'Déjà un compte ? Se connecter', to: '/login' }]}
|
||||
>
|
||||
{showVerificationNotice ? (
|
||||
<div className="text-center space-y-4" role="status" aria-live="polite">
|
||||
<div
|
||||
className="bg-kodo-lime/10 border border-kodo-lime text-kodo-lime px-4 py-4 rounded"
|
||||
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é à {formData.email}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-kodo-content-dim">
|
||||
Veuillez vérifier votre boîte mail et cliquer sur le lien de
|
||||
vérification.
|
||||
</p>
|
||||
{resendSuccess && (
|
||||
<p
|
||||
className="text-sm text-kodo-lime"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Email de vérification renvoyé avec succès !
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleResendVerificationEmail}
|
||||
disabled={resendLoading}
|
||||
className="text-kodo-cyan 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" />
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : (
|
||||
"Renvoyer l'email de vérification"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-4"
|
||||
aria-label="Formulaire d'inscription"
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
className="bg-kodo-red/10 border border-kodo-red text-kodo-red px-4 py-4 rounded"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onBlur={() => handleBlur('username')}
|
||||
required
|
||||
autoComplete="username"
|
||||
aria-invalid={errors.username ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-kodo-red" 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-kodo-content-dim" role="status">
|
||||
<span className="sr-only">Vérification en cours</span>
|
||||
<span aria-hidden="true">Vérification en cours...</span>
|
||||
</p>
|
||||
) : usernameAvailable === true ? (
|
||||
<p className="text-xs text-kodo-lime" role="status">
|
||||
<span className="sr-only">Disponible:</span>
|
||||
<span aria-hidden="true">✓</span> Ce nom d'utilisateur est
|
||||
disponible
|
||||
</p>
|
||||
) : usernameAvailable === false ? (
|
||||
<p className="text-xs text-kodo-red" role="alert">
|
||||
<span className="sr-only">Indisponible:</span>
|
||||
<span aria-hidden="true">✗</span> Ce nom d'utilisateur est
|
||||
déjà pris
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
onBlur={() => handleBlur('email')}
|
||||
required
|
||||
autoComplete="email"
|
||||
aria-invalid={errors.email ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-kodo-red" role="alert">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
onBlur={() => handleBlur('password')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-kodo-red" role="alert">
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
<PasswordStrengthIndicator password={formData.password} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password_confirm">Confirmer le mot de passe *</Label>
|
||||
<Input
|
||||
id="password_confirm"
|
||||
type="password"
|
||||
value={formData.password_confirm}
|
||||
onChange={(e) => handleChange('password_confirm', e.target.value)}
|
||||
onBlur={() => handleBlur('password_confirm')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password_confirm ? 'true' : 'false'}
|
||||
/>
|
||||
{errors.password_confirm && (
|
||||
<p className="mt-1 text-sm text-kodo-red" role="alert">
|
||||
{errors.password_confirm}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcceptedTerms(checked as boolean);
|
||||
if (errors.terms) {
|
||||
setErrors({ ...errors, terms: undefined });
|
||||
}
|
||||
}}
|
||||
required
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
aria-describedby={
|
||||
errors.terms ? 'terms-error' : 'terms-description'
|
||||
}
|
||||
/>
|
||||
<label htmlFor="terms" className="ml-2 block text-sm text-kodo-text-main">
|
||||
J'accepte les{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-kodo-cyan hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||
aria-label="Lire les conditions d'utilisation"
|
||||
>
|
||||
conditions d'utilisation
|
||||
</Link>{' '}
|
||||
et la{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-kodo-cyan hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
|
||||
aria-label="Lire la politique de confidentialité"
|
||||
>
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
<p id="terms-description" className="sr-only">
|
||||
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-kodo-red" role="alert">
|
||||
{errors.terms}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Inscription en cours...
|
||||
</>
|
||||
) : (
|
||||
"S'inscrire"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
/**
|
||||
* Register page — re-export from feature module.
|
||||
*/
|
||||
export { RegisterPage } from '../components/register-page';
|
||||
|
|
|
|||
Loading…
Reference in a new issue