feat: add profile link to header and implement user profile update functionality with email and password management

This commit is contained in:
Julien Froidefond
2025-11-27 13:37:18 +01:00
parent 10ff15392f
commit 873b3dd9f3
7 changed files with 417 additions and 0 deletions

BIN
dev.db

Binary file not shown.

58
src/actions/profile.ts Normal file
View 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;
}

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

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

View File

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

View File

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