Logout now increments sessionVersion so existing JWTs are rejected server-side, deletes orphaned DB sessions, and uses redirectTo for signOut. Google OAuth requests account selection each time; optional AUTH_GOOGLE_PROMPT=login forces Google re-authentication on shared devices. Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
8.1 KiB
TypeScript
158 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { Input } from '@/components/ui/input'
|
|
import { updateProfile, changePassword } from '@/app/actions/profile'
|
|
import { updateUserSettings } from '@/app/actions/user-settings'
|
|
import { performSignOut } from '@/lib/auth-client'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { User, Mail, Shield, LogOut, Camera, Bell } from 'lucide-react'
|
|
import { motion } from 'motion/react'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
|
|
export function ProfileForm({ user }: { user: { name: string | null; email: string; image: string | null } }) {
|
|
const { t } = useLanguage()
|
|
const [desktopNotif, setDesktopNotif] = useState(false)
|
|
|
|
const initial = (user.name || user.email || 'U')[0].toUpperCase()
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="space-y-12 max-w-2xl"
|
|
>
|
|
<div className="space-y-8">
|
|
{/* Avatar + Name */}
|
|
<div className="flex items-center gap-8">
|
|
<div className="relative group">
|
|
<div className="w-24 h-24 rounded-[32px] bg-brand-accent/10 border-2 border-brand-accent/20 flex items-center justify-center overflow-hidden">
|
|
{user.image ? (
|
|
<Avatar className="size-24 rounded-[32px]">
|
|
<AvatarImage src={user.image} alt="" />
|
|
<AvatarFallback className="text-2xl font-serif font-bold text-brand-accent">{initial}</AvatarFallback>
|
|
</Avatar>
|
|
) : (
|
|
<User size={40} className="text-brand-accent" />
|
|
)}
|
|
</div>
|
|
<button className="absolute -bottom-2 -right-2 p-2 bg-ink text-white rounded-xl shadow-lg border border-border opacity-0 group-hover:opacity-100 transition-all hover:scale-110">
|
|
<Camera size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h3 className="text-2xl font-serif font-bold text-ink">{user.name || 'Utilisateur'}</h3>
|
|
<p className="text-sm text-concrete font-light">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Personal info */}
|
|
<div className="grid grid-cols-1 gap-6">
|
|
<div className="space-y-4">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
|
{t('profile.description')}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{/* Name edit */}
|
|
<form action={async (formData) => {
|
|
const result = await updateProfile({ name: formData.get('name') as string })
|
|
if (result?.error) toast.error(t('profile.updateFailed'))
|
|
else toast.success(t('profile.updateSuccess'))
|
|
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<User size={18} className="text-concrete" />
|
|
<div>
|
|
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.displayName')}</p>
|
|
<Input name="name" defaultValue={user.name || ''} className="border-0 bg-transparent p-0 h-auto text-sm text-ink focus:ring-0 focus:outline-none" />
|
|
</div>
|
|
</div>
|
|
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
|
{t('general.save')}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Email */}
|
|
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Mail size={18} className="text-concrete" />
|
|
<div>
|
|
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">Email</p>
|
|
<p className="text-sm text-ink">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security / Password */}
|
|
<form action={async (formData) => {
|
|
const result = await changePassword(formData)
|
|
if (result?.error) {
|
|
const msg = '_form' in result.error
|
|
? result.error._form[0]
|
|
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
|
toast.error(msg)
|
|
} else {
|
|
toast.success(t('profile.passwordChangeSuccess'))
|
|
}
|
|
}} className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Shield size={18} className="text-concrete" />
|
|
<div>
|
|
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">{t('profile.changePassword')}</p>
|
|
<p className="text-sm text-ink">{t('profile.changePasswordDescription')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 ms-10">
|
|
<Input name="currentPassword" type="password" required placeholder={t('profile.currentPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
|
<Input name="newPassword" type="password" required minLength={6} placeholder={t('profile.newPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
|
<Input name="confirmPassword" type="password" required minLength={6} placeholder={t('profile.confirmPassword')} className="bg-white/50 dark:bg-black/20 border-border text-sm h-9" />
|
|
</div>
|
|
<div className="flex justify-end ms-10">
|
|
<button type="submit" className="text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline">
|
|
{t('profile.updatePassword')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preferences */}
|
|
<div className="space-y-4">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">
|
|
{t('profile.preferences')}
|
|
</h4>
|
|
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Bell size={18} className="text-concrete" />
|
|
<div>
|
|
<p className="text-sm text-ink">{t('profile.desktopNotifications')}</p>
|
|
<p className="text-[10px] text-concrete font-light pe-4">{t('profile.desktopNotificationsDesc')}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setDesktopNotif(!desktopNotif)}
|
|
className={`w-10 h-5 rounded-full relative p-0.5 cursor-pointer transition-all duration-300 ${desktopNotif ? 'bg-brand-accent' : 'bg-gray-200 dark:bg-white/10'}`}
|
|
>
|
|
<div className={`w-3.5 h-3.5 bg-white rounded-full transition-all duration-300 ${desktopNotif ? 'translate-x-[18px]' : 'translate-x-0'}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="pt-8 border-t border-border/40">
|
|
<button
|
|
onClick={() => performSignOut('/login')}
|
|
className="flex items-center gap-3 px-6 py-3 bg-rose-50 dark:bg-rose-500/10 text-rose-600 rounded-xl font-bold uppercase tracking-widest text-[10px] hover:bg-rose-100 transition-colors"
|
|
>
|
|
<LogOut size={16} />
|
|
{t('sidebar.signOut')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|