aboutsummaryrefslogtreecommitdiff
path: root/src/app/settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/settings')
-rw-r--r--src/app/settings/page.tsx19
-rw-r--r--src/app/settings/password-form.tsx9
-rw-r--r--src/app/settings/profile-form.tsx12
-rw-r--r--src/app/settings/profile-image.tsx3
-rw-r--r--src/app/settings/settings-content.tsx537
-rw-r--r--src/app/settings/settings-form.tsx4
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>