diff options
| -rw-r--r-- | src/app/settings/page.tsx | 19 | ||||
| -rw-r--r-- | src/app/settings/password-form.tsx | 9 | ||||
| -rw-r--r-- | src/app/settings/profile-form.tsx | 12 | ||||
| -rw-r--r-- | src/app/settings/profile-image.tsx | 3 | ||||
| -rw-r--r-- | src/app/settings/settings-content.tsx | 537 | ||||
| -rw-r--r-- | src/app/settings/settings-form.tsx | 4 |
6 files changed, 19 insertions, 565 deletions
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 75d9d3d..1baba3f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,22 +1,21 @@ import { getServerSession } from "next-auth" -import { redirect } from "next/navigation" -import dbConnect from "@/lib/mongodb" -import User from "@/model/User" import { authOptions } from "@/lib/auth" -import SettingsContent from "./settings-content" +import { SettingsForm } from "./settings-form" +import { getUserProfile } from "@/services/user.service" export default async function SettingsPage() { const session = await getServerSession(authOptions) - + if (!session?.user?.email) { - redirect("/login") + // This case should be handled by middleware, but for type safety: + return null } - await dbConnect() - const user = await User.findOne({ email: session.user.email }).lean() as any + const user = await getUserProfile(session.user.email) if (!user) { - redirect("/login") + // This case suggests a data inconsistency (session exists but user not in DB) + return null } // Sanitize user object for client component @@ -27,5 +26,5 @@ export default async function SettingsPage() { twoFactorEnabled: !!user.twoFactorEnabled, } - return <SettingsContent initialUser={initialUser} /> + return <SettingsForm user={initialUser} /> } diff --git a/src/app/settings/password-form.tsx b/src/app/settings/password-form.tsx index 2377408..f56ae9e 100644 --- a/src/app/settings/password-form.tsx +++ b/src/app/settings/password-form.tsx @@ -16,7 +16,6 @@ export function PasswordForm() { const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [isLoading, setIsLoading] = useState(false) const form = useForm<PasswordChangeInput>({ resolver: zodResolver(passwordChangeSchema), @@ -28,8 +27,6 @@ export function PasswordForm() { }) const onSubmit = async (data: PasswordChangeInput) => { - setIsLoading(true) - try { const response = await fetch("/api/user/password", { method: "PATCH", @@ -51,8 +48,6 @@ export function PasswordForm() { form.reset() } catch { toast.error("An unexpected error occurred") - } finally { - setIsLoading(false) } } @@ -169,8 +164,8 @@ export function PasswordForm() { <div className="text-xs text-muted-foreground"> Password must contain at least 8 characters with uppercase, lowercase, and a number. </div> - <Button type="submit" disabled={isLoading}> - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Button type="submit" disabled={form.formState.isSubmitting}> + {form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} <Save className="mr-2 h-4 w-4" /> Update Password </Button> diff --git a/src/app/settings/profile-form.tsx b/src/app/settings/profile-form.tsx index 6f532f0..2cd8b52 100644 --- a/src/app/settings/profile-form.tsx +++ b/src/app/settings/profile-form.tsx @@ -4,8 +4,8 @@ import { useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { Loader2, User, Save } from "lucide-react" -import { toast } from "sonner" import { Session } from "next-auth" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -22,8 +22,6 @@ interface ProfileFormProps { } export function ProfileForm({ user, update }: ProfileFormProps) { - const [isLoading, setIsLoading] = useState(false) - const form = useForm<UpdateProfileInput>({ resolver: zodResolver(updateProfileSchema), defaultValues: { @@ -33,8 +31,6 @@ export function ProfileForm({ user, update }: ProfileFormProps) { }) const onSubmit = async (data: UpdateProfileInput) => { - setIsLoading(true) - try { const response = await fetch("/api/user/profile", { method: "PATCH", @@ -57,8 +53,6 @@ export function ProfileForm({ user, update }: ProfileFormProps) { toast.success("Profile updated successfully!") } catch { toast.error("An unexpected error occurred") - } finally { - setIsLoading(false) } } @@ -102,8 +96,8 @@ export function ProfileForm({ user, update }: ProfileFormProps) { </FormItem> )} /> - <Button type="submit" disabled={isLoading}> - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Button type="submit" disabled={form.formState.isSubmitting}> + {form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} <Save className="mr-2 h-4 w-4" /> Save Changes </Button> diff --git a/src/app/settings/profile-image.tsx b/src/app/settings/profile-image.tsx index 7b4114d..73c7e7a 100644 --- a/src/app/settings/profile-image.tsx +++ b/src/app/settings/profile-image.tsx @@ -97,7 +97,7 @@ export function ProfileImage({ user, update }: ProfileImageProps) { </CardTitle> <CardDescription> Upload or update your profile picture - </CardDescription> + </CardDescription> </CardHeader> <CardContent> <div className="flex items-center space-x-6"> @@ -136,7 +136,6 @@ export function ProfileImage({ user, update }: ProfileImageProps) { <p className="text-xs text-muted-foreground"> Supported formats: JPEG, PNG, WebP, GIF. Maximum size: 10MB. - Images will be resized to 400x400 pixels. </p> <input diff --git a/src/app/settings/settings-content.tsx b/src/app/settings/settings-content.tsx deleted file mode 100644 index 8916b9e..0000000 --- a/src/app/settings/settings-content.tsx +++ /dev/null @@ -1,537 +0,0 @@ -"use client" - -import { useState } from "react" -import { useSession } from "next-auth/react" -import { useRouter } from "next/navigation" -import { Shield, Loader2, Copy } from "lucide-react" -import { toast } from "sonner" -import Image from "next/image" - -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { Separator } from "@/components/ui/separator" -import Navbar from "@/components/Navbar" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Camera, Lock, Save, Trash2, Upload, User } from "lucide-react" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { updateProfileSchema, updatePasswordSchema, type UpdateProfileInput } from "@/lib/validation" -import { z } from "zod" - -// Re-using existing types and schemas from previous implementation -const passwordChangeSchema = updatePasswordSchema.extend({ - confirmPassword: z.string() -}).refine((data) => data.newPassword === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], -}) - -type ProfileFormData = UpdateProfileInput -type PasswordFormData = z.infer<typeof passwordChangeSchema> - -interface SettingsContentProps { - initialUser: { - name: string - email: string - image: string | null - twoFactorEnabled: boolean - } -} - -export default function SettingsContent({ initialUser }: SettingsContentProps) { - const { update } = useSession() - const router = useRouter() - const [twoFactorEnabled, setTwoFactorEnabled] = useState(initialUser.twoFactorEnabled) - const [is2FALoading, setIs2FALoading] = useState(false) - const [setupData, setSetupData] = useState<{ secret: string;qrCode: string } | null>(null) - const [verificationCode, setVerificationCode] = useState("") - const [isDialogOpen, setIsDialogOpen] = useState(false) - - // Existing state for other forms - const [isLoading, setIsLoading] = useState(false) - const [isImageLoading, setIsImageLoading] = useState(false) - const [profileImageUrl, setProfileImageUrl] = useState<string | null>(initialUser.image) - - const profileForm = useForm<ProfileFormData>({ - resolver: zodResolver(updateProfileSchema), - defaultValues: { - name: initialUser.name, - email: initialUser.email, - }, - }) - - const passwordForm = useForm<PasswordFormData>({ - resolver: zodResolver(passwordChangeSchema), - defaultValues: { - currentPassword: "", - newPassword: "", - confirmPassword: "", - }, - }) - - // 2FA Handlers - const start2FASetup = async () => { - setIs2FALoading(true) - try { - const res = await fetch("/api/user/2fa", { method: "PUT" }) - const data = await res.json() - if (data.error) throw new Error(data.error) - setSetupData(data) - setIsDialogOpen(true) - } catch (error) { - toast.error("Failed to start 2FA setup") - } finally { - setIs2FALoading(false) - } - } - - const verifyAndEnable2FA = async () => { - if (verificationCode.length !== 6) { - toast.error("Please enter a 6-digit code") - return - } - - setIs2FALoading(true) - try { - const res = await fetch("/api/user/2fa", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code: verificationCode, - secret: setupData?.secret, - }), - }) - const data = await res.json() - - if (data.error) throw new Error(data.error) - - setTwoFactorEnabled(true) - setIsDialogOpen(false) - toast.success("Two-factor authentication enabled") - router.refresh() - } catch (error) { - toast.error("Invalid verification code") - } finally { - setIs2FALoading(false) - setVerificationCode("") - } - } - - const disable2FA = async () => { - if (!confirm("Are you sure you want to disable 2FA? This will make your account less secure.")) return - - setIs2FALoading(true) - try { - const res = await fetch("/api/user/2fa", { method: "DELETE" }) - const data = await res.json() - - if (data.error) throw new Error(data.error) - - setTwoFactorEnabled(false) - toast.success("Two-factor authentication disabled") - router.refresh() - } catch (error) { - toast.error("Failed to disable 2FA") - } finally { - setIs2FALoading(false) - } - } - - const copyToClipboard = () => { - if (setupData?.secret) { - navigator.clipboard.writeText(setupData.secret) - toast.success("Secret copied to clipboard") - } - } - - // Existing Handlers (Profile, Password, Image) - const onProfileSubmit = async (data: ProfileFormData) => { - setIsLoading(true) - try { - const response = await fetch("/api/user/profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }) - const result = await response.json() - if (!response.ok) { - toast.error(result.error || "Failed to update profile") - return - } - await update({ name: data.name, email: data.email }) - toast.success("Profile updated successfully!") - } catch { - toast.error("An unexpected error occurred") - } finally { - setIsLoading(false) - } - } - - const onPasswordSubmit = async (data: PasswordFormData) => { - setIsLoading(true) - try { - const response = await fetch("/api/user/password", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword: data.currentPassword, - newPassword: data.newPassword, - }), - }) - const result = await response.json() - if (!response.ok) { - toast.error(result.error || "Failed to update password") - return - } - toast.success("Password updated successfully!") - passwordForm.reset() - } catch { - toast.error("An unexpected error occurred") - } finally { - setIsLoading(false) - } - } - - const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { - const file = event.target.files?.[0] - if (!file) return - setIsImageLoading(true) - try { - const formData = new FormData() - formData.append('image', file) - const response = await fetch('/api/user/profile-image', { - method: 'POST', - body: formData, - }) - const result = await response.json() - if (!response.ok) { - toast.error(result.error || 'Failed to upload image') - return - } - setProfileImageUrl(result.profileImage.url) - toast.success('Profile image uploaded successfully!') - await update({ image: result.profileImage.url }) - } catch { - toast.error('An unexpected error occurred') - } finally { - setIsImageLoading(false) - } - } - - const handleImageDelete = async () => { - setIsImageLoading(true) - try { - const response = await fetch('/api/user/profile-image', { method: 'DELETE' }) - const result = await response.json() - if (!response.ok) { - toast.error(result.error || 'Failed to delete image') - return - } - setProfileImageUrl(null) - toast.success('Profile image deleted successfully!') - await update({ image: null }) - } catch { - toast.error('An unexpected error occurred') - } finally { - setIsImageLoading(false) - } - } - - return ( - <div className="min-h-screen bg-background"> - <Navbar /> - - <div className="container mx-auto px-4 py-8 max-w-2xl"> - <div className="space-y-6"> - <div> - <h1 className="text-3xl font-bold">Account Settings</h1> - <p className="text-muted-foreground"> - Manage your account information and security settings - </p> - </div> - - {/* Profile Information */} - <Card> - <CardHeader> - <CardTitle className="flex items-center"> - <User className="mr-2 h-5 w-5" /> - Profile Information - </CardTitle> - <CardDescription>Update your personal information</CardDescription> - </CardHeader> - <CardContent> - <Form {...profileForm}> - <form onSubmit={profileForm.handleSubmit(onProfileSubmit)} className="space-y-4"> - <FormField - control={profileForm.control} - name="name" - render={({ field }) => ( - <FormItem> - <FormLabel>Full Name</FormLabel> - <FormControl> - <Input placeholder="John Doe" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={profileForm.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email Address</FormLabel> - <FormControl> - <Input type="email" placeholder="john@example.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <Button type="submit" disabled={isLoading}> - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Save className="mr-2 h-4 w-4" /> - Save Changes - </Button> - </form> - </Form> - </CardContent> - </Card> - - <Separator /> - - {/* Profile Image */} - <Card> - <CardHeader> - <CardTitle className="flex items-center"> - <Camera className="mr-2 h-5 w-5" /> - Profile Image - </CardTitle> - <CardDescription>Upload or update your profile picture</CardDescription> - </CardHeader> - <CardContent> - <div className="flex items-center space-x-6"> - <Avatar className="h-24 w-24"> - <AvatarImage src={profileImageUrl || undefined} alt="Profile" /> - <AvatarFallback className="text-lg"> - {initialUser.name.charAt(0).toUpperCase()} - </AvatarFallback> - </Avatar> - - <div className="flex-1 space-y-3"> - <div className="flex items-center space-x-3"> - <div className="relative"> - <Button - disabled={isImageLoading} - variant="outline" - className="relative" - > - {isImageLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Upload className="mr-2 h-4 w-4" /> - {profileImageUrl ? 'Change Image' : 'Upload Image'} - <input - type="file" - accept="image/jpeg,image/jpg,image/png,image/webp,image/gif" - onChange={handleImageUpload} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - </Button> - </div> - - {profileImageUrl && ( - <Button - onClick={handleImageDelete} - disabled={isImageLoading} - variant="outline" - className="text-destructive hover:text-destructive" - > - {isImageLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Trash2 className="mr-2 h-4 w-4" /> - Remove - </Button> - )} - </div> - <p className="text-xs text-muted-foreground"> - Supported formats: JPEG, PNG, WebP, GIF. Maximum size: 10MB. - </p> - </div> - </div> - </CardContent> - </Card> - - <Separator /> - - {/* Two-Factor Authentication */} - <Card> - <CardHeader> - <CardTitle className="flex items-center"> - <Shield className="mr-2 h-5 w-5" /> - Two-Factor Authentication - </CardTitle> - <CardDescription> - Add an extra layer of security to your account - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="flex items-center justify-between"> - <div className="space-y-1"> - <p className="font-medium">Status: {twoFactorEnabled ? "Enabled" : "Disabled"}</p> - <p className="text-sm text-muted-foreground"> - {twoFactorEnabled - ? "Your account is secured with 2FA." - : "Protect your account by enabling 2FA."} - </p> - </div> - {twoFactorEnabled ? ( - <Button - variant="destructive" - onClick={disable2FA} - disabled={is2FALoading} - > - {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Disable 2FA - </Button> - ) : ( - <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> - <DialogTrigger asChild> - <Button onClick={start2FASetup} disabled={is2FALoading}> - {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Enable 2FA - </Button> - </DialogTrigger> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Set up Two-Factor Authentication</DialogTitle> - <DialogDescription> - Scan the QR code with your authenticator app (like Google Authenticator or Authy). - </DialogDescription> - </DialogHeader> - - {setupData && ( - <div className="flex flex-col items-center space-y-4 py-4"> - <div className="relative w-48 h-48"> - <Image - src={setupData.qrCode} - alt="2FA QR Code" - fill - style={{ objectFit: "contain" }} - /> - </div> - - <div className="flex items-center space-x-2"> - <code className="bg-muted px-2 py-1 rounded text-sm"> - {setupData.secret} - </code> - <Button size="icon" variant="ghost" onClick={copyToClipboard}> - <Copy className="h-4 w-4" /> - </Button> - </div> - - <div className="w-full space-y-2"> - <Label htmlFor="code">Verification Code</Label> - <Input - id="code" - placeholder="Enter 6-digit code" - value={verificationCode} - onChange={(e) => setVerificationCode(e.target.value.slice(0, 6))} - maxLength={6} - /> - </div> - - <Button - className="w-full" - onClick={verifyAndEnable2FA} - disabled={verificationCode.length !== 6 || is2FALoading} - > - {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Verify & Enable - </Button> - </div> - )} - </DialogContent> - </Dialog> - )} - </div> - </CardContent> - </Card> - - <Separator /> - - {/* Password Change */} - <Card> - <CardHeader> - <CardTitle className="flex items-center"> - <Lock className="mr-2 h-5 w-5" /> - Change Password - </CardTitle> - <CardDescription> - Update your password to keep your account secure - </CardDescription> - </CardHeader> - <CardContent> - <Form {...passwordForm}> - <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4"> - <FormField - control={passwordForm.control} - name="currentPassword" - render={({ field }) => ( - <FormItem> - <FormLabel>Current Password</FormLabel> - <FormControl> - <Input type="password" placeholder="Enter current password" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={passwordForm.control} - name="newPassword" - render={({ field }) => ( - <FormItem> - <FormLabel>New Password</FormLabel> - <FormControl> - <Input type="password" placeholder="Enter new password" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={passwordForm.control} - name="confirmPassword" - render={({ field }) => ( - <FormItem> - <FormLabel>Confirm New Password</FormLabel> - <FormControl> - <Input type="password" placeholder="Confirm new password" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <Button type="submit" disabled={isLoading}> - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Save className="mr-2 h-4 w-4" /> - Update Password - </Button> - </form> - </Form> - </CardContent> - </Card> - </div> - </div> - </div> - ) -} diff --git a/src/app/settings/settings-form.tsx b/src/app/settings/settings-form.tsx index 477473d..8b09cdf 100644 --- a/src/app/settings/settings-form.tsx +++ b/src/app/settings/settings-form.tsx @@ -5,12 +5,14 @@ import { Separator } from "@/components/ui/separator" import { ProfileForm } from "./profile-form" import { ProfileImage } from "./profile-image" import { PasswordForm } from "./password-form" +import { TwoFactorForm } from "./two-factor-form" interface SettingsFormProps { user: { name?: string | null email?: string | null image?: string | null + twoFactorEnabled: boolean } } @@ -31,6 +33,8 @@ export function SettingsForm({ user }: SettingsFormProps) { <Separator /> <ProfileImage user={user} update={update} /> <Separator /> + <TwoFactorForm twoFactorEnabled={user.twoFactorEnabled} /> + <Separator /> <PasswordForm /> </div> </div> |
