diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx new file mode 100644 index 0000000..70b3d29 --- /dev/null +++ b/app/auth/error/page.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { Suspense } from 'react' + +function ErrorContent() { + const searchParams = useSearchParams() + const error = searchParams.get('error') + + const getErrorMessage = (errorCode: string | null) => { + switch (errorCode) { + case 'Configuration': + return 'There is a problem with the server configuration.' + case 'AccessDenied': + return 'You do not have permission to sign in.' + case 'Verification': + return 'The verification token has expired or has already been used.' + case 'OAuthSignin': + return 'Error in constructing an authorization URL.' + case 'OAuthCallback': + return 'Error in handling the response from an OAuth provider.' + case 'OAuthCreateAccount': + return 'Could not create OAuth provider user in the database.' + case 'EmailCreateAccount': + return 'Could not create email provider user in the database.' + case 'Callback': + return 'Error in the OAuth callback handler route.' + case 'OAuthAccountNotLinked': + return 'Email on the account is already linked, but not with this OAuth account.' + case 'EmailSignin': + return 'Sending the email with the verification token failed.' + case 'CredentialsSignin': + return 'Invalid email or password. Please check your credentials and try again.' + case 'SessionRequired': + return 'Please sign in to access this page.' + default: + return 'An authentication error occurred. Please try again.' + } + } + + return ( +
+
+
+
+ +
+

+ Authentication Error +

+

+ {getErrorMessage(error)} +

+
+ +
+
+ + Try Logging In Again + + + Create New Account + +
+ + {error && ( +
+

+ Error code: {error} +

+
+ )} +
+
+
+ ) +} + +export default function AuthErrorPage() { + return ( + +
Loading...
+ + }> + +
+ ) +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..a77cbd8 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,53 @@ +import Link from 'next/link' +import LoginForm from '@/components/LoginForm' +import { Suspense } from 'react' + +export const metadata = { + title: 'Login - Ultimate Lists', + description: 'Log in to your Ultimate Lists account', +} + +function LoginContent() { + return ( +
+
+
+

+ Welcome Back +

+

+ Log in to access your lists +

+
+ +
+ + +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+
+ ) +} + +export default function LoginPage() { + return ( + +
Loading...
+ + }> + +
+ ) +} diff --git a/app/auth/register/page.tsx b/app/auth/register/page.tsx new file mode 100644 index 0000000..ac41a47 --- /dev/null +++ b/app/auth/register/page.tsx @@ -0,0 +1,53 @@ +import Link from 'next/link' +import RegisterForm from '@/components/RegisterForm' +import { Suspense } from 'react' + +export const metadata = { + title: 'Register - Ultimate Lists', + description: 'Create your Ultimate Lists account', +} + +function RegisterContent() { + return ( +
+
+
+

+ Create Account +

+

+ Sign up to start organizing your tasks +

+
+ +
+ + +
+

+ Already have an account?{' '} + + Log in + +

+
+
+
+
+ ) +} + +export default function RegisterPage() { + return ( + +
Loading...
+ + }> + +
+ ) +} diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..75a1569 --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState, useEffect } from 'react' +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { validateEmail, validatePassword } from '@/lib/validation' +import { useToast } from './ToastContainer' + +interface LoginFormProps { + callbackUrl?: string +} + +export default function LoginForm({ callbackUrl = '/' }: LoginFormProps) { + const router = useRouter() + const { showToast } = useToast() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [errors, setErrors] = useState<{ email?: string; password?: string }>({}) + const [touched, setTouched] = useState<{ email: boolean; password: boolean }>({ + email: false, + password: false, + }) + + // Inline validation when fields change + useEffect(() => { + if (touched.email) { + const result = validateEmail(email) + setErrors((prev) => ({ + ...prev, + email: result.isValid ? undefined : result.error, + })) + } + }, [email, touched.email]) + + useEffect(() => { + if (touched.password) { + const result = validatePassword(password) + setErrors((prev) => ({ + ...prev, + password: result.isValid ? undefined : result.error, + })) + } + }, [password, touched.password]) + + const handleEmailBlur = () => { + setTouched((prev) => ({ ...prev, email: true })) + const result = validateEmail(email) + setErrors((prev) => ({ + ...prev, + email: result.isValid ? undefined : result.error, + })) + } + + const handlePasswordBlur = () => { + setTouched((prev) => ({ ...prev, password: true })) + const result = validatePassword(password) + setErrors((prev) => ({ + ...prev, + password: result.isValid ? undefined : result.error, + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate all fields before submission + const emailResult = validateEmail(email) + const passwordResult = validatePassword(password) + + const newErrors: { email?: string; password?: string } = {} + if (!emailResult.isValid) { + newErrors.email = emailResult.error + } + if (!passwordResult.isValid) { + newErrors.password = passwordResult.error + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + setTouched({ email: true, password: true }) + return + } + + setIsSubmitting(true) + setErrors({}) + + try { + const result = await signIn('credentials', { + email: email.trim(), + password, + redirect: false, + }) + + if (result?.error) { + showToast('Invalid email or password', 'error') + setErrors({ email: 'Invalid credentials', password: 'Invalid credentials' }) + } else if (result?.ok) { + showToast('Login successful', 'success') + router.push(callbackUrl) + router.refresh() + } + } catch (err) { + console.error('Login error:', err) + showToast('An error occurred. Please try again.', 'error') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + 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" + autoFocus + aria-invalid={errors.email && touched.email ? 'true' : 'false'} + aria-describedby={errors.email && touched.email ? 'email-error' : undefined} + /> + {errors.email && touched.email && ( +

+ {errors.email} +

+ )} +
+ +
+ + setPassword(e.target.value)} + onBlur={handlePasswordBlur} + 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.password && touched.password + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + placeholder="Enter your password" + disabled={isSubmitting} + autoComplete="current-password" + aria-invalid={errors.password && touched.password ? 'true' : 'false'} + aria-describedby={errors.password && touched.password ? 'password-error' : undefined} + /> + {errors.password && touched.password && ( +

+ {errors.password} +

+ )} +
+ + +
+ ) +} diff --git a/components/RegisterForm.tsx b/components/RegisterForm.tsx new file mode 100644 index 0000000..024983e --- /dev/null +++ b/components/RegisterForm.tsx @@ -0,0 +1,300 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { validateEmail, validatePassword, sanitizeString } from '@/lib/validation' +import { registerUser } from '@/lib/auth-actions' +import { useToast } from './ToastContainer' + +interface RegisterFormProps { + callbackUrl?: string +} + +export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) { + const router = useRouter() + const { showToast } = useToast() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [errors, setErrors] = useState<{ + name?: string + email?: string + password?: string + confirmPassword?: string + }>({}) + const [touched, setTouched] = useState<{ + name: boolean + email: boolean + password: boolean + confirmPassword: boolean + }>({ + name: false, + email: false, + password: false, + confirmPassword: false, + }) + + // 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]) + + useEffect(() => { + if (touched.password) { + const result = validatePassword(password) + setErrors((prev) => ({ + ...prev, + password: result.isValid ? undefined : result.error, + })) + } + }, [password, touched.password]) + + useEffect(() => { + if (touched.confirmPassword) { + if (!confirmPassword) { + setErrors((prev) => ({ ...prev, confirmPassword: 'Please confirm your password' })) + } else if (password !== confirmPassword) { + setErrors((prev) => ({ ...prev, confirmPassword: 'Passwords do not match' })) + } else { + setErrors((prev) => ({ ...prev, confirmPassword: undefined })) + } + } + }, [confirmPassword, password, touched.confirmPassword]) + + const handleNameBlur = () => { + setTouched((prev) => ({ ...prev, name: true })) + } + + const handleEmailBlur = () => { + setTouched((prev) => ({ ...prev, email: true })) + } + + const handlePasswordBlur = () => { + setTouched((prev) => ({ ...prev, password: true })) + } + + const handleConfirmPasswordBlur = () => { + setTouched((prev) => ({ ...prev, confirmPassword: true })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate all fields before submission + const sanitizedName = sanitizeString(name) + const emailResult = validateEmail(email) + const passwordResult = validatePassword(password) + + const newErrors: { + name?: string + email?: string + password?: string + confirmPassword?: 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 (!passwordResult.isValid) { + newErrors.password = passwordResult.error + } + + if (!confirmPassword) { + newErrors.confirmPassword = 'Please confirm your password' + } else if (password !== confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match' + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + setTouched({ name: true, email: true, password: true, confirmPassword: true }) + return + } + + setIsSubmitting(true) + setErrors({}) + + try { + const result = await registerUser(email.trim(), password, sanitizedName) + + if (!result.success) { + if (result.error === 'Email already exists') { + setErrors({ email: 'This email is already registered' }) + showToast('Email already exists', 'error') + } else { + showToast(result.error || 'Registration failed', 'error') + } + } else { + showToast('Registration successful! Logging you in...', 'success') + router.push(callbackUrl) + router.refresh() + } + } catch (err) { + console.error('Registration 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" + autoFocus + 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} +

+ )} +
+ +
+ + setPassword(e.target.value)} + onBlur={handlePasswordBlur} + 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.password && touched.password + ? '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.password && touched.password ? 'true' : 'false'} + aria-describedby={errors.password && touched.password ? 'password-error' : undefined} + /> + {errors.password && touched.password && ( +

+ {errors.password} +

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

+ 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 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} +

+ )} +
+ + +
+ ) +}