From 796bde22e75ffd513543fe4f9353dfabf327ad40 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 1 Dec 2025 21:26:26 +0000 Subject: [PATCH] feat: Add comprehensive input validation - Created lib/validation.ts with validation utilities for all form inputs - Added validation for list titles, item titles, colors, dates, tags, descriptions, and notes - Implemented inline validation with real-time error feedback in ListForm - Enhanced ItemForm with comprehensive validation for all fields - Updated InlineItemCreate with validation - Added input sanitization to prevent XSS attacks - Updated server actions to use validation utilities - Added maxLength attributes to prevent excessive input - Improved accessibility with aria-invalid and aria-describedby attributes - Added visual feedback for validation errors with red borders - Validates Requirements 1.1 and 2.1 --- .kiro/specs/ultimate-lists-app/tasks.md | 4 +- components/InlineItemCreate.tsx | 51 +++- components/ItemForm.tsx | 234 ++++++++++++++++-- components/ListForm.tsx | 51 +++- lib/actions.ts | 107 ++++++--- lib/validation.ts | 303 ++++++++++++++++++++++++ 6 files changed, 667 insertions(+), 83 deletions(-) create mode 100644 lib/validation.ts diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 24bd1d5..153e227 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -199,7 +199,7 @@ - Implement skeleton loaders - _Requirements: 7.3_ -- [-] 16. Implement error handling +- [x] 16. Implement error handling - Add try-catch blocks to all server actions - Implement error boundaries in React components - Add user-friendly error messages @@ -207,7 +207,7 @@ - Implement retry logic for failed operations - _Requirements: All (error handling is cross-cutting)_ -- [ ] 17. Add input validation +- [x] 17. Add input validation - Validate all form inputs before submission - Add inline validation errors - Prevent empty task/list creation diff --git a/components/InlineItemCreate.tsx b/components/InlineItemCreate.tsx index bf757a5..111cae7 100644 --- a/components/InlineItemCreate.tsx +++ b/components/InlineItemCreate.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { createItem } from '@/lib/actions' import { Plus } from 'lucide-react' import { useToast } from './ToastContainer' +import { validateItemTitle, sanitizeString } from '@/lib/validation' interface InlineItemCreateProps { listId: string @@ -16,25 +17,36 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) { const [isCreating, setIsCreating] = useState(false) const [title, setTitle] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!title.trim() || isSubmitting) { + // Validate title + const validationResult = validateItemTitle(title) + if (!validationResult.isValid) { + setError(validationResult.error || 'Invalid title') + return + } + + if (isSubmitting) { return } setIsSubmitting(true) + setError('') try { - await createItem(listId, title.trim()) + await createItem(listId, sanitizeString(title)) showToast('Task added successfully', 'success') setTitle('') setIsCreating(false) router.refresh() } catch (error) { console.error('Error creating item:', error) - showToast('Failed to add task', 'error') + const errorMessage = error instanceof Error ? error.message : 'Failed to add task' + setError(errorMessage) + showToast(errorMessage, 'error') } finally { setIsSubmitting(false) } @@ -65,15 +77,30 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) { return (
- setTitle(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Enter task title..." - autoFocus - className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 mb-3 sm:mb-4 text-sm sm:text-base" - /> +
+ { + setTitle(e.target.value) + setError('') + }} + onKeyDown={handleKeyDown} + placeholder="Enter task title..." + autoFocus + maxLength={200} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500' + }`} + aria-invalid={error ? 'true' : 'false'} + aria-describedby={error ? 'inline-title-error' : undefined} + /> + {error && ( +

+ {error} +

+ )} +
@@ -147,10 +275,23 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) id="description" value={description} onChange={(e) => setDescription(e.target.value)} + onBlur={() => handleBlur('description')} rows={3} - className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base resize-y" + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${ + errors.description && touched.description + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} placeholder="Add a description" + maxLength={2000} + aria-invalid={errors.description && touched.description ? 'true' : 'false'} + aria-describedby={errors.description && touched.description ? 'description-error' : undefined} /> + {errors.description && touched.description && ( +

+ {errors.description} +

+ )} {/* Priority */} @@ -180,14 +321,28 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) id="dueDate" value={dueDate} onChange={(e) => setDueDate(e.target.value)} - className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target" + onBlur={() => handleBlur('dueDate')} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base tap-target ${ + errors.dueDate && touched.dueDate + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + min="1900-01-01" + max="2100-12-31" + aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'} + aria-describedby={errors.dueDate && touched.dueDate ? 'dueDate-error' : undefined} /> + {errors.dueDate && touched.dueDate && ( +

+ {errors.dueDate} +

+ )} {/* Tags */}
setTagInput(e.target.value)} onKeyDown={handleKeyDown} - className="flex-1 px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base" + className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + errors.tagInput + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} placeholder="Add a tag" + maxLength={50} + aria-invalid={errors.tagInput ? 'true' : 'false'} + aria-describedby={errors.tagInput ? 'tagInput-error' : undefined} />
+ {errors.tagInput && ( +

+ {errors.tagInput} +

+ )} + {errors.tags && ( +

{errors.tags}

+ )} {tags.length > 0 && (
{tags.map((tag) => ( @@ -238,10 +409,23 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} + onBlur={() => handleBlur('notes')} rows={5} - className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base resize-y" + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${ + errors.notes && touched.notes + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} placeholder="Add additional notes" + maxLength={5000} + aria-invalid={errors.notes && touched.notes ? 'true' : 'false'} + aria-describedby={errors.notes && touched.notes ? 'notes-error' : undefined} /> + {errors.notes && touched.notes && ( +

+ {errors.notes} +

+ )}
{/* Error message */} diff --git a/components/ListForm.tsx b/components/ListForm.tsx index d7ed548..9e36c1d 100644 --- a/components/ListForm.tsx +++ b/components/ListForm.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { createList, updateList } from '@/lib/actions' import { useRouter } from 'next/navigation' import { useToast } from './ToastContainer' +import { validateListTitle } from '@/lib/validation' interface ListFormProps { list?: { @@ -33,12 +34,30 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) { const [color, setColor] = useState(list?.color || PRESET_COLORS[0]) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState('') + const [touched, setTouched] = useState(false) + + // Inline validation when title changes + useEffect(() => { + if (touched) { + const result = validateListTitle(title) + setError(result.isValid ? '' : result.error || '') + } + }, [title, touched]) + + const handleTitleBlur = () => { + setTouched(true) + const result = validateListTitle(title) + setError(result.isValid ? '' : result.error || '') + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!title.trim()) { - setError('List title is required') + // Validate before submission + const result = validateListTitle(title) + if (!result.isValid) { + setError(result.error || 'Invalid input') + setTouched(true) return } @@ -62,8 +81,9 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) { router.refresh() } } catch (err) { - setError('Failed to save list. Please try again.') - showToast('Failed to save list', 'error') + const errorMessage = err instanceof Error ? err.message : 'Failed to save list. Please try again.' + setError(errorMessage) + showToast(errorMessage, 'error') console.error(err) } finally { setIsSubmitting(false) @@ -74,18 +94,31 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
setTitle(e.target.value)} - className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base" + onBlur={handleTitleBlur} + className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ + error && touched + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} placeholder="Enter list title" disabled={isSubmitting} autoFocus + maxLength={200} + aria-invalid={error && touched ? 'true' : 'false'} + aria-describedby={error && touched ? 'title-error' : undefined} /> + {error && touched && ( +

+ {error} +

+ )}
@@ -111,10 +144,6 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
- {error && ( -
{error}
- )} -
{onCancel && (