diff --git a/.kiro/specs/user-auth-and-sharing/tasks.md b/.kiro/specs/user-auth-and-sharing/tasks.md index 07984ec..e30d9d4 100644 --- a/.kiro/specs/user-auth-and-sharing/tasks.md +++ b/.kiro/specs/user-auth-and-sharing/tasks.md @@ -200,7 +200,7 @@ - **Property 24: Permission update enforcement** - **Validates: Requirements 6.5** -- [ ] 13. Create sharing UI components +- [x] 13. Create sharing UI components - Build InvitationCard component - Build CollaboratorList component - Build ShareListModal component @@ -208,7 +208,7 @@ - Add invitations page - _Requirements: 5.1, 5.4, 8.1, 8.2_ -- [ ] 14. Implement profile management +- [-] 14. Implement profile management - Create profile page and ProfileForm component - Implement updateProfile server action - Create PasswordChangeForm component diff --git a/app/page.tsx b/app/page.tsx index 695034d..fcea3fc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,17 +3,38 @@ import ListCard from '@/components/ListCard' import NewListButton from '@/components/NewListButton' import GlobalSearch from '@/components/GlobalSearch' import EmptyState from '@/components/EmptyState' +import Link from 'next/link' +import { auth } from '@/auth' export const dynamic = 'force-dynamic' export default async function Home() { const lists = await getLists() + const session = await auth() return (

Ultimate Lists

- +
+ {session?.user && ( + <> + + Invitations + + + Profile + + + )} + +
{/* Global Search */} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..e75b53c --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,119 @@ +import { redirect } from 'next/navigation' +import { auth } from '@/auth' +import { getUserProfile } from '@/lib/auth-actions' +import ProfileForm from '@/components/ProfileForm' +import PasswordChangeForm from '@/components/PasswordChangeForm' +import Link from 'next/link' + +export const metadata = { + title: 'Profile - Ultimate Lists', + description: 'Manage your profile and account settings', +} + +export default async function ProfilePage() { + const session = await auth() + + if (!session?.user?.id) { + redirect('/auth/login') + } + + const userProfile = await getUserProfile(session.user.id) + + if (!userProfile) { + redirect('/auth/login') + } + + return ( +
+
+ {/* Header */} +
+ + + + + Back to Lists + +

+ Profile Settings +

+

+ Manage your account information and security settings +

+
+ + {/* Profile Information Section */} +
+

+ Profile Information +

+

+ Update your name and email address +

+ +
+ + {/* Password Section */} +
+

+ Change Password +

+

+ Update your password to keep your account secure +

+ +
+ + {/* Account Information */} +
+

+ Account Information +

+
+
+ Account ID + {userProfile.id} +
+
+ Member Since + + {new Date(userProfile.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+ Last Updated + + {new Date(userProfile.updatedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+
+
+
+ ) +} diff --git a/components/PasswordChangeForm.tsx b/components/PasswordChangeForm.tsx new file mode 100644 index 0000000..484de4d --- /dev/null +++ b/components/PasswordChangeForm.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useState, useEffect } from 'react' +import { validatePassword } from '@/lib/validation' +import { updatePassword } from '@/lib/auth-actions' +import { useToast } from './ToastContainer' + +interface PasswordChangeFormProps { + userId: string +} + +export default function PasswordChangeForm({ userId }: PasswordChangeFormProps) { + const { showToast } = useToast() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [errors, setErrors] = useState<{ + currentPassword?: string + newPassword?: string + confirmPassword?: string + }>({}) + const [touched, setTouched] = useState<{ + currentPassword: boolean + newPassword: boolean + confirmPassword: boolean + }>({ + currentPassword: false, + newPassword: false, + confirmPassword: false, + }) + + // Inline validation when fields change + useEffect(() => { + if (touched.currentPassword) { + if (!currentPassword) { + setErrors((prev) => ({ ...prev, currentPassword: 'Current password is required' })) + } else { + setErrors((prev) => ({ ...prev, currentPassword: undefined })) + } + } + }, [currentPassword, touched.currentPassword]) + + useEffect(() => { + if (touched.newPassword) { + const result = validatePassword(newPassword) + setErrors((prev) => ({ + ...prev, + newPassword: result.isValid ? undefined : result.error, + })) + } + }, [newPassword, touched.newPassword]) + + useEffect(() => { + if (touched.confirmPassword) { + if (!confirmPassword) { + setErrors((prev) => ({ ...prev, confirmPassword: 'Please confirm your new password' })) + } else if (newPassword !== confirmPassword) { + setErrors((prev) => ({ ...prev, confirmPassword: 'Passwords do not match' })) + } else { + setErrors((prev) => ({ ...prev, confirmPassword: undefined })) + } + } + }, [confirmPassword, newPassword, touched.confirmPassword]) + + const handleCurrentPasswordBlur = () => { + setTouched((prev) => ({ ...prev, currentPassword: true })) + } + + const handleNewPasswordBlur = () => { + setTouched((prev) => ({ ...prev, newPassword: true })) + } + + const handleConfirmPasswordBlur = () => { + setTouched((prev) => ({ ...prev, confirmPassword: true })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate all fields before submission + const newPasswordResult = validatePassword(newPassword) + + const newErrors: { + currentPassword?: string + newPassword?: string + confirmPassword?: string + } = {} + + if (!currentPassword) { + newErrors.currentPassword = 'Current password is required' + } + + if (!newPasswordResult.isValid) { + newErrors.newPassword = newPasswordResult.error + } + + if (!confirmPassword) { + newErrors.confirmPassword = 'Please confirm your new password' + } else if (newPassword !== confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match' + } + + if (currentPassword === newPassword) { + newErrors.newPassword = 'New password must be different from current password' + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + setTouched({ currentPassword: true, newPassword: true, confirmPassword: true }) + return + } + + setIsSubmitting(true) + setErrors({}) + + try { + const result = await updatePassword(userId, currentPassword, newPassword) + + if (!result.success) { + if (result.error === 'Current password is incorrect') { + setErrors({ currentPassword: 'Current password is incorrect' }) + showToast('Current password is incorrect', 'error') + } else { + showToast(result.error || 'Password update failed', 'error') + } + } else { + showToast('Password updated successfully', 'success') + // Reset form + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + setTouched({ currentPassword: false, newPassword: false, confirmPassword: false }) + } + } catch (err) { + console.error('Password update error:', err) + showToast('An error occurred. Please try again.', 'error') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + setCurrentPassword(e.target.value)} + onBlur={handleCurrentPasswordBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.currentPassword && touched.currentPassword + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="Enter your current password" + disabled={isSubmitting} + autoComplete="current-password" + aria-invalid={errors.currentPassword && touched.currentPassword ? 'true' : 'false'} + aria-describedby={errors.currentPassword && touched.currentPassword ? 'current-password-error' : undefined} + /> + {errors.currentPassword && touched.currentPassword && ( +

+ {errors.currentPassword} +

+ )} +
+ +
+ + setNewPassword(e.target.value)} + onBlur={handleNewPasswordBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.newPassword && touched.newPassword + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="At least 8 characters" + disabled={isSubmitting} + autoComplete="new-password" + aria-invalid={errors.newPassword && touched.newPassword ? 'true' : 'false'} + aria-describedby={errors.newPassword && touched.newPassword ? 'new-password-error' : undefined} + /> + {errors.newPassword && touched.newPassword && ( +

+ {errors.newPassword} +

+ )} + {!errors.newPassword && touched.newPassword && ( +

+ Password must be at least 8 characters +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + onBlur={handleConfirmPasswordBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.confirmPassword && touched.confirmPassword + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="Re-enter your new password" + disabled={isSubmitting} + autoComplete="new-password" + aria-invalid={errors.confirmPassword && touched.confirmPassword ? 'true' : 'false'} + aria-describedby={ + errors.confirmPassword && touched.confirmPassword ? 'confirm-password-error' : undefined + } + /> + {errors.confirmPassword && touched.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} +
+ + +
+ ) +} diff --git a/components/ProfileForm.tsx b/components/ProfileForm.tsx new file mode 100644 index 0000000..dba24b3 --- /dev/null +++ b/components/ProfileForm.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useState, useEffect } from 'react' +import { validateEmail, sanitizeString } from '@/lib/validation' +import { updateProfile } from '@/lib/auth-actions' +import { useToast } from './ToastContainer' + +interface ProfileFormProps { + initialName: string | null + initialEmail: string + userId: string +} + +export default function ProfileForm({ initialName, initialEmail, userId }: ProfileFormProps) { + const { showToast } = useToast() + const [name, setName] = useState(initialName || '') + const [email, setEmail] = useState(initialEmail) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errors, setErrors] = useState<{ + name?: string + email?: string + }>({}) + const [touched, setTouched] = useState<{ + name: boolean + email: boolean + }>({ + name: false, + email: false, + }) + + // Check if form has changes + const hasChanges = name !== (initialName || '') || email !== initialEmail + + // Inline validation when fields change + useEffect(() => { + if (touched.name) { + const sanitized = sanitizeString(name) + if (!sanitized) { + setErrors((prev) => ({ ...prev, name: 'Name is required' })) + } else if (sanitized.length > 100) { + setErrors((prev) => ({ ...prev, name: 'Name must be less than 100 characters' })) + } else { + setErrors((prev) => ({ ...prev, name: undefined })) + } + } + }, [name, touched.name]) + + useEffect(() => { + if (touched.email) { + const result = validateEmail(email) + setErrors((prev) => ({ + ...prev, + email: result.isValid ? undefined : result.error, + })) + } + }, [email, touched.email]) + + const handleNameBlur = () => { + setTouched((prev) => ({ ...prev, name: true })) + } + + const handleEmailBlur = () => { + setTouched((prev) => ({ ...prev, email: true })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate all fields before submission + const sanitizedName = sanitizeString(name) + const emailResult = validateEmail(email) + + const newErrors: { + name?: string + email?: string + } = {} + + if (!sanitizedName) { + newErrors.name = 'Name is required' + } else if (sanitizedName.length > 100) { + newErrors.name = 'Name must be less than 100 characters' + } + + if (!emailResult.isValid) { + newErrors.email = emailResult.error + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + setTouched({ name: true, email: true }) + return + } + + if (!hasChanges) { + showToast('No changes to save', 'info') + return + } + + setIsSubmitting(true) + setErrors({}) + + try { + const updateData: { name?: string; email?: string } = {} + + if (name !== (initialName || '')) { + updateData.name = sanitizedName + } + + if (email !== initialEmail) { + updateData.email = email.trim() + } + + const result = await updateProfile(userId, updateData) + + if (!result.success) { + if (result.error === 'Email already in use') { + setErrors({ email: 'This email is already in use' }) + showToast('Email already in use', 'error') + } else { + showToast(result.error || 'Profile update failed', 'error') + } + } else { + showToast('Profile updated successfully', 'success') + // Refresh the page to update the session + window.location.reload() + } + } catch (err) { + console.error('Profile update error:', err) + showToast('An error occurred. Please try again.', 'error') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + setName(e.target.value)} + onBlur={handleNameBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.name && touched.name + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="Enter your name" + disabled={isSubmitting} + autoComplete="name" + maxLength={100} + aria-invalid={errors.name && touched.name ? 'true' : 'false'} + aria-describedby={errors.name && touched.name ? 'name-error' : undefined} + /> + {errors.name && touched.name && ( +

+ {errors.name} +

+ )} +
+ +
+ + setEmail(e.target.value)} + onBlur={handleEmailBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.email && touched.email + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="Enter your email" + disabled={isSubmitting} + autoComplete="email" + aria-invalid={errors.email && touched.email ? 'true' : 'false'} + aria-describedby={errors.email && touched.email ? 'email-error' : undefined} + /> + {errors.email && touched.email && ( +

+ {errors.email} +

+ )} +
+ + +
+ ) +} diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index 1948446..23126f6 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -173,3 +173,157 @@ export async function isAuthenticated(): Promise { const session = await getCurrentSession() return !!session?.user } + +// Profile management schemas +const updateProfileSchema = z.object({ + name: z.string().min(1, { message: 'Name is required' }).max(100, { message: 'Name must be less than 100 characters' }).optional(), + email: z.string().email({ message: 'Invalid email address' }).optional(), +}) + +const updatePasswordSchema = z.object({ + currentPassword: z.string().min(1, { message: 'Current password is required' }), + newPassword: z.string().min(8, { message: 'New password must be at least 8 characters' }), +}) + +export type UpdateProfileResult = + | { success: true } + | { success: false; error: string } + +export type UpdatePasswordResult = + | { success: true } + | { success: false; error: string } + +/** + * Update user profile information + * Requirements: 7.1, 7.4, 7.5 + */ +export async function updateProfile( + userId: string, + data: { name?: string; email?: string } +): Promise { + try { + // Validate input + const validation = updateProfileSchema.safeParse(data) + + if (!validation.success) { + const errors = validation.error.issues.map((issue) => issue.message).join(', ') + return { success: false, error: errors } + } + + const { name, email } = validation.data + + // Check if there's anything to update + if (!name && !email) { + return { success: false, error: 'No data to update' } + } + + // If email is being updated, check uniqueness (Requirement 7.4) + if (email) { + const existingUser = await prisma.user.findUnique({ + where: { email }, + }) + + // Check if email is already in use by another user + if (existingUser && existingUser.id !== userId) { + return { success: false, error: 'Email already in use' } + } + } + + // Update user profile (Requirement 7.1) + await prisma.user.update({ + where: { id: userId }, + data: { + ...(name !== undefined && { name }), + ...(email !== undefined && { email }), + }, + }) + + return { success: true } + } catch (error) { + console.error('Profile update error:', error) + + // Handle unique constraint violation + if (error instanceof Error && error.message.includes('Unique constraint')) { + return { success: false, error: 'Email already in use' } + } + + return { success: false, error: 'Profile update failed. Please try again.' } + } +} + +/** + * Update user password + * Requirements: 7.2, 7.3 + */ +export async function updatePassword( + userId: string, + currentPassword: string, + newPassword: string +): Promise { + try { + // Validate input + const validation = updatePasswordSchema.safeParse({ currentPassword, newPassword }) + + if (!validation.success) { + const errors = validation.error.issues.map((issue) => issue.message).join(', ') + return { success: false, error: errors } + } + + // Get user from database + const user = await prisma.user.findUnique({ + where: { id: userId }, + }) + + if (!user || !user.passwordHash) { + return { success: false, error: 'User not found' } + } + + // Verify current password (Requirement 7.2) + const isCurrentPasswordValid = await bcrypt.compare(currentPassword, user.passwordHash) + + if (!isCurrentPasswordValid) { + return { success: false, error: 'Current password is incorrect' } + } + + // Hash new password (Requirement 7.3) + const newPasswordHash = await bcrypt.hash(newPassword, 10) + + // Update password + await prisma.user.update({ + where: { id: userId }, + data: { + passwordHash: newPasswordHash, + }, + }) + + return { success: true } + } catch (error) { + console.error('Password update error:', error) + return { success: false, error: 'Password update failed. Please try again.' } + } +} + +/** + * Get user profile (without sensitive data) + * Requirement: 7.5 - Profile data exposure protection + */ +export async function getUserProfile(userId: string) { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + // Explicitly exclude passwordHash (Requirement 7.5) + }, + }) + + return user + } catch (error) { + console.error('Get user profile error:', error) + return null + } +}