428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { useRouter } from 'next/navigation'
|
||
|
|
import { signOut } from 'next-auth/react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
} from '@/components/ui/card'
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
DialogTrigger,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||
|
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||
|
|
import {
|
||
|
|
Loader2,
|
||
|
|
Save,
|
||
|
|
Camera,
|
||
|
|
Lock,
|
||
|
|
Bell,
|
||
|
|
Trash2,
|
||
|
|
User,
|
||
|
|
} from 'lucide-react'
|
||
|
|
|
||
|
|
export default function ProfileSettingsPage() {
|
||
|
|
const router = useRouter()
|
||
|
|
const { data: user, isLoading, refetch } = trpc.user.me.useQuery()
|
||
|
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||
|
|
const updateProfile = trpc.user.updateProfile.useMutation()
|
||
|
|
const changePassword = trpc.user.changePassword.useMutation()
|
||
|
|
const deleteAccount = trpc.user.deleteAccount.useMutation()
|
||
|
|
|
||
|
|
// Profile form state
|
||
|
|
const [name, setName] = useState('')
|
||
|
|
const [bio, setBio] = useState('')
|
||
|
|
const [phoneNumber, setPhoneNumber] = useState('')
|
||
|
|
const [notificationPreference, setNotificationPreference] = useState('EMAIL')
|
||
|
|
const [profileLoaded, setProfileLoaded] = useState(false)
|
||
|
|
|
||
|
|
// Password form state
|
||
|
|
const [currentPassword, setCurrentPassword] = useState('')
|
||
|
|
const [newPassword, setNewPassword] = useState('')
|
||
|
|
const [confirmNewPassword, setConfirmNewPassword] = useState('')
|
||
|
|
|
||
|
|
// Delete account state
|
||
|
|
const [deletePassword, setDeletePassword] = useState('')
|
||
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||
|
|
|
||
|
|
// Populate form when user data loads
|
||
|
|
if (user && !profileLoaded) {
|
||
|
|
setName(user.name || '')
|
||
|
|
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||
|
|
setBio((meta.bio as string) || '')
|
||
|
|
setPhoneNumber(user.phoneNumber || '')
|
||
|
|
setNotificationPreference(user.notificationPreference || 'EMAIL')
|
||
|
|
setProfileLoaded(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSaveProfile = async () => {
|
||
|
|
try {
|
||
|
|
await updateProfile.mutateAsync({
|
||
|
|
name: name || undefined,
|
||
|
|
bio,
|
||
|
|
phoneNumber: phoneNumber || null,
|
||
|
|
notificationPreference: notificationPreference as 'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE',
|
||
|
|
})
|
||
|
|
toast.success('Profile updated successfully')
|
||
|
|
refetch()
|
||
|
|
} catch (error) {
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Failed to update profile')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleChangePassword = async () => {
|
||
|
|
if (newPassword !== confirmNewPassword) {
|
||
|
|
toast.error('New passwords do not match')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await changePassword.mutateAsync({
|
||
|
|
currentPassword,
|
||
|
|
newPassword,
|
||
|
|
confirmNewPassword,
|
||
|
|
})
|
||
|
|
toast.success('Password changed successfully')
|
||
|
|
setCurrentPassword('')
|
||
|
|
setNewPassword('')
|
||
|
|
setConfirmNewPassword('')
|
||
|
|
} catch (error) {
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Failed to change password')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteAccount = async () => {
|
||
|
|
try {
|
||
|
|
await deleteAccount.mutateAsync({ password: deletePassword })
|
||
|
|
toast.success('Account deleted')
|
||
|
|
setDeleteDialogOpen(false)
|
||
|
|
signOut({ callbackUrl: '/login' })
|
||
|
|
} catch (error) {
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Failed to delete account')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Skeleton className="h-8 w-48" />
|
||
|
|
<Skeleton className="h-[200px] w-full" />
|
||
|
|
<Skeleton className="h-[200px] w-full" />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!user) return null
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">Profile Settings</h1>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
Manage your personal information and preferences
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Profile Photo */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Camera className="h-5 w-5" />
|
||
|
|
Profile Photo
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Click your avatar to upload a new profile picture
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<AvatarUpload
|
||
|
|
user={{ name: user.name, email: user.email, profileImageKey: user.profileImageKey }}
|
||
|
|
currentAvatarUrl={avatarUrl}
|
||
|
|
onUploadComplete={() => refetch()}
|
||
|
|
>
|
||
|
|
<div className="cursor-pointer">
|
||
|
|
<UserAvatar
|
||
|
|
user={{ name: user.name, email: user.email }}
|
||
|
|
avatarUrl={avatarUrl}
|
||
|
|
size="xl"
|
||
|
|
showEditOverlay
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</AvatarUpload>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Personal Information */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<User className="h-5 w-5" />
|
||
|
|
Personal Information
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Update your name, bio, and contact information
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="email">Email</Label>
|
||
|
|
<Input id="email" value={user.email} disabled />
|
||
|
|
<p className="text-xs text-muted-foreground">Email cannot be changed</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="name">Name</Label>
|
||
|
|
<Input
|
||
|
|
id="name"
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => setName(e.target.value)}
|
||
|
|
placeholder="Your full name"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="bio">Bio</Label>
|
||
|
|
<Textarea
|
||
|
|
id="bio"
|
||
|
|
value={bio}
|
||
|
|
onChange={(e) => setBio(e.target.value)}
|
||
|
|
placeholder="Tell us a bit about yourself..."
|
||
|
|
rows={3}
|
||
|
|
maxLength={1000}
|
||
|
|
/>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{bio.length}/1000 characters
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="phone">Phone Number</Label>
|
||
|
|
<Input
|
||
|
|
id="phone"
|
||
|
|
value={phoneNumber}
|
||
|
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||
|
|
placeholder="+33 6 12 34 56 78"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button
|
||
|
|
onClick={handleSaveProfile}
|
||
|
|
disabled={updateProfile.isPending}
|
||
|
|
>
|
||
|
|
{updateProfile.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Save Changes
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Notifications */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Bell className="h-5 w-5" />
|
||
|
|
Notifications
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Choose how you want to receive notifications
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="notification-pref">Notification Preference</Label>
|
||
|
|
<Select
|
||
|
|
value={notificationPreference}
|
||
|
|
onValueChange={setNotificationPreference}
|
||
|
|
>
|
||
|
|
<SelectTrigger id="notification-pref">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="EMAIL">Email only</SelectItem>
|
||
|
|
<SelectItem value="WHATSAPP">WhatsApp only</SelectItem>
|
||
|
|
<SelectItem value="BOTH">Email & WhatsApp</SelectItem>
|
||
|
|
<SelectItem value="NONE">None</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button
|
||
|
|
onClick={handleSaveProfile}
|
||
|
|
disabled={updateProfile.isPending}
|
||
|
|
>
|
||
|
|
{updateProfile.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Save Preferences
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Change Password */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Lock className="h-5 w-5" />
|
||
|
|
Change Password
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Update your account password
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="current-password">Current Password</Label>
|
||
|
|
<Input
|
||
|
|
id="current-password"
|
||
|
|
type="password"
|
||
|
|
value={currentPassword}
|
||
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
|
|
placeholder="Enter current password"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="new-password">New Password</Label>
|
||
|
|
<Input
|
||
|
|
id="new-password"
|
||
|
|
type="password"
|
||
|
|
value={newPassword}
|
||
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
||
|
|
placeholder="Enter new password"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||
|
|
<Input
|
||
|
|
id="confirm-password"
|
||
|
|
type="password"
|
||
|
|
value={confirmNewPassword}
|
||
|
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||
|
|
placeholder="Confirm new password"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button
|
||
|
|
onClick={handleChangePassword}
|
||
|
|
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmNewPassword}
|
||
|
|
>
|
||
|
|
{changePassword.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Lock className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Change Password
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Danger Zone */}
|
||
|
|
{user.role !== 'SUPER_ADMIN' && (
|
||
|
|
<Card className="border-destructive/50">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2 text-destructive">
|
||
|
|
<Trash2 className="h-5 w-5" />
|
||
|
|
Danger Zone
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Permanently delete your account and all associated data
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="destructive">
|
||
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
||
|
|
Delete Account
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Delete Account</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
This action is permanent and cannot be undone. All your data,
|
||
|
|
evaluations, and assignments will be removed.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4 py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="delete-password">
|
||
|
|
Enter your password to confirm
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="delete-password"
|
||
|
|
type="password"
|
||
|
|
value={deletePassword}
|
||
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||
|
|
placeholder="Your password"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => {
|
||
|
|
setDeleteDialogOpen(false)
|
||
|
|
setDeletePassword('')
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="destructive"
|
||
|
|
onClick={handleDeleteAccount}
|
||
|
|
disabled={deleteAccount.isPending || !deletePassword}
|
||
|
|
>
|
||
|
|
{deleteAccount.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Delete Account
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|