chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
@@ -0,0 +1,279 @@
'use client';
/**
* Reusable "Edit account" section: display name, email (read-only), avatar upload/remove.
* Use on a protected dashboard page. Requires session cookie (user must be logged in).
*
* @param {Object} props
* @param {Object} [props.initialUser] - Initial user from server (e.g. getSession().user). If omitted, fetches from API.
* @param {function} [props.onUpdate] - Called after profile or avatar update with the new user object (e.g. to refresh layout)
*/
import React, { useState, useEffect, useRef } from 'react';
import { Card, Input, Button } from '../../../shared/components/index.js';
import { useToast } from '@hykocx/zen/toast';
import { useCurrentUser } from './useCurrentUser.js';
import UserAvatar from './UserAvatar.js';
const API_BASE = '/zen/api';
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
export default function AccountSection({ initialUser, onUpdate }) {
const toast = useToast();
const { user: fetchedUser, loading: fetchLoading, refetch } = useCurrentUser();
const user = initialUser ?? fetchedUser;
const [formData, setFormData] = useState({ name: user?.name ?? '' });
const [saving, setSaving] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [imagePreview, setImagePreview] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (user) {
setFormData((prev) => ({ ...prev, name: user.name ?? '' }));
setImagePreview(user.image ? getImageUrl(user.image) : null);
}
}, [user]);
const handleNameChange = (value) => {
setFormData((prev) => ({ ...prev, name: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name?.trim()) {
toast.error('Le nom est requis');
return;
}
setSaving(true);
try {
const res = await fetch(`${API_BASE}/users/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: formData.name.trim() }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Échec de la mise à jour du profil');
}
toast.success('Profil mis à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Échec de la mise à jour du profil');
} finally {
setSaving(false);
}
};
const handleReset = () => {
setFormData({ name: user?.name ?? '' });
};
const handleImageSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner un fichier image');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image doit faire moins de 5MB");
return;
}
const reader = new FileReader();
reader.onloadend = () => setImagePreview(reader.result);
reader.readAsDataURL(file);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'POST',
credentials: 'include',
body: fd,
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Upload failed');
}
setImagePreview(getImageUrl(data.user?.image));
toast.success('Photo de profil mise à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Upload failed');
setImagePreview(user?.image ? getImageUrl(user.image) : null);
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = async () => {
if (!user?.image) return;
setUploadingImage(true);
try {
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Remove failed');
}
setImagePreview(null);
toast.success('Photo de profil supprimée avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Remove failed');
} finally {
setUploadingImage(false);
}
};
const created_at = user?.created_at ?? user?.createdAt;
const hasChanges = formData.name?.trim() !== (user?.name ?? '');
if (fetchLoading && !initialUser) {
return (
<Card variant="lightDark">
<div className="animate-pulse space-y-4">
<div className="h-24 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
<div className="h-32 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
</div>
</Card>
);
}
if (!user) {
return null;
}
return (
<div className="space-y-6">
<Card variant="lightDark">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Photo de profil
</h2>
<div className="flex flex-wrap items-center gap-6">
<div className="relative">
{imagePreview ? (
<img
src={imagePreview}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-200 dark:border-neutral-700"
/>
) : (
<UserAvatar user={user} size="lg" className="w-24 h-24" />
)}
{uploadingImage && (
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Téléchargez une nouvelle photo de profil. Taille max 5MB.
</p>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
>
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
</Button>
{imagePreview && (
<Button
type="button"
variant="secondary"
onClick={handleRemoveImage}
disabled={uploadingImage}
>
Supprimer
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
<Card variant="lightDark">
<form onSubmit={handleSubmit} className="space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Informations personnelles
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom complet"
type="text"
value={formData.name}
onChange={handleNameChange}
placeholder="Entrez votre nom complet"
required
disabled={saving}
/>
<Input
label="Courriel"
type="email"
value={user.email ?? ''}
disabled
readOnly
description="L'email ne peut pas être modifié"
/>
</div>
{created_at && (
<Input
label="Compte créé"
type="text"
value={new Date(created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
disabled
readOnly
/>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
type="button"
variant="secondary"
onClick={handleReset}
disabled={saving || !hasChanges}
>
Réinitialiser
</Button>
<Button
type="submit"
variant="primary"
disabled={saving || !hasChanges}
loading={saving}
>
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</Button>
</div>
</form>
</Card>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
/**
* Auth Pages Component - Catch-all route for Next.js App Router
* This component handles all authentication routes: login, register, forgot, reset, confirm
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import LoginPage from './pages/LoginPage.js';
import RegisterPage from './pages/RegisterPage.js';
import ForgotPasswordPage from './pages/ForgotPasswordPage.js';
import ResetPasswordPage from './pages/ResetPasswordPage.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.js';
import LogoutPage from './pages/LogoutPage.js';
export default function AuthPagesClient({
params,
searchParams,
registerAction,
loginAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
currentUser = null
}) {
const router = useRouter();
const [currentPage, setCurrentPage] = useState(null); // null = loading
const [isLoading, setIsLoading] = useState(true);
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
useEffect(() => {
// Get page from params or URL
const getPageFromParams = () => {
if (params?.auth?.[0]) {
return params.auth[0];
}
// Fallback: read from URL
if (typeof window !== 'undefined') {
const pathname = window.location.pathname;
const match = pathname.match(/\/auth\/([^\/\?]+)/);
return match ? match[1] : 'login';
}
return 'login';
};
const page = getPageFromParams();
setCurrentPage(page);
setIsLoading(false);
}, [params]);
// Extract email and token from searchParams (handles both Promise and regular object)
useEffect(() => {
const extractSearchParams = async () => {
let resolvedParams = searchParams;
// Check if searchParams is a Promise (Next.js 15+)
if (searchParams && typeof searchParams.then === 'function') {
resolvedParams = await searchParams;
}
// Extract email and token from URL if not in searchParams
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
setEmail(resolvedParams?.email || urlParams.get('email') || '');
setToken(resolvedParams?.token || urlParams.get('token') || '');
} else {
setEmail(resolvedParams?.email || '');
setToken(resolvedParams?.token || '');
}
};
extractSearchParams();
}, [searchParams]);
const navigate = (page) => {
router.push(`/auth/${page}`);
};
// Don't render anything while determining the correct page
if (isLoading || !currentPage) {
return null;
}
// Page components mapping
const pageComponents = {
login: () => <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />,
register: () => <RegisterPage onSubmit={registerAction} onNavigate={navigate} currentUser={currentUser} />,
forgot: () => <ForgotPasswordPage onSubmit={forgotPasswordAction} onNavigate={navigate} currentUser={currentUser} />,
reset: () => <ResetPasswordPage onSubmit={resetPasswordAction} onNavigate={navigate} email={email} token={token} />,
confirm: () => <ConfirmEmailPage onSubmit={verifyEmailAction} onNavigate={navigate} email={email} token={token} />,
logout: () => <LogoutPage onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />
};
// Render the appropriate page
const PageComponent = pageComponents[currentPage];
return PageComponent ? <PageComponent /> : <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />;
}
@@ -0,0 +1,19 @@
/**
* Auth Pages Layout - Server Component
* Provides the layout structure for authentication pages
*
* Usage:
* <AuthPagesLayout>
* <AuthPagesClient {...props} />
* </AuthPagesLayout>
*/
export default function AuthPagesLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full">
{children}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
'use client';
/**
* Displays the current user's avatar (image or initials fallback).
* Image is loaded from /zen/api/storage/{key}; session cookie is sent automatically.
*
* @param {Object} props
* @param {Object} props.user - User object with optional image (storage key) and name
* @param {'sm'|'md'|'lg'} [props.size='md'] - Size of the avatar
* @param {string} [props.className] - Additional CSS classes for the wrapper
*/
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
function getInitials(name) {
if (!name || !name.trim()) return '?';
return name
.trim()
.split(/\s+/)
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
};
export default function UserAvatar({ user, size = 'md', className = '' }) {
const sizeClass = sizeClasses[size] || sizeClasses.md;
const imageUrl = user?.image ? getImageUrl(user.image) : null;
return (
<div
className={`rounded-full overflow-hidden flex items-center justify-center bg-neutral-700 text-white font-medium shrink-0 ${sizeClass} ${className}`}
aria-hidden
>
{imageUrl ? (
<img
src={imageUrl}
alt={user?.name ? `${user.name} avatar` : 'Avatar'}
className="w-full h-full object-cover"
/>
) : (
<span>{getInitials(user?.name)}</span>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
/**
* User menu: avatar + name with optional dropdown (account link, logout).
* Can receive user from server (e.g. from getSession()) or use useCurrentUser() on client.
*
* @param {Object} props
* @param {Object} [props.user] - User from server (getSession().user). If not provided, useCurrentUser() is used.
* @param {string} [props.accountHref='/dashboard/account'] - Link for "My account"
* @param {string} [props.logoutHref='/auth/logout'] - Link for logout
* @param {string} [props.className] - Extra classes for the menu wrapper
*/
import { Fragment } from 'react';
import { Menu, Transition } from '@headlessui/react';
import UserAvatar from './UserAvatar.js';
import { useCurrentUser } from './useCurrentUser.js';
export default function UserMenu({
user: userProp,
accountHref = '/dashboard/account',
logoutHref = '/auth/logout',
className = '',
}) {
const { user: userFromHook, loading } = useCurrentUser();
const user = userProp ?? userFromHook;
if (loading && !userProp) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="w-10 h-10 rounded-full bg-neutral-700 animate-pulse" />
<div className="h-4 w-24 bg-neutral-700 rounded animate-pulse" />
</div>
);
}
if (!user) {
return null;
}
return (
<Menu as="div" className={`relative ${className}`}>
<Menu.Button className="flex items-center gap-2 sm:gap-3 px-2 py-1.5 rounded-lg hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-400 transition-colors">
<UserAvatar user={user} size="md" />
<span className="text-sm font-medium text-inherit truncate max-w-[120px] sm:max-w-[160px]">
{user.name || user.email || 'Account'}
</span>
<svg className="w-4 h-4 text-neutral-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg bg-white dark:bg-neutral-900 shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 focus:outline-none z-50">
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate">{user.name || 'User'}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{user.email}</p>
</div>
<Menu.Item>
{({ active }) => (
<a
href={accountHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
My account
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={logoutHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
Log out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Auth Components Export
*
* Use these components to build custom auth pages for every flow (login, register, forgot,
* reset, confirm, logout) so they match your site's style.
* For a ready-made catch-all auth UI, use AuthPagesClient from '@hykocx/zen/auth/pages'.
* For the default full-page auth (no custom layout), re-export from '@hykocx/zen/auth/page'.
*
* --- Custom auth pages (all types) ---
*
* Pattern: server component loads session/searchParams and passes actions to a client wrapper;
* client wrapper uses useRouter for onNavigate and renders the Zen component.
*
* Component props:
* - LoginPage: onSubmit (loginAction), onSetSessionCookie, onNavigate, redirectAfterLogin, currentUser
* - RegisterPage: onSubmit (registerAction), onNavigate, currentUser
* - ForgotPasswordPage: onSubmit (forgotPasswordAction), onNavigate, currentUser
* - ResetPasswordPage: onSubmit (resetPasswordAction), onNavigate, email, token (from URL)
* - ConfirmEmailPage: onSubmit (verifyEmailAction), onNavigate, email, token (from URL)
* - LogoutPage: onLogout (logoutAction), onSetSessionCookie (optional)
*
* onNavigate receives 'login' | 'register' | 'forgot' | 'reset'. Map to your routes (e.g. /auth/${page}).
* For reset/confirm, pass email and token from searchParams. Full guide: see README-custom-login.md in this package.
* Protect routes with protect() from '@hykocx/zen/auth', redirectTo your login path.
*
* --- Dashboard / user display ---
*
* UserAvatar, UserMenu, AccountSection, useCurrentUser: see README-dashboard.md.
*/
export { default as AuthPagesLayout } from './AuthPagesLayout.js';
export { default as AuthPagesClient } from './AuthPages.js';
export { default as LoginPage } from './pages/LoginPage.js';
export { default as RegisterPage } from './pages/RegisterPage.js';
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.js';
export { default as ResetPasswordPage } from './pages/ResetPasswordPage.js';
export { default as ConfirmEmailPage } from './pages/ConfirmEmailPage.js';
export { default as LogoutPage } from './pages/LogoutPage.js';
export { default as UserAvatar } from './UserAvatar.js';
export { default as UserMenu } from './UserMenu.js';
export { default as AccountSection } from './AccountSection.js';
export { useCurrentUser } from './useCurrentUser.js';
@@ -0,0 +1,162 @@
'use client';
/**
* Confirm Email Page Component
*/
import { useState, useEffect, useRef } from 'react';
export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState('');
const [hasVerified, setHasVerified] = useState(false);
const isVerifyingRef = useRef(false);
useEffect(() => {
console.log('ConfirmEmailPage useEffect triggered', { email, token, hasVerified });
// Check for persisted success message on mount
const persistedSuccess = sessionStorage.getItem('emailVerificationSuccess');
console.log('Persisted success message:', persistedSuccess);
if (persistedSuccess) {
console.log('Restoring persisted success message');
setSuccess(persistedSuccess);
setIsLoading(false);
setHasVerified(true); // Mark as verified to prevent re-verification
// Clear the persisted message after showing it
sessionStorage.removeItem('emailVerificationSuccess');
// Redirect after showing the message
setTimeout(() => {
onNavigate('login');
}, 3000);
return;
}
// Auto-verify on mount, but only once
if (email && token && !hasVerified && !isVerifyingRef.current) {
console.log('Starting email verification');
verifyEmail();
} else if (!email || !token) {
console.log('Invalid email or token');
setError('Lien de vérification invalide');
setIsLoading(false);
}
}, [email, token, hasVerified, onNavigate]);
async function verifyEmail() {
// Prevent multiple calls
if (hasVerified || isVerifyingRef.current) {
console.log('Email verification already attempted or in progress');
return;
}
// Set flags IMMEDIATELY to prevent multiple calls
isVerifyingRef.current = true;
setHasVerified(true);
// Clear any existing states at the start
setError('');
setSuccess('');
console.log('Starting email verification for:', email);
const formData = new FormData();
formData.set('email', email);
formData.set('token', token);
try {
const result = await onSubmit(formData);
console.log('Verification result:', result);
if (result.success) {
console.log('Verification successful');
const successMessage = result.message || 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.';
// Persist success message in sessionStorage
sessionStorage.setItem('emailVerificationSuccess', successMessage);
setSuccess(successMessage);
setIsLoading(false);
// Redirect to login after 3 seconds
setTimeout(() => {
onNavigate('login');
}, 3000);
} else {
console.log('Verification failed:', result.error);
setError(result.error || 'Échec de la vérification de l\'e-mail');
setIsLoading(false);
}
} catch (err) {
console.error('Email verification error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Vérification de l'e-mail
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nous vérifions votre adresse e-mail...
</p>
</div>
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white"></div>
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Vérification de votre e-mail en cours...</p>
</div>
)}
{/* Success Message - Only show if success and no error */}
{success && !error && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message - Only show if error and no success */}
{error && !success && (
<div>
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800/50 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
)}
{/* Redirect message - Only show if success and no error */}
{success && !error && (
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm mt-3">Redirection vers la connexion...</p>
)}
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
/**
* Forgot Password Page Component
*/
import { useState, useEffect } from 'react';
export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
email: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'envoi de l\'e-mail de réinitialisation');
setIsLoading(false);
}
} catch (err) {
console.error('Forgot password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Mot de passe oublié
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Forgot Password Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_forgot">Website</label>
<input id="_hp_forgot" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Envoi en cours...</span>
</div>
) : (
'Envoyer le lien de réinitialisation'
)}
</button>
</form>
{/* Back to Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
/**
* Login Page Component
*/
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, redirectAfterLogin = '/', currentUser = null }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
const router = useRouter();
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
// If already logged in, redirect to redirectAfterLogin
useEffect(() => {
if (currentUser) {
router.replace(redirectAfterLogin);
}
}, [currentUser, redirectAfterLogin, router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !isLoading && !success) {
handleSubmit();
}
};
const handleSubmit = async () => {
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
const successMsg = result.message || 'Connexion réussie ! Redirection...';
// Display success message immediately (no page refresh because we didn't set cookie yet)
setSuccess(successMsg);
setIsLoading(false);
// Wait for user to see the success message
setTimeout(async () => {
// Now set the session cookie (this might cause a refresh, but we're redirecting anyway)
if (result.sessionToken && onSetSessionCookie) {
await onSetSessionCookie(result.sessionToken);
}
// Then navigate
router.push(redirectAfterLogin);
}, 1500);
} else {
setError(result.error || 'Échec de la connexion');
setIsLoading(false);
}
} catch (err) {
console.error('Login error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
};
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Connexion
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Veuillez vous connecter pour continuer.
</p>
</div>
{/* Already logged in: redirecting (brief message while redirect runs) */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-blue-400/50 border-t-blue-600 rounded-full animate-spin flex-shrink-0 dark:border-blue-500/30 dark:border-t-blue-500"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">Redirection...</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Login Form */}
<div className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_login">Website</label>
<input id="_hp_login" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white">
Mot de passe
</label>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('forgot');
}
}}
className="text-xs text-neutral-600 hover:text-neutral-900 transition-colors duration-200 dark:text-neutral-300 dark:hover:text-white"
>
Mot de passe oublié ?
</a>
</div>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
/>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Connexion en cours...</span>
</div>
) : (
'Se connecter'
)}
</button>
</div>
{/* Register Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('register');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Pas de compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">S'inscrire</span>
</a>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
/**
* Logout Page Component
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LogoutPage({ onLogout, onSetSessionCookie }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
// Call the logout action if provided
if (onLogout) {
const result = await onLogout();
if (result && !result.success) {
setError(result.error || 'Échec de la déconnexion');
setIsLoading(false);
return;
}
}
// Clear session cookie if provided
if (onSetSessionCookie) {
await onSetSessionCookie('', { expires: new Date(0) });
}
// Show success message
setSuccess('Vous avez été déconnecté. Redirection...');
setIsLoading(false);
// Wait for user to see the success message, then redirect
setTimeout(() => {
router.push('/');
}, 100);
} catch (err) {
console.error('Logout error:', err);
setError('Une erreur inattendue s\'est produite lors de la déconnexion');
setIsLoading(false);
}
};
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Prêt à vous déconnecter ?
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Cela mettra fin à votre session et vous déconnectera de votre compte.
</p>
</div>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Logout Button */}
<div className="flex flex-col gap-4">
<button
type="button"
onClick={handleLogout}
disabled={isLoading || success}
className="cursor-pointer w-full bg-red-600 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-red-500/20 dark:focus:ring-red-400/30"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>Déconnexion en cours...</span>
</div>
) : (
'Se déconnecter'
)}
</button>
</div>
{/* Cancel Link */}
<div className="mt-6 text-center">
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez changé d'avis ? </span>
<a
href="/"
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour
</a>
</div>
</div>
);
}
@@ -0,0 +1,337 @@
'use client';
/**
* Register Page Component
*/
import { useState, useEffect } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
export default function RegisterPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
// Validation functions
const validateEmail = (email) => {
const errors = [];
if (email.length > 254) {
errors.push('L\'e-mail doit contenir 254 caractères ou moins');
}
return errors;
};
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
return errors;
};
const validateName = (name) => {
const errors = [];
if (name.trim().length === 0) {
errors.push('Le nom ne peut pas être vide');
}
if (name.length > 100) {
errors.push('Le nom doit contenir 100 caractères ou moins');
}
return errors;
};
const isFormValid = () => {
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
return emailErrors.length === 0 &&
passwordErrors.length === 0 &&
nameErrors.length === 0 &&
formData.password === formData.confirmPassword &&
formData.email.trim().length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
if (emailErrors.length > 0) {
setError(emailErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (nameErrors.length > 0) {
setError(nameErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('name', formData.name);
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'inscription');
setIsLoading(false);
}
} catch (err) {
console.error('Registration error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Créer un compte
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Inscrivez-vous pour commencer.
</p>
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Registration Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_register">Website</label>
<input id="_hp_register" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="name" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nom complet
</label>
<input
id="name"
name="name"
type="text"
required
maxLength="100"
value={formData.name}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="John Doe"
autoComplete="name"
/>
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
maxLength="254"
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
minLength="8"
maxLength="128"
value={formData.password}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.password} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Création du compte en cours...</span>
</div>
) : (
'Créer un compte'
)}
</button>
</form>
{/* Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez déjà un compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">Se connecter</span>
</a>
</div>
</div>
);
}
@@ -0,0 +1,222 @@
'use client';
/**
* Reset Password Page Component
*/
import { useState } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
newPassword: '',
confirmPassword: ''
});
// Validation functions
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
return errors;
};
const isFormValid = () => {
const passwordErrors = validatePassword(formData.newPassword);
return passwordErrors.length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('newPassword', formData.newPassword);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('email', email);
submitData.append('token', token);
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
// Redirect to login after 2 seconds
setTimeout(() => {
onNavigate('login');
}, 2000);
} else {
setError(result.error || 'Échec de la réinitialisation du mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Reset password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Réinitialiser le mot de passe
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Saisissez votre nouveau mot de passe ci-dessous.
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Reset Password Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="newPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nouveau mot de passe
</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.newPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Réinitialisation...</span>
</div>
) : (
'Réinitialiser le mot de passe'
)}
</button>
</form>
{/* Back to Login Link */}
<div className="mt-6 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
'use client';
/**
* Client hook to fetch the current user from the API.
* Uses session cookie (credentials: 'include'); safe to use in client components.
*
* @returns {{ user: Object|null, loading: boolean, error: string|null, refetch: function }}
*
* @example
* const { user, loading, error, refetch } = useCurrentUser();
* if (loading) return <Spinner />;
* if (error) return <div>Error: {error}</div>;
* if (!user) return <Link href="/auth/login">Log in</Link>;
* return <span>Hello, {user.name}</span>;
*/
import { useState, useEffect, useCallback } from 'react';
const API_BASE = '/zen/api';
export function useCurrentUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUser = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/users/me`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
setUser(null);
return;
}
setError(data.message || data.error || 'Failed to load user');
setUser(null);
return;
}
if (data.user) {
setUser(data.user);
} else {
setUser(null);
}
} catch (err) {
console.error('[useCurrentUser]', err);
setError(err.message || 'Failed to load user');
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return { user, loading, error, refetch: fetchUser };
}