feat: add profile link to header and implement user profile update functionality with email and password management
This commit is contained in:
58
src/actions/profile.ts
Normal file
58
src/actions/profile.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { updateUserProfile, updateUserPassword, getUserById } from '@/services/auth';
|
||||||
|
|
||||||
|
export async function getProfileAction() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié', data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé', data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfileAction(data: { name?: string; email?: string }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateUserProfile(session.user.id, data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePasswordAction(data: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.newPassword.length < 6) {
|
||||||
|
return { success: false, error: 'Le nouveau mot de passe doit faire au moins 6 caractères' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateUserPassword(
|
||||||
|
session.user.id,
|
||||||
|
data.currentPassword,
|
||||||
|
data.newPassword
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
115
src/app/profile/PasswordForm.tsx
Normal file
115
src/app/profile/PasswordForm.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { Input, Button } from '@/components/ui';
|
||||||
|
import { updatePasswordAction } from '@/actions/profile';
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
currentPassword.length > 0 &&
|
||||||
|
newPassword.length >= 6 &&
|
||||||
|
newPassword === confirmPassword;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setMessage({ type: 'error', text: 'Les mots de passe ne correspondent pas' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updatePasswordAction({ currentPassword, newPassword });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setMessage({ type: 'success', text: 'Mot de passe modifié avec succès' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: result.error || 'Erreur lors de la modification' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="currentPassword"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Mot de passe actuel
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="newPassword"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Nouveau mot de passe
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted">Minimum 6 caractères</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Confirmer le nouveau mot de passe
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{confirmPassword && newPassword !== confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">
|
||||||
|
Les mots de passe ne correspondent pas
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
message.type === 'success' ? 'text-success' : 'text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending || !canSubmit}>
|
||||||
|
{isPending ? 'Modification...' : 'Modifier le mot de passe'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
84
src/app/profile/ProfileForm.tsx
Normal file
84
src/app/profile/ProfileForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Input, Button } from '@/components/ui';
|
||||||
|
import { updateProfileAction } from '@/actions/profile';
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
initialData: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileForm({ initialData }: ProfileFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [name, setName] = useState(initialData.name);
|
||||||
|
const [email, setEmail] = useState(initialData.email);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const hasChanges = name !== initialData.name || email !== initialData.email;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateProfileAction({ name, email });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setMessage({ type: 'success', text: 'Profil mis à jour avec succès' });
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: result.error || 'Erreur lors de la mise à jour' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Votre nom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
message.type === 'success' ? 'text-success' : 'text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending || !hasChanges}>
|
||||||
|
{isPending ? 'Enregistrement...' : 'Enregistrer les modifications'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
75
src/app/profile/page.tsx
Normal file
75
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getUserById } from '@/services/auth';
|
||||||
|
import { ProfileForm } from './ProfileForm';
|
||||||
|
import { PasswordForm } from './PasswordForm';
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(session.user.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
|
||||||
|
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Profile Info */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<h2 className="mb-6 text-xl font-semibold text-foreground">
|
||||||
|
Informations personnelles
|
||||||
|
</h2>
|
||||||
|
<ProfileForm
|
||||||
|
initialData={{
|
||||||
|
name: user.name || '',
|
||||||
|
email: user.email,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<h2 className="mb-6 text-xl font-semibold text-foreground">
|
||||||
|
Changer le mot de passe
|
||||||
|
</h2>
|
||||||
|
<PasswordForm />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-foreground">
|
||||||
|
Informations du compte
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted">ID du compte</span>
|
||||||
|
<span className="font-mono text-foreground">{user.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted">Membre depuis</span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -75,6 +75,13 @@ export function Header() {
|
|||||||
{session.user.email}
|
{session.user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
👤 Mon Profil
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||||
|
|||||||
@@ -66,3 +66,81 @@ export async function getUserById(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileInput {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfile(
|
||||||
|
userId: string,
|
||||||
|
input: UpdateProfileInput
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const { name, email } = input;
|
||||||
|
|
||||||
|
// If changing email, check it's not already taken
|
||||||
|
if (email) {
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
NOT: { id: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Cet email est déjà utilisé par un autre compte',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
...(name !== undefined && { name: name || null }),
|
||||||
|
...(email && { email }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserPassword(
|
||||||
|
userId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const { compare } = await import('bcryptjs');
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Utilisateur non trouvé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValid = await compare(currentPassword, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
return { success: false, error: 'Mot de passe actuel incorrect' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const hashedPassword = await hash(newPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { password: hashedPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user