feat: enhance profile page and authentication with user avatar support

- Updated `next.config.ts` to allow images from various external sources, including LinkedIn and GitHub.
- Refactored `ProfilePage` to improve layout and display user avatar, name, and role more prominently.
- Enhanced `AuthButton` to show user avatar if available, improving user experience.
- Updated authentication logic in `auth.ts` to include user avatar and role in session management.
- Extended JWT type definitions to support new user fields (firstName, lastName, avatar, role) for better user data handling.
This commit is contained in:
Julien Froidefond
2025-09-30 23:15:21 +02:00
parent 307b3a8a14
commit d8ca4ef00b
5 changed files with 272 additions and 121 deletions

View File

@@ -2,6 +2,46 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'media.licdn.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'via.placeholder.com',
port: '',
pathname: '/**',
},
],
},
turbopack: { turbopack: {
rules: { rules: {
'*.sql': ['raw'], '*.sql': ['raw'],

View File

@@ -140,138 +140,228 @@ export default function ProfilePage() {
<Header title="TowerControl" subtitle="Profil utilisateur" /> <Header title="TowerControl" subtitle="Profil utilisateur" />
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Informations générales */} {/* Header avec avatar et infos principales */}
<div className="bg-[var(--card)] rounded-lg p-6 mb-6"> <div className="bg-gradient-to-r from-[var(--primary)]/10 to-[var(--accent)]/10 rounded-xl p-8 mb-8 border border-[var(--primary)]/20">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-4"> <div className="flex flex-col md:flex-row items-center md:items-start gap-6">
Informations générales {/* Avatar section */}
</h2> <div className="flex-shrink-0">
{profile.avatar ? (
<div className="space-y-4"> <div className="relative">
<div> {/* eslint-disable-next-line @next/next/no-img-element */}
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1"> <img
Email src={profile.avatar}
</label> alt="Avatar"
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md"> className="w-24 h-24 rounded-full object-cover border-4 border-[var(--primary)]/30 shadow-lg"
{profile.email} />
</div> <div className="absolute -bottom-2 -right-2 w-8 h-8 bg-[var(--success)] rounded-full border-4 border-[var(--card)] flex items-center justify-center">
<p className="text-xs text-[var(--muted-foreground)] mt-1"> <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
L&apos;email ne peut pas être modifié <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</p> </svg>
</div>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-[var(--primary)]/20 border-4 border-[var(--primary)]/30 flex items-center justify-center">
<svg className="w-12 h-12 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
</div> </div>
<div> {/* User info */}
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1"> <div className="flex-1 text-center md:text-left">
Rôle <h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-2">
</label> {profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || profile.email}
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md"> </h1>
{profile.role} <p className="text-[var(--muted-foreground)] text-lg mb-4">{profile.email}</p>
</div>
</div> <div className="flex flex-wrap gap-4 justify-center md:justify-start">
<div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]">
<div> <div className="w-2 h-2 bg-[var(--success)] rounded-full"></div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1"> <span className="text-sm font-medium text-[var(--foreground)]">{profile.role}</span>
Membre depuis </div>
</label> <div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]">
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md"> <svg className="w-4 h-4 text-[var(--muted-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{new Date(profile.createdAt).toLocaleDateString('fr-FR')} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</div> </svg>
</div> <span className="text-sm text-[var(--muted-foreground)]">
Membre depuis {new Date(profile.createdAt).toLocaleDateString('fr-FR')}
{profile.lastLoginAt && ( </span>
<div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1">
Dernière connexion
</label>
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md">
{new Date(profile.lastLoginAt).toLocaleString('fr-FR')}
</div> </div>
</div> </div>
)} </div>
</div> </div>
</div> </div>
{/* Formulaire de modification */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<form onSubmit={handleSubmit} className="bg-[var(--card)] rounded-lg p-6"> {/* Informations générales */}
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-4"> <div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
Modifier le profil <h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-6 flex items-center gap-2">
</h2> <svg className="w-5 h-5 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Informations générales
</h2>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<svg className="w-5 h-5 text-[var(--muted-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
<div>
<div className="text-[var(--foreground)] font-medium">{profile.email}</div>
<div className="text-xs text-[var(--muted-foreground)]">Email principal</div>
</div>
</div>
<div className="space-y-4"> <div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<div> <svg className="w-5 h-5 text-[var(--muted-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
Prénom </svg>
</label> <div>
<Input <div className="text-[var(--foreground)] font-medium">{profile.role}</div>
id="firstName" <div className="text-xs text-[var(--muted-foreground)]">Rôle utilisateur</div>
value={formData.firstName} </div>
onChange={(e) => handleChange('firstName', e.target.value)} </div>
placeholder="Votre prénom"
/>
</div>
<div> {profile.lastLoginAt && (
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
Nom de famille <svg className="w-5 h-5 text-[var(--muted-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</label> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<Input </svg>
id="lastName" <div>
value={formData.lastName} <div className="text-[var(--foreground)] font-medium">
onChange={(e) => handleChange('lastName', e.target.value)} {new Date(profile.lastLoginAt).toLocaleString('fr-FR')}
placeholder="Votre nom de famille" </div>
/> <div className="text-xs text-[var(--muted-foreground)]">Dernière connexion</div>
</div> </div>
</div>
<div> )}
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom d&apos;affichage (optionnel)
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Nom d&apos;affichage personnalisé"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Si vide, sera généré automatiquement à partir du prénom et nom
</p>
</div>
<div>
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
URL de l&apos;avatar (optionnel)
</label>
<Input
id="avatar"
value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg"
/>
</div> </div>
</div> </div>
{error && ( {/* Formulaire de modification */}
<div className="mt-4 text-[var(--destructive)] text-sm"> <div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
{error} <h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-6 flex items-center gap-2">
</div> <svg className="w-5 h-5 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
)} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Modifier le profil
</h2>
{success && ( <form onSubmit={handleSubmit} className="space-y-4">
<div className="mt-4 text-[var(--success)] text-sm"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{success} <div>
</div> <label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
)} Prénom
</label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
placeholder="Votre prénom"
/>
</div>
<div className="mt-6"> <div>
<Button <label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
type="submit" Nom de famille
disabled={isPending} </label>
className="w-full" <Input
> id="lastName"
{isPending ? 'Sauvegarde...' : 'Sauvegarder les modifications'} value={formData.lastName}
</Button> onChange={(e) => handleChange('lastName', e.target.value)}
placeholder="Votre nom de famille"
/>
</div>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom d&apos;affichage (optionnel)
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Nom d&apos;affichage personnalisé"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Si vide, sera généré automatiquement à partir du prénom et nom
</p>
</div>
<div>
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
URL de l&apos;avatar (optionnel)
</label>
<Input
id="avatar"
value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg"
/>
{formData.avatar && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">Aperçu:</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={formData.avatar}
alt="Aperçu avatar"
className="w-8 h-8 rounded-full object-cover border border-[var(--border)]"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/30 rounded-lg">
<svg className="w-5 h-5 text-[var(--destructive)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[var(--destructive)] text-sm">{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 bg-[var(--success)]/10 border border-[var(--success)]/30 rounded-lg">
<svg className="w-5 h-5 text-[var(--success)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[var(--success)] text-sm">{success}</span>
</div>
)}
<div className="pt-4">
<Button
type="submit"
disabled={isPending}
className="w-full bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
>
{isPending ? (
<div className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sauvegarde...
</div>
) : (
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Sauvegarder les modifications
</div>
)}
</Button>
</div>
</form>
</div> </div>
</form> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -36,9 +36,18 @@ export function AuthButton() {
className="p-1 h-auto" className="p-1 h-auto"
title={`Profil - ${session.user?.email}`} title={`Profil - ${session.user?.email}`}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {session.user?.avatar ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> // eslint-disable-next-line @next/next/no-img-element
</svg> <img
src={session.user.avatar}
alt="Avatar"
className="w-4 h-4 rounded-full object-cover"
/>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)}
</Button> </Button>
<Button <Button
onClick={() => signOut({ callbackUrl: '/login' })} onClick={() => signOut({ callbackUrl: '/login' })}

View File

@@ -59,12 +59,20 @@ export const authOptions: NextAuthOptions = {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.id = user.id token.id = user.id
token.firstName = user.firstName
token.lastName = user.lastName
token.avatar = user.avatar
token.role = user.role
} }
return token return token
}, },
async session({ session, token }) { async session({ session, token }) {
if (token && session.user) { if (token && session.user) {
session.user.id = token.id as string session.user.id = token.id as string
session.user.firstName = token.firstName as string | undefined
session.user.lastName = token.lastName as string | undefined
session.user.avatar = token.avatar as string | undefined
session.user.role = token.role as string
} }
return session return session
}, },

View File

@@ -28,5 +28,9 @@ declare module "next-auth" {
declare module "next-auth/jwt" { declare module "next-auth/jwt" {
interface JWT { interface JWT {
id: string id: string
firstName?: string
lastName?: string
avatar?: string
role: string
} }
} }