Compare commits

..

99 Commits

Author SHA1 Message Date
da593e052b Update PWA version for focus improvements
- Bumped service worker cache to v8
- Updated manifest version to 1.0.2
- Forces PWA refresh to pick up keyboard focus improvements
2025-12-14 01:01:22 +00:00
37955f95ab Keep input focused and keyboard open after adding tasks
- Added useRef to manage input focus directly
- Added useEffect to auto-focus when form becomes visible
- Refocus input after successful task creation with setTimeout
- Modified handleBlur to preserve focus when keeping form open
- Provides seamless continuous task input experience on mobile
2025-12-14 00:59:48 +00:00
466bce862e Update PWA manifest and service worker to force iPhone refresh
- Updated manifest.json version to 1.0.1 and description
- Bumped service worker cache version from v6 to v7
- Forces PWA refresh on iPhone Safari to pick up latest changes
2025-12-14 00:56:29 +00:00
3043293fa7 Keep task form open after adding task for continuous input
- Modified InlineItemCreate component to maintain form visibility after successful task creation
- Added keepFormOpen state to prevent form from closing on page revalidation
- Users can now add multiple tasks without clicking 'Add task' button repeatedly
- Form only closes when explicitly cancelled or escaped
2025-12-14 00:40:59 +00:00
1838502b8b fix: prevent hydration error in NotificationPrompt
Add mounted state to prevent SSR rendering.
Component now only renders after client-side mount.
Fixes login blocking hydration error.
2025-12-07 01:59:55 +00:00
6508e27391 chore: remove NotificationDebug component
Debug complete - notifications are working (permission already granted).
Remove debug component to clean up UI.
2025-12-07 01:58:39 +00:00
15b9f196f5 chore: bump service worker cache version to v6
Force update on all PWA installations to pick up:
- NotificationPrompt component
- NotificationDebug component
- Push notification functionality
- Hydration fix
2025-12-07 01:55:28 +00:00
1c5e623c7b fix: prevent hydration error in NotificationDebug component
Add mounted state to prevent rendering during SSR.
Component now only renders after client-side mount.
2025-12-07 01:53:52 +00:00
298ebc5fb9 fix: change PushSubscription endpoint to VARCHAR for MySQL compatibility
MySQL cannot create unique constraints on TEXT fields without length.
Changed endpoint from @db.Text to @db.VarChar(512) to fix Prisma generation error.
2025-12-07 01:50:28 +00:00
374bfb3a62 .env 2025-12-07 01:48:02 +00:00
e095568fbf debug: add notification debug component to troubleshoot iOS 2025-12-07 01:43:00 +00:00
a02b9438af security: remove .env from git tracking and add to .gitignore
BREAKING CHANGE: .env file is no longer tracked in git for security.
Each environment must maintain its own .env file with secrets.
Use .env.example as a template.
2025-12-07 01:35:01 +00:00
3a1a1626fe chore: update package-lock.json for web-push dependencies 2025-12-07 01:32:53 +00:00
f49f62d659 vapid keys 2025-12-07 01:31:33 +00:00
8e7440166f feat: add Docker support for push notifications
- Update docker-compose.yml to include VAPID environment variables
- Create DOCKER_PUSH_NOTIFICATIONS_SETUP.md with Docker-specific instructions
- Create PUSH_NOTIFICATIONS_DOCKER_CHECKLIST.md for easy setup tracking
- Update .env.example with Docker command for generating VAPID keys

Setup for Docker:
1. Generate VAPID keys (npx web-push generate-vapid-keys)
2. Add keys to .env file
3. Run: docker-compose exec app npx prisma db push
4. Rebuild: docker-compose up --build

The web-push package is automatically installed during docker build.
All notification functionality works the same in Docker as locally.
2025-12-07 01:25:48 +00:00
3f1abd527e feat: add auto-prompt for push notifications on app load
- Create NotificationPrompt component that appears 3 seconds after app loads
- Add to main layout so it shows on all pages (for logged-in users)
- Prompt only shows if permission not yet requested
- Users can dismiss temporarily or permanently
- Add slide-up animation for smooth appearance
- Create simplified SETUP_PUSH_NOTIFICATIONS.md guide

Users will now be automatically prompted to enable notifications
instead of having to go to the profile page.
2025-12-07 01:24:20 +00:00
7cda58440f feat: implement complete web push notification system
- Add self-hosted push notifications using VAPID (no external services)
- Create PushSubscription model in database
- Implement server-side push logic with web-push library
- Add notification helpers for list events (items, invitations, collaborators)
- Create NotificationSettings UI component for profile page
- Add usePushNotifications React hook for subscription management
- Create API routes for subscription management and VAPID key
- Update service worker with push event handlers
- Integrate notifications into item actions (add, remove, complete)
- Integrate notifications into sharing actions (invite, accept, remove)
- Add VAPID key generation script
- Add comprehensive documentation and setup guides

Notifications are sent when:
- Items are added/removed/completed in shared lists
- Users are invited to lists
- Invitations are accepted
- Collaborators are removed from lists

Works on iOS 16.4+ (when installed to home screen), Android, and desktop browsers.
2025-12-07 01:22:16 +00:00
625ca40e48 Bump service worker version to v5 for PWA auto-update 2025-12-07 01:07:25 +00:00
021bbf1335 Fix toast auto-dismiss issue by using ref for onClose callback
- Toast component now uses useRef to store onClose callback
- Prevents timer from resetting on parent re-renders
- Fixes issue where toasts wouldn't dismiss after adding items
2025-12-07 01:06:03 +00:00
737049dc69 Bump service worker to v4 - force PWA update for zoom fix 2025-12-03 21:01:49 +00:00
2f2cc309a3 Fix iOS Safari auto-zoom on input fields
- Changed all input/textarea/select font-size from text-sm to text-base (16px minimum)
- Added blur handler to reset viewport zoom on InlineItemCreate
- Prevents iOS Safari from auto-zooming when focusing on form fields
- Ensures zoom resets properly when closing forms
2025-12-03 21:01:17 +00:00
1e7aa6ae5d Bump service worker cache version to force update 2025-12-03 20:56:31 +00:00
509de75e1a Fix Safari/iPhone performance issues - remove 300ms click delay
- Added touch-action: manipulation CSS to eliminate 300ms tap delay
- Optimized service worker to skip cross-origin requests
- Updated viewport settings to prevent iOS delays
- Added format-detection meta tag
- Enabled Next.js performance optimizations
- Created comprehensive performance fix documentation
2025-12-03 20:54:55 +00:00
bfe49e7211 Mark checkpoint: IT'S WORKING HERE 2025-12-03 20:46:50 +00:00
2640559de8 Add logout button to home page header 2025-12-03 20:39:13 +00:00
4422498bc9 Move permission badge and share button above add task bar 2025-12-03 20:35:16 +00:00
376b8cead9 Clean up unused ItemWithList interface 2025-12-03 20:33:41 +00:00
66f8fcade7 Remove search and filter functionality from list detail view to fix hydration errors 2025-12-03 20:31:39 +00:00
540f5dfc89 Remove polling from home page to prevent interference with UI 2025-12-03 20:20:24 +00:00
eae3723e66 Increase polling interval to 5 seconds to prevent modal closing 2025-12-03 20:08:44 +00:00
5545caa1c3 Fix modal closing immediately by preventing event propagation 2025-12-03 20:00:13 +00:00
6c5f2fbc1e Redesign ItemCard with smaller height and right-aligned priority
- Reduced padding from p-4/p-5 to p-3/p-3.5 for more compact cards
- Moved priority badge to the right side next to title
- Changed items-start to items-center for better alignment
- Description now limited to 1 line instead of 2
- Metadata section only shows if there's a due date or tags
- Cleaner, more compact layout
2025-12-03 00:49:10 +00:00
bdff38c9a5 Remove SyncIndicator to fix hydration errors
- Removed SyncIndicator component causing hydration issues
- Background polling still works for real-time updates
- Updates appear within 1 second without visual indicator
2025-12-03 00:45:51 +00:00
79026733cb Add suppressHydrationWarning to SyncIndicator
- Suppress hydration warnings for timestamp display
- Prevents React hydration errors from time formatting differences
2025-12-03 00:43:04 +00:00
865c2a85de Fix SyncIndicator hydration error
- Add mounted state to prevent SSR/client mismatch
- Component only renders after client-side mount
- Fixes hydration error with timestamp display
2025-12-03 00:39:53 +00:00
72f98c783a Fix toast auto-dismiss and add debugging
- Added console logging to debug toast lifecycle
- Improved toast ID generation with timestamp
- Limited toasts to max 3 at a time
- Toasts should now auto-dismiss after 3 seconds
2025-12-03 00:37:43 +00:00
fc5ad9f7af Improve real-time sync with 1-second polling and visual indicator
- Reduced polling interval from 2s to 1s for near-instant updates
- Added SyncIndicator component to show sync status
- Added console logging to debug sync behavior
- Updates across devices now appear within 1 second
- Visual feedback shows when data is being refreshed
2025-12-03 00:29:24 +00:00
bd61aaa5b2 Use startTransition for delete refresh and fix filter updates
- DeleteItemButton now uses startTransition for router.refresh()
- ListDetailView re-applies filters when initialItems changes
- This ensures deleted items disappear after refresh completes
- Transition provides better React 18 concurrent rendering support
2025-12-02 14:47:43 +00:00
35e20fb16d Fix ListDetailView not updating when items change
- Add useEffect to sync filteredItems state with initialItems prop
- Now when router.refresh() updates initialItems, the UI updates
- Deleted items now disappear immediately after refresh
2025-12-02 14:44:03 +00:00
cbf6f3c6b6 Fix delete button not refreshing UI after deletion
- Remove router.push() logic that was preventing refresh
- Always call router.refresh() after successful deletion
- Reset state before refresh to clear 'Deleting...' text
- Item now disappears immediately after deletion
2025-12-02 14:41:42 +00:00
e6067e76cd Improve real-time sync speed and caching
- Reduced polling interval from 3s to 2s for faster updates
- Added revalidate = 0 to prevent Next.js caching
- Updates from other browsers now appear within 2 seconds
- Both home page and list detail pages sync faster
2025-12-02 14:38:01 +00:00
78bfcb7ac3 Add optimistic updates for immediate UI feedback
- ToggleItemButton now uses useOptimistic for instant toggle feedback
- InlineItemCreate clears form immediately before server action
- Both components call router.refresh() after mutations
- UI updates appear instantly, no waiting for server response
2025-12-02 14:33:43 +00:00
0fc8c719b9 Add real-time updates and fix delete button state
- Simplified useRealtimeSync to use router.refresh() polling
- Created RealtimeUpdater component for easy integration
- Added real-time updates to home page (3s polling)
- Fixed DeleteItemButton to properly reset state on error
- Updates now refresh every 3 seconds and on window focus
- Changes from other users/browsers will appear quickly
2025-12-02 14:29:28 +00:00
78901c8e2e Fix hydration error in OfflineIndicator component
- Add mounted state to prevent SSR/client mismatch
- Component now only renders after client-side mount
- Resolves React hydration error
2025-12-02 14:22:34 +00:00
ade7ad19d6 Fix getCurrentSession for NextAuth v4 compatibility 2025-12-02 14:12:22 +00:00
d92f13fdc4 Remove build step, use dev server 2025-12-02 14:06:04 +00:00
909f51ff6b Use dev server for NextAuth v4 compatibility 2025-12-02 14:05:46 +00:00
d7297248c1 Fix all NextAuth v4 imports across codebase 2025-12-02 14:03:13 +00:00
2f7bc4fe8e Fix NextAuth v4 imports and exports 2025-12-02 14:01:05 +00:00
40b168b6d8 Update package-lock.json for NextAuth v4 2025-12-02 13:58:40 +00:00
fe783301e7 Downgrade to NextAuth v4 to fix constructor error 2025-12-02 13:56:57 +00:00
60ebf13a9c Simplify Docker setup to fix constructor error
- Remove standalone output mode from Next.js config
- Use single-stage Docker build with npm start
- Eliminate complex multi-stage build that was causing issues
2025-12-02 13:54:26 +00:00
3a3db4195f Fix Docker Prisma constructor error
- Remove --ignore-scripts from npm ci in builder stage
- Simplify Prisma client initialization
- Ensure proper file copying order in Docker
- Remove async connection handling that may cause issues
2025-12-02 13:49:51 +00:00
c0358d79d7 Fix Docker build and Next.js metadata configuration
- Move themeColor and viewport to separate viewport export in layout.tsx
- Improve Docker build process with npm ci and proper dependency handling
- Add Docker-friendly Next.js configuration settings
- Resolve constructor error and metadata warnings
2025-12-02 13:47:40 +00:00
ff16860d8a Revert to production build with proper cookie settings
- Production build is more stable than dev mode
- Cookie settings already fixed for HTTP
- NextAuth v5 beta works better in production mode
2025-12-02 13:31:19 +00:00
855abb98b7 Re-add redirect for unauthenticated users on home page 2025-12-02 13:28:39 +00:00
b9b28ad92c Run as root in dev mode for file write permissions 2025-12-02 13:26:15 +00:00
9f25b30083 Switch to dev server in Docker 2025-12-02 13:22:38 +00:00
adb4f6b119 Set NODE_ENV to development for better NextAuth compatibility
- Avoids production build issues with NextAuth v5 beta
- Enables better error messages for debugging
- Session cookies work correctly in development mode
2025-12-02 13:21:07 +00:00
9bd4dfe0e1 Fix cookie settings for HTTP deployment
- Remove __Secure- prefix for non-HTTPS
- Set secure: false to allow cookies over HTTP
- Enables session persistence in Docker deployment
2025-12-02 13:19:30 +00:00
f3d9ccde56 Remove manual redirect from home page - let middleware handle auth
- Middleware already handles authentication redirects
- Prevents error boundary from catching redirect errors
- Cleaner separation of concerns
2025-12-02 13:17:12 +00:00
ce7021e3fa Use npm install instead of npm ci for deployment flexibility 2025-12-02 13:14:46 +00:00
d677fcb9b5 Fix Docker file copying for standalone build
- Add @prisma client to copied files
- Set proper ownership for nextjs user
- Ensures all required files are available at runtime
2025-12-02 13:11:27 +00:00
b1705fa6da Simplify port mapping: expose 3500 externally, use 3000 internally
- Map host port 3500 to container port 3000
- Keep Next.js on default port 3000 inside container
- Simplifies auth configuration
2025-12-02 13:06:24 +00:00
a084853b6c Add AUTH_URL for NextAuth v5 compatibility
- NextAuth v5 uses AUTH_URL instead of NEXTAUTH_URL
- Keep both for backward compatibility
- Fixes UntrustedHost error
2025-12-02 13:02:10 +00:00
39401a3fa2 Update package-lock.json 2025-12-02 13:00:06 +00:00
899ba97457 Explicitly set trustHost in NextAuth initialization 2025-12-02 12:56:24 +00:00
1c0e0a097e Add AUTH_TRUST_HOST to build environment 2025-12-02 12:52:53 +00:00
fc06003701 Allow .env file in Docker build for private repo 2025-12-02 12:48:43 +00:00
8c9e1a76d1 Fix Prisma client generation for correct platform
- Skip Prisma postinstall during npm ci with --ignore-scripts
- Generate Prisma client explicitly in builder stage
- Ensures correct binary for Debian/Linux platform
2025-12-02 12:46:31 +00:00
92401c5324 Fix home page redirect for unauthenticated users
- Check session before calling getLists()
- Redirect to login page if not authenticated
- Prevents server component render error in production
2025-12-02 12:40:15 +00:00
c51bc28d67 Add AUTH_TRUST_HOST environment variable for Docker
- Set AUTH_TRUST_HOST=true in .env and docker-compose.yml
- Ensures NextAuth trusts all hosts in Docker environment
- Fixes UntrustedHost error
2025-12-02 12:35:23 +00:00
f1cbd88761 Switch to Debian-based Node image for Prisma compatibility
- Change from node:20-alpine to node:20-slim
- Fixes OpenSSL library compatibility issues with Prisma
- Debian has better support for Prisma's binary engines
2025-12-02 12:32:23 +00:00
9ebb2786b3 Fix Prisma OpenSSL compatibility for Alpine Docker
- Add openssl package to all Docker build stages
- Ensures Prisma engine works on Alpine Linux ARM64
- Fixes libssl.so.1.1 missing library error
2025-12-02 12:28:40 +00:00
86ef3c41d5 Fix NextAuth trusted host for Docker deployment
- Add trustHost: true to auth.config.ts
- Remove obsolete version field from docker-compose.yml
2025-12-02 12:15:08 +00:00
c5182e3b99 Update .env with DB credentials and port 3500 2025-12-02 10:14:48 +00:00
37e0620caf Include .env in repo for private deployment 2025-12-02 10:10:37 +00:00
0d26087049 Add Docker configuration for port 3500
- Add Dockerfile with multi-stage build for production
- Add docker-compose.yml for easy deployment
- Add .dockerignore to exclude unnecessary files
- Add DOCKER_SETUP.md with comprehensive setup guide
- Update next.config.js to enable standalone output mode
- Configure app to run on port 3500
- Keep MySQL server external to Docker
2025-12-02 10:09:13 +00:00
b2a59eecf1 feat: implement real-time list synchronization
- Add useRealtimeSync hook for polling server updates every 3 seconds
- Create API endpoint to check last updated timestamp for lists
- Integrate real-time sync into ListDetailView component
- Automatically refresh when other users make changes to shared lists
- Poll on interval and when page becomes visible again
2025-12-02 10:06:49 +00:00
11c4411f68 fix: Resolve Prisma Edge Runtime error in middleware
- Move all auth configuration to auth.config.ts to avoid Prisma imports in Edge Runtime
- Update middleware to use NextAuth with edge-compatible config
- Consolidate session, cookies, and callback configuration in auth.config.ts
- Update auth.ts to extend authConfig and only add Prisma-dependent providers
- This fixes the 'PrismaClient is not configured to run in Edge Runtime' error

The middleware now properly works with NextAuth v5 beta without trying to load Prisma in the Edge Runtime.
2025-12-02 01:38:26 +00:00
0e609af924 feat: Update PWA service worker for authentication
- Add authentication pages to static cache (/auth/login, /auth/register, /auth/error)
- Implement special handling for authentication API requests (network-only with offline error)
- Add authentication page caching strategy (cache-first with network fallback)
- Prevent caching of authentication errors (401, 403)
- Enhance offline page with online detection and auto-redirect
- Add authentication notice to offline page
- Update cache version to v2 for proper invalidation
- Create PWA_AUTH_GUIDE.md documentation

Task: 19. Update PWA service worker for authentication
Requirements: 4.4
2025-12-02 01:34:28 +00:00
534796edd7 feat: implement security enhancements (task 17)
- Add rate limiting for login, registration, and invitations
- Implement CSRF protection with SameSite cookies and security headers
- Configure secure session cookies with httpOnly and secure flags
- Enhance input sanitization for XSS and injection prevention
- Add comprehensive security tests (41 tests passing)
- Document security enhancements in SECURITY_ENHANCEMENTS.md

Requirements: 9.3, 9.4, 9.5
2025-12-02 01:29:35 +00:00
2ce89e7c6c feat: implement data migration for existing lists
- Created migration-actions.ts with server actions for claiming lists
- Added ClaimListsModal component for UI during registration
- Integrated claim modal into RegisterForm
- Created anonymous-lists.ts utilities for tracking lists in localStorage
- Added migrate-lists.ts script for deployment migration
- Created comprehensive MIGRATION_GUIDE.md documentation
- Added migration tests with 100% pass rate
- Updated package.json with db:migrate script
- Added tsx as dev dependency for running migration scripts

Requirements: 10.2, 10.3, 10.4, 10.5
- Users can claim existing lists on first registration
- All list data is preserved during migration
- All lists have an owner after migration
- Migrated lists appear in user's account
2025-12-02 01:20:35 +00:00
54612a4782 feat: update components for multi-user support
- Add sharing indicator to ListCard showing collaborator count
- Add permission badge and share button to ListDetailView
- Disable editing in ItemForm for viewers with visual feedback
- Add permission checks to item detail and edit pages
- Hide edit/delete buttons for viewers on item detail page
- Pass permission and collaborator data through component hierarchy
- Update navigation to include profile and invitations links (already done in previous tasks)

Implements task 15 from user-auth-and-sharing spec
2025-12-02 01:14:21 +00:00
85c8a1650b Implement profile management (task 14)
- Add updateProfile and updatePassword server actions
- Add getUserProfile function with sensitive data protection
- Create ProfileForm component for name and email updates
- Create PasswordChangeForm component with current password verification
- Create profile page at /profile with all profile management features
- Add profile and invitations navigation links to main page
- Implement email uniqueness validation
- Implement password hashing on password change

Requirements: 7.1, 7.2, 7.3, 7.4, 7.5
2025-12-02 01:06:21 +00:00
b102e86b33 feat: create sharing UI components (task 13)
- Add PermissionBadge component to display owner/editor/viewer roles
- Add InvitationCard component for accepting/declining invitations
- Add CollaboratorList component to manage list collaborators
- Add ShareListModal component for inviting users and viewing access
- Add invitations page to view and manage pending invitations

Requirements: 5.1, 5.4, 8.1, 8.2
2025-12-02 01:02:33 +00:00
7464620668 Complete task 12: Implement collaborator management
- Verified getCollaborators server action (Requirement 5.4)
- Verified updateCollaboratorPermission server action (Requirement 6.5)
- Verified removeCollaborator server action (Requirement 5.5)
- Added permission level enforcement throughout (Requirement 6.1)
- Created comprehensive test suite for collaborator management
- All tests passing (6/6 tests)
2025-12-02 01:00:03 +00:00
b73f761308 Implement invitation system with server actions
- Add inviteUserToList: Create invitations with email validation and expiration
- Add getInvitations: Retrieve pending invitations for current user
- Add acceptInvitation: Accept invitation and create collaborator record
- Add declineInvitation: Decline and remove invitation
- Add getCollaborators: List all collaborators for a list
- Add updateCollaboratorPermission: Change collaborator permission level
- Add removeCollaborator: Revoke collaborator access
- Include proper authorization checks and error handling
- Add invitation expiration logic (7 days)
- Implement transaction for atomic invitation acceptance

Requirements: 5.1, 5.2, 5.3, 8.1, 8.5
2025-12-02 00:57:50 +00:00
0e60c91b98 Complete task 10: Update item operations for access control
- All item operations (createItem, updateItem, deleteItem, toggleItem) now enforce access control
- Editor vs viewer permissions properly enforced (only owner/editor can modify)
- Item queries (searchItems, getItemsByFilter, getItem) filter by list access
- Validates Requirements 6.2, 6.3, and 9.2
2025-12-02 00:54:55 +00:00
a8962247fb Implement permission utilities (task 9)
- Created lib/permissions.ts with four utility functions:
  - canAccessList: Check if user can view a list
  - canEditList: Check if user can edit a list or its items
  - canDeleteList: Check if user can delete a list (owner only)
  - getPermission: Get user's permission level for a list
- Added comprehensive unit tests in lib/__tests__/permissions.test.ts
- All 24 tests passing
- Validates Requirements 4.3, 6.2, 6.3
2025-12-02 00:52:48 +00:00
0bb293e101 feat: implement list ownership and access control
- Modified createList to assign ownerId to authenticated user
- Updated getLists to filter by user access (owner or collaborator)
- Added ownership checks to updateList (owner or editor can update)
- Added ownership checks to deleteList (only owner can delete)
- Implemented access control for all item operations (create, update, delete, toggle)
- Updated getItem, searchItems, and getItemsByFilter to enforce access control
- Added dynamic rendering to pages that use authentication
- Cascade deletion already handled by database schema (onDelete: Cascade)

Requirements: 4.1, 4.2, 4.3, 4.5
2025-12-02 00:48:47 +00:00
2ebda95207 feat: implement route protection middleware for authentication
- Created Next.js middleware for authentication checks
- Protected main routes (/, /list/*, /profile/*, /invitations/*)
- Redirect unauthenticated users to login with callback URL
- Handle session validation and expiration
- Allow access to auth pages for unauthenticated users
- Redirect authenticated users away from login/register pages
- Added comprehensive middleware tests
- Validates Requirements 3.3, 3.5

Task: 7. Implement route protection middleware
2025-12-02 00:43:30 +00:00
d4a5232e71 feat: implement authentication UI components
- Add LoginForm component with email/password validation
- Add RegisterForm component with password confirmation
- Create login page at /auth/login
- Create register page at /auth/register
- Create authentication error page at /auth/error
- Add loading states and error handling
- Implement inline validation with user feedback
- Follow existing component patterns from ListForm

Implements task 6 from user-auth-and-sharing spec
Requirements: 1.1, 2.1
2025-12-02 00:42:04 +00:00
895ceba55f Implement session management (Task 5)
- Configure JWT session strategy with 30-day expiration
- Implement session validation middleware for route protection
- Create logout functionality with session termination
- Handle session expiration automatically via JWT
- Add session helper functions (getCurrentSession, isAuthenticated)
- Protect routes: /, /list/*, /profile/*, /invitations/*
- Redirect unauthenticated users to login with callback URL
- Requirements: 2.4, 2.5, 3.1, 3.2, 3.4
2025-12-02 00:39:04 +00:00
211ed50503 Implement authentication functionality with login server action
- Created loginUser server action with credential validation
- Implemented password verification using bcrypt (via NextAuth authorize)
- Session creation on successful login handled by NextAuth
- Invalid credentials handled with appropriate error messages
- Added comprehensive unit tests for login functionality
- All tests passing (11/11)

Requirements: 2.1, 2.2
2025-12-02 00:36:03 +00:00
70121982a0 feat: implement user registration functionality
- Created registerUser server action with email/password validation
- Implemented password hashing with bcrypt (10 rounds)
- Added email uniqueness check to prevent duplicate registrations
- Created session after successful registration using NextAuth
- Added comprehensive validation for email format and password length (min 8 chars)
- Added email and password validation utilities to lib/validation.ts
- Removed PrismaAdapter from auth.ts (using JWT sessions only)
- Added vitest testing framework and configuration
- Created comprehensive unit tests for registration functionality
- All tests passing (6/6)

Requirements covered: 1.1, 1.2, 1.3, 1.4, 1.5
2025-12-02 00:03:23 +00:00
174a6601b2 feat: update database schema for multi-user support
- Add User model with email, passwordHash, and name fields
- Add Collaborator model for list sharing with permission levels
- Add Invitation model for sharing invitations with status tracking
- Add ownerId field to List model with cascade deletion
- Add necessary indexes for performance optimization
- Implements Requirements 1.1, 4.1, 5.1
2025-12-01 23:58:20 +00:00
2c3b66b085 feat: set up authentication infrastructure with NextAuth.js v5
- Install next-auth@beta, bcryptjs, zod, and @auth/prisma-adapter
- Create auth.ts with credentials provider and JWT session strategy
- Create auth.config.ts for edge-compatible middleware configuration
- Add NextAuth type extensions for session and JWT
- Create API route handler for authentication
- Configure environment variables for NextAuth
- Add AUTH_SETUP.md documentation

Task: 1. Set up authentication infrastructure
Requirements: 1.1, 2.1
2025-12-01 23:56:45 +00:00
b3c7f92877 Complete task 19: Final checkpoint - All tests pass 2025-12-01 21:34:08 +00:00
100 changed files with 12964 additions and 635 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
Dockerfile
.dockerignore
.git
.gitignore
.next
.kiro
.vscode
node_modules
npm-debug.log
README.md
*.md
.env.local
.env.*.local
coverage
.DS_Store
*.pem
tsconfig.tsbuildinfo

18
.env Normal file
View File

@@ -0,0 +1,18 @@
DATABASE_URL="mysql://lists_app:ListsApp@2025!@192.168.4.20:3306/ultimate_lists"
# NextAuth Configuration
AUTH_URL=http://localhost:3500
NEXTAUTH_URL=http://localhost:3500
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_TRUST_HOST=true
# Database connection check (for docker-compose health check)
DB_HOST=192.168.4.20
DB_PORT=3306
DB_USER=lists_app
DB_PASSWORD=ListsApp@2025!
VAPID_PUBLIC_KEY=BI05JNvKs4CSiG8c4q6Lt_FjMmuVy-0LWiiuvYbjnu10TpysQdVpY2LpY0gotdhlXJJLn6ub_cf4EX69gZDs83o
VAPID_PRIVATE_KEY=EUsjxWebCbrVphsDDFuoRqUE5-eX8nhpuLvYKVVeGnQ
VAPID_SUBJECT=mailto:psbailey31@gmail.com

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
DATABASE_URL="mysql://user:password@host:port/database"
# NextAuth Configuration
# NEXTAUTH_URL should be your production URL in production
NEXTAUTH_URL=http://localhost:3000
# Generate a secure secret with: openssl rand -base64 32
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_SECRET=your-secret-key-here-please-change-in-production
# Web Push Notifications (VAPID)
# Generate keys with: npx web-push generate-vapid-keys
# Or in Docker: docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
VAPID_PUBLIC_KEY=your-vapid-public-key-here
VAPID_PRIVATE_KEY=your-vapid-private-key-here
VAPID_SUBJECT=mailto:your-email@example.com

6
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# Local env files # Local env files
# .env
.env*.local .env*.local
# Vercel # Vercel
@@ -30,7 +31,4 @@ yarn-error.log*
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Environment
.env

View File

@@ -215,7 +215,7 @@
- Sanitize user inputs - Sanitize user inputs
- _Requirements: 1.1, 2.1_ - _Requirements: 1.1, 2.1_
- [-] 18. Optimize performance - [x] 18. Optimize performance
- Add database indexes for frequently queried fields - Add database indexes for frequently queried fields
- Implement pagination for large lists - Implement pagination for large lists
- Optimize bundle size with code splitting - Optimize bundle size with code splitting
@@ -223,9 +223,17 @@
- Use React.memo for expensive components - Use React.memo for expensive components
- _Requirements: All (performance is cross-cutting)_ - _Requirements: All (performance is cross-cutting)_
- [ ] 19. Final checkpoint - Ensure all tests pass - [x] 19. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise. - Ensure all tests pass, ask the user if questions arise.
- [x] 19.5. Implement real-time list synchronization
- Create useRealtimeSync hook for polling server updates
- Add API endpoint to check last updated timestamp
- Integrate real-time sync into ListDetailView component
- Automatically refresh when other users make changes
- Poll every 3 seconds and on page visibility change
- _Requirements: Collaborative features (enhancement)_
- [ ] 20. End-to-end testing and polish - [ ] 20. End-to-end testing and polish
- Test complete user flows from list creation to task completion - Test complete user flows from list creation to task completion
- Verify PWA installation on iOS Safari - Verify PWA installation on iOS Safari

View File

@@ -0,0 +1,745 @@
# Design Document: User Authentication and Sharing
## Overview
This design extends the Ultimate Lists App to support multi-user authentication and collaborative list sharing. The system will implement secure user registration and login using NextAuth.js (Auth.js v5), session management with JWT tokens, and a permission-based access control system for list sharing.
The authentication system integrates seamlessly with the existing Next.js 14 App Router architecture, using Server Actions for authentication operations and middleware for route protection. The design maintains the current PWA capabilities while adding user-specific data isolation and collaborative features.
Key design decisions:
- **NextAuth.js v5** for authentication (industry-standard, well-maintained, Next.js 14 compatible)
- **Credentials provider** for email/password authentication
- **bcrypt** for password hashing
- **JWT sessions** for stateless authentication
- **Middleware-based route protection** for securing pages
- **Permission-based access control** with owner, editor, and viewer roles
- **Database-level access control** to ensure data isolation
## Architecture
### High-Level Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌──────────────────────────────────────────────────┐ │
│ │ React Components (RSC + Client) │ │
│ │ ┌────────────┐ ┌────────────┐ ┌───────────┐ │ │
│ │ │Auth Pages │ │ Components │ │ Hooks │ │ │
│ │ │Login/ │ │ (Protected)│ │ │ │ │
│ │ │Register │ │ │ │ │ │ │
│ │ └────────────┘ └────────────┘ └───────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Service Worker (PWA) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ HTTP/Server Actions
▼
┌─────────────────────────────────────────────────────────┐
│ Next.js Server │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Middleware │ │
│ │ (Route Protection & Auth) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ NextAuth.js │ │
│ │ (Session Management & Authentication) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Server Actions │ │
│ │ (Business Logic + Access Control) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Prisma Client │ │
│ │ (Data Access with ACL) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ SQL
▼
┌─────────────────────────────────────────────────────────┐
│ MySQL Database │
│ (Users, Lists, Items, Collaborators, Invitations) │
└─────────────────────────────────────────────────────────┘
```
### Technology Stack
**New Dependencies:**
- **next-auth@5.0.0-beta**: Authentication framework for Next.js
- **bcryptjs**: Password hashing library
- **@types/bcryptjs**: TypeScript types for bcryptjs
- **zod**: Schema validation for forms and API inputs
**Existing Stack:**
- Framework: Next.js 14 with App Router
- Language: TypeScript
- Database: MySQL with Prisma ORM
- UI: React 18 with Tailwind CSS
## Components and Interfaces
### Authentication Layer
**NextAuth Configuration (`auth.ts`)**
```typescript
// Core authentication configuration
export const authOptions = {
providers: [
CredentialsProvider({
credentials: { email, password },
authorize: async (credentials) => {
// Validate credentials
// Query user from database
// Verify password with bcrypt
// Return user object or null
}
})
],
session: { strategy: 'jwt' },
callbacks: {
jwt: async ({ token, user }) => {
// Add user ID to token
},
session: async ({ session, token }) => {
// Add user ID to session
}
},
pages: {
signIn: '/auth/login',
signOut: '/auth/logout',
error: '/auth/error'
}
}
```
**Middleware (`middleware.ts`)**
```typescript
// Protect routes requiring authentication
export function middleware(request: NextRequest) {
// Check authentication status
// Redirect to login if not authenticated
// Allow access if authenticated
}
export const config = {
matcher: ['/', '/list/:path*', '/profile/:path*']
}
```
**Authentication Actions (`lib/auth-actions.ts`)**
- `registerUser(email, password, name)`: Create new user account
- `validateCredentials(email, password)`: Verify login credentials
- `updatePassword(userId, currentPassword, newPassword)`: Change password
- `updateProfile(userId, data)`: Update user profile
- `deleteAccount(userId)`: Delete user and associated data
### Access Control Layer
**Permission Utilities (`lib/permissions.ts`)**
```typescript
type Permission = 'owner' | 'editor' | 'viewer'
// Check if user can access list
async function canAccessList(userId: string, listId: string): Promise<boolean>
// Check if user can edit list
async function canEditList(userId: string, listId: string): Promise<boolean>
// Check if user can delete list
async function canDeleteList(userId: string, listId: string): Promise<boolean>
// Get user's permission level for list
async function getPermission(userId: string, listId: string): Promise<Permission | null>
```
**Modified Server Actions (`lib/actions.ts`)**
All existing server actions will be modified to include access control:
- `getLists(userId)`: Get lists user owns or has access to
- `createList(userId, title, color)`: Create list owned by user
- `updateList(userId, listId, data)`: Update list if user has permission
- `deleteList(userId, listId)`: Delete list if user is owner
- `getListWithItems(userId, listId)`: Get list if user has access
- `createItem(userId, listId, data)`: Create item if user can edit
- `updateItem(userId, itemId, data)`: Update item if user can edit parent list
- `deleteItem(userId, itemId)`: Delete item if user can edit parent list
- `toggleItem(userId, itemId)`: Toggle item if user can edit parent list
**Sharing Actions (`lib/sharing-actions.ts`)**
- `inviteUserToList(ownerId, listId, email, permission)`: Create invitation
- `getInvitations(userId)`: Get pending invitations for user
- `acceptInvitation(userId, invitationId)`: Accept invitation and grant access
- `declineInvitation(userId, invitationId)`: Decline and remove invitation
- `getCollaborators(userId, listId)`: Get users with access to list
- `updateCollaboratorPermission(ownerId, listId, collaboratorId, permission)`: Change permission level
- `removeCollaborator(ownerId, listId, collaboratorId)`: Revoke access
### Presentation Layer
**New Pages**
- `/auth/login` - Login form
- `/auth/register` - Registration form
- `/auth/error` - Authentication error display
- `/profile` - User profile management
- `/invitations` - Pending invitations list
**New Components**
- `LoginForm`: Email/password login form with validation
- `RegisterForm`: Registration form with password requirements
- `ProfileForm`: Edit user profile information
- `PasswordChangeForm`: Change password with current password verification
- `InvitationCard`: Display invitation with accept/decline actions
- `CollaboratorList`: Show users with access to a list
- `ShareListModal`: Invite users to a list
- `PermissionBadge`: Display permission level (owner/editor/viewer)
- `ProtectedRoute`: Wrapper component for authenticated routes
**Modified Components**
- `ListCard`: Show sharing indicator if list has collaborators
- `ListDetailView`: Add share button for owners, show permission level
- `ItemForm`: Disable editing for viewers
## Data Models
### New Database Models
**User Model**
```typescript
{
id: string (cuid)
email: string (unique)
name: string | null
passwordHash: string
createdAt: DateTime
updatedAt: DateTime
ownedLists: List[] (relation)
collaborations: Collaborator[] (relation)
sentInvitations: Invitation[] (relation)
receivedInvitations: Invitation[] (relation)
}
```
**Collaborator Model** (Join table for list sharing)
```typescript
{
id: string (cuid)
userId: string
listId: string
permission: 'editor' | 'viewer'
createdAt: DateTime
user: User (relation)
list: List (relation)
@@unique([userId, listId])
}
```
**Invitation Model**
```typescript
{
id: string (cuid)
email: string
listId: string
inviterId: string
permission: 'editor' | 'viewer'
status: 'pending' | 'accepted' | 'declined'
createdAt: DateTime
expiresAt: DateTime
list: List (relation)
inviter: User (relation)
}
```
### Modified Database Models
**List Model** (add owner relationship)
```typescript
{
id: string (cuid)
title: string
color: string
ownerId: string (new)
createdAt: DateTime
updatedAt: DateTime
owner: User (new relation)
items: Item[]
collaborators: Collaborator[] (new relation)
invitations: Invitation[] (new relation)
}
```
**Item Model** (no changes to schema, but queries will check list access)
```typescript
{
// Existing fields remain the same
// Access control enforced through parent list
}
```
### Session Model
**Session Data** (stored in JWT)
```typescript
{
user: {
id: string
email: string
name: string | null
}
expires: string (ISO date)
}
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### User Registration Properties
**Property 1: Registration creates unique user**
*For any* valid email and password, registering a new user should create a user account with a unique identifier that differs from all existing user identifiers.
**Validates: Requirements 1.1**
**Property 2: Duplicate email rejection**
*For any* existing user email, attempting to register with that email should be rejected and return an error.
**Validates: Requirements 1.2**
**Property 3: Password hashing on registration**
*For any* user registration with a password, the stored password value in the database should be a valid bcrypt hash and should not equal the plain text password.
**Validates: Requirements 1.3**
**Property 4: Registration creates session**
*For any* successful user registration, a valid session should be created for that user immediately after registration.
**Validates: Requirements 1.4**
**Property 5: Password length validation**
*For any* password with fewer than 8 characters, registration should be rejected and return a validation error.
**Validates: Requirements 1.5**
### Authentication Properties
**Property 6: Valid credentials create session**
*For any* registered user with known password, submitting correct credentials should authenticate the user and create a valid session.
**Validates: Requirements 2.1**
**Property 7: Invalid credentials rejection**
*For any* login attempt with incorrect password or non-existent email, authentication should fail and return an error.
**Validates: Requirements 2.2**
**Property 8: Session expiration enforcement**
*For any* expired session token, attempting to access protected resources should be denied.
**Validates: Requirements 2.4**
**Property 9: Logout terminates session**
*For any* authenticated user, after logging out, the session token should become invalid and subsequent requests should be denied.
**Validates: Requirements 2.5**
### Session Management Properties
**Property 10: Secure token generation**
*For any* successful login, a session token should be created with sufficient entropy and proper format.
**Validates: Requirements 3.1**
**Property 11: Session persistence**
*For any* valid session token, re-validating the token should maintain the authenticated state with the same user information.
**Validates: Requirements 3.2**
**Property 12: Session duration persistence**
*For any* session created, the session should remain valid for the configured duration even after simulated browser close/reopen.
**Validates: Requirements 3.4**
### List Ownership Properties
**Property 13: List ownership assignment**
*For any* user creating a list, the created list should have its ownerId set to the creating user's ID.
**Validates: Requirements 4.1**
**Property 14: List access filtering**
*For any* user querying for lists, the results should include only lists where the user is the owner or has a collaborator record, and should include all such lists.
**Validates: Requirements 4.2, 4.4**
**Property 15: Unauthorized list access denial**
*For any* user attempting to access a list they don't own and aren't a collaborator on, the access should be denied and return an error.
**Validates: Requirements 4.3**
**Property 16: Account deletion cascades to lists**
*For any* user with owned lists, deleting the user account should result in all owned lists being removed from the database.
**Validates: Requirements 4.5**
### Invitation Properties
**Property 17: Invitation creation with unique ID**
*For any* list owner inviting a user by email, an invitation record should be created with a unique identifier and the specified permission level.
**Validates: Requirements 5.1, 6.1**
**Property 18: Invitation acceptance grants access**
*For any* pending invitation, when the invited user accepts it, a collaborator record should be created granting the user access to the list with the specified permission.
**Validates: Requirements 5.2**
**Property 19: Invitation decline removes record**
*For any* pending invitation, when the invited user declines it, the invitation should be removed from the database.
**Validates: Requirements 5.3**
**Property 20: Collaborator list completeness**
*For any* list with collaborators, querying the collaborators should return all users who have access including their permission levels.
**Validates: Requirements 5.4**
**Property 21: Access revocation enforcement**
*For any* collaborator with access to a list, after the owner revokes access, the user should no longer be able to access the list.
**Validates: Requirements 5.5**
### Permission Properties
**Property 22: Editor permission allows modifications**
*For any* user with editor permission on a list, the user should be able to create, update, and delete items in that list.
**Validates: Requirements 6.2**
**Property 23: Viewer permission prevents modifications**
*For any* user with viewer permission on a list, attempts to create, update, or delete items should be denied and return an error.
**Validates: Requirements 6.3, 6.4**
**Property 24: Permission update enforcement**
*For any* collaborator with editor permission, after the owner changes their permission to viewer, item modification attempts should be denied.
**Validates: Requirements 6.5**
### Profile Management Properties
**Property 25: Profile update persistence**
*For any* user updating profile information, the changes should be saved and associated with the correct user account.
**Validates: Requirements 7.1**
**Property 26: Password change requires current password**
*For any* password change attempt with incorrect current password, the change should be rejected and return an error.
**Validates: Requirements 7.2**
**Property 27: Password hashing on change**
*For any* successful password change, the new password should be stored as a bcrypt hash and not as plain text.
**Validates: Requirements 7.3**
**Property 28: Email uniqueness validation**
*For any* attempt to update email to an address already in use by another user, the update should be rejected and return an error.
**Validates: Requirements 7.4**
**Property 29: Profile data exposure protection**
*For any* user profile query, the returned data should not include the password hash or other sensitive authentication data.
**Validates: Requirements 7.5**
### Invitation Notification Properties
**Property 30: Invitation visibility**
*For any* invitation created for a user's email, querying that user's pending invitations should include the invitation.
**Validates: Requirements 8.1**
**Property 31: Invitation details completeness**
*For any* pending invitation, the invitation data should include the list name and inviter information.
**Validates: Requirements 8.2**
**Property 32: Invitation removal after action**
*For any* invitation that is accepted or declined, the invitation should no longer appear in the user's pending invitations list.
**Validates: Requirements 8.4**
**Property 33: Invitation timestamp recording**
*For any* created invitation, the invitation should have a createdAt timestamp that is set to the time of creation.
**Validates: Requirements 8.5**
### Data Isolation Properties
**Property 34: Item access through list permission**
*For any* user querying items, only items from lists the user has access to should be returned.
**Validates: Requirements 9.2**
### Migration Properties
**Property 35: Migration data preservation**
*For any* list migrated to a user account, all list data including items, metadata, and timestamps should remain unchanged after migration.
**Validates: Requirements 10.3**
## Error Handling
### Authentication Errors
**Invalid Credentials**
- Return clear error messages without revealing whether email exists
- Log failed login attempts for security monitoring
- Implement rate limiting to prevent brute force attacks
**Session Errors**
- Handle expired sessions gracefully with redirect to login
- Clear invalid tokens from client storage
- Provide clear feedback when re-authentication is required
**Registration Errors**
- Validate email format before database query
- Check password requirements client-side and server-side
- Return specific validation errors for each field
### Authorization Errors
**Access Denied**
- Return 403 Forbidden for unauthorized access attempts
- Log unauthorized access attempts
- Provide user-friendly error messages
**Permission Errors**
- Check permissions before executing operations
- Return clear error messages indicating required permission level
- Prevent information leakage about resources user can't access
### Database Errors
**Constraint Violations**
- Handle unique constraint violations (duplicate email)
- Manage foreign key constraints properly
- Use transactions for multi-step operations
**Connection Failures**
- Implement retry logic with exponential backoff
- Provide fallback error messages
- Log database errors for debugging
### Invitation Errors
**Invalid Invitations**
- Validate email format before creating invitation
- Check if user is already a collaborator
- Handle expired invitations gracefully
**Invitation Conflicts**
- Prevent duplicate invitations to same email for same list
- Handle case where invited email doesn't exist yet
- Manage invitation expiration
## Testing Strategy
### Unit Testing
The application will use **Vitest** as the testing framework for unit tests. Unit tests will focus on:
**Authentication Layer**
- Password hashing and verification
- Token generation and validation
- Credential validation logic
- Session creation and management
**Access Control Layer**
- Permission checking functions
- Access control filtering logic
- Collaborator management operations
**Business Logic**
- Invitation creation and acceptance flow
- Profile update operations
- List ownership assignment
- Cascade deletion logic
**Utility Functions**
- Email validation
- Password strength validation
- Token generation
- Data sanitization
Unit tests will use realistic test data and verify specific examples, edge cases, and error conditions.
### Property-Based Testing
The application will use **fast-check** as the property-based testing library. Property-based tests will verify the universal correctness properties defined in this document.
**Configuration Requirements:**
- Each property-based test MUST run a minimum of 100 iterations
- Each test MUST be tagged with a comment referencing the correctness property
- Tag format: `// Feature: user-auth-and-sharing, Property {number}: {property_text}`
- Each correctness property MUST be implemented by a SINGLE property-based test
**Property Test Coverage:**
- User registration (Properties 1-5)
- Authentication (Properties 6-9)
- Session management (Properties 10-12)
- List ownership (Properties 13-16)
- Invitations (Properties 17-21)
- Permissions (Properties 22-24)
- Profile management (Properties 25-29)
- Invitation notifications (Properties 30-33)
- Data isolation (Property 34)
- Migration (Property 35)
**Test Data Generation:**
Property-based tests will use custom generators for:
- Valid email addresses
- Passwords of various lengths
- User IDs (cuid format)
- Permission levels (owner, editor, viewer)
- Invitation statuses
- Session tokens
- Profile data
### Integration Testing
Integration tests will verify:
- End-to-end authentication flows (register → login → access protected resource)
- List sharing workflows (invite → accept → collaborate)
- Permission enforcement across the stack
- Session persistence across requests
- Middleware integration with authentication
### Security Testing
Security tests will verify:
- Password hashing strength
- Session token security
- SQL injection prevention
- XSS prevention in user inputs
- CSRF protection
- Rate limiting effectiveness
## Performance Considerations
### Authentication Performance
- Cache session validation results
- Use efficient bcrypt work factor (10-12 rounds)
- Implement connection pooling for database
- Index user email field for fast lookups
### Access Control Performance
- Index foreign keys (userId, listId in Collaborator table)
- Cache permission checks for frequently accessed lists
- Use database-level filtering for access control
- Implement efficient JOIN queries for list retrieval
### Session Management
- Use stateless JWT tokens to avoid database lookups
- Set appropriate token expiration times
- Implement token refresh mechanism if needed
- Use secure, httpOnly cookies for token storage
## Security Considerations
### Password Security
- Use bcrypt with appropriate work factor (10-12 rounds)
- Enforce minimum password length (8 characters)
- Consider additional password requirements (complexity)
- Never log or expose passwords in plain text
- Implement password reset functionality securely
### Session Security
- Use cryptographically secure random token generation
- Set httpOnly and secure flags on session cookies
- Implement CSRF protection
- Use short session expiration times
- Implement session invalidation on logout
### Access Control Security
- Enforce access control at database query level
- Never trust client-side permission checks
- Validate user identity on every request
- Use parameterized queries to prevent SQL injection
- Implement rate limiting on sensitive operations
### Data Protection
- Encrypt sensitive data at rest
- Use HTTPS for all communications
- Implement proper CORS policies
- Sanitize all user inputs
- Follow principle of least privilege
### Invitation Security
- Implement invitation expiration
- Validate email addresses
- Prevent invitation spam
- Rate limit invitation creation
- Verify inviter has permission to share
## Migration Strategy
### Data Migration
**Phase 1: Schema Updates**
1. Add User table to database
2. Add Collaborator table
3. Add Invitation table
4. Add ownerId field to List table (nullable initially)
**Phase 2: Migration Script**
1. Create a default "system" user for existing lists
2. Associate all existing lists with system user
3. Provide UI for users to claim lists by creating accounts
4. Make ownerId non-nullable after migration
**Phase 3: Deployment**
1. Deploy authentication system
2. Run migration script
3. Enable authentication requirements
4. Monitor for issues
### Backward Compatibility
- Maintain existing API endpoints during transition
- Provide grace period for users to create accounts
- Preserve all existing list and item data
- Ensure PWA functionality continues to work
## Deployment Considerations
### Environment Variables
```
DATABASE_URL=mysql://...
NEXTAUTH_URL=https://yourdomain.com
NEXTAUTH_SECRET=<generated-secret>
JWT_SECRET=<generated-secret>
```
### Database Migrations
1. Run Prisma migrations: `npx prisma migrate dev`
2. Generate Prisma client: `npx prisma generate`
3. Run data migration script if needed
4. Verify schema changes in production
### Monitoring
- Track failed login attempts
- Monitor session creation/expiration
- Log unauthorized access attempts
- Track invitation acceptance rates
- Monitor database query performance
## Future Enhancements
### Authentication Features
- OAuth providers (Google, GitHub, etc.)
- Two-factor authentication
- Password reset via email
- Email verification
- Remember me functionality
- Account recovery options
### Collaboration Features
- Real-time collaboration indicators
- Activity feed for shared lists
- Comments on items
- @mentions in notes
- Notification system for list updates
- Team/organization support
### Permission Enhancements
- Custom permission levels
- Granular item-level permissions
- Temporary access grants
- Permission templates
- Audit log for permission changes

View File

@@ -0,0 +1,140 @@
# Requirements Document
## Introduction
The User Authentication and Sharing feature extends the Ultimate Lists App to support multiple users with private lists and collaborative sharing capabilities. The system SHALL enable users to register accounts, authenticate securely, maintain private lists, and selectively share lists with other users through invitations. This feature transforms the application from a single-user task management tool into a multi-user collaborative platform while maintaining data privacy and security.
## Glossary
- **User**: An individual with a registered account in the Ultimate Lists App
- **Authentication**: The process of verifying a user's identity through credentials
- **Session**: A period of authenticated access to the application
- **Private List**: A list that is only accessible to its owner and invited collaborators
- **List Owner**: The user who created a list and has full control over it
- **Collaborator**: A user who has been granted access to another user's list
- **Invitation**: A request sent by a list owner to grant another user access to a list
- **Permission Level**: The degree of access a user has to a list (owner, editor, viewer)
- **Credential**: User authentication information (email and password)
- **Session Token**: A secure identifier used to maintain authenticated sessions
## Requirements
### Requirement 1: User Registration
**User Story:** As a new user, I want to register for an account with my email and password, so that I can have my own private lists.
#### Acceptance Criteria
1. WHEN a user submits a registration form with a valid email and password, THE Ultimate Lists App SHALL create a new user account with a unique identifier
2. WHEN a user attempts to register with an email that already exists, THE Ultimate Lists App SHALL reject the registration and display an error message
3. WHEN a user registers, THE Ultimate Lists App SHALL hash the password before storing it in the database
4. WHEN a user completes registration, THE Ultimate Lists App SHALL create an authenticated session for that user
5. WHEN a user provides a password during registration, THE Ultimate Lists App SHALL validate that the password meets minimum security requirements (at least 8 characters)
### Requirement 2: User Authentication
**User Story:** As a registered user, I want to log in with my email and password, so that I can access my private lists.
#### Acceptance Criteria
1. WHEN a user submits valid credentials, THE Ultimate Lists App SHALL authenticate the user and create a session
2. WHEN a user submits invalid credentials, THE Ultimate Lists App SHALL reject the login attempt and display an error message
3. WHEN a user logs in successfully, THE Ultimate Lists App SHALL redirect the user to their lists dashboard
4. WHEN a user session expires, THE Ultimate Lists App SHALL require re-authentication before allowing access to protected resources
5. WHEN a user logs out, THE Ultimate Lists App SHALL terminate the session and clear authentication tokens
### Requirement 3: Session Management
**User Story:** As a logged-in user, I want my session to persist across page refreshes, so that I don't have to log in repeatedly.
#### Acceptance Criteria
1. WHEN a user logs in, THE Ultimate Lists App SHALL create a secure session token
2. WHEN a user refreshes the page, THE Ultimate Lists App SHALL validate the session token and maintain the authenticated state
3. WHEN a session token is invalid or expired, THE Ultimate Lists App SHALL redirect the user to the login page
4. WHEN a user closes the browser, THE Ultimate Lists App SHALL preserve the session for a configurable duration
5. WHEN a user accesses a protected route without authentication, THE Ultimate Lists App SHALL redirect to the login page
### Requirement 4: Private Lists
**User Story:** As a user, I want all my lists to be private by default, so that only I can see and modify them.
#### Acceptance Criteria
1. WHEN a user creates a list, THE Ultimate Lists App SHALL associate the list with the user's account as the owner
2. WHEN a user views their lists, THE Ultimate Lists App SHALL display only lists they own or have been granted access to
3. WHEN a user attempts to access another user's private list without permission, THE Ultimate Lists App SHALL deny access and return an error
4. WHEN a user queries for lists, THE Ultimate Lists App SHALL filter results to include only lists the user has permission to view
5. WHEN a user deletes their account, THE Ultimate Lists App SHALL delete all lists owned by that user
### Requirement 5: List Sharing and Invitations
**User Story:** As a list owner, I want to invite other users to collaborate on my lists, so that we can work together on shared tasks.
#### Acceptance Criteria
1. WHEN a list owner invites a user by email, THE Ultimate Lists App SHALL create an invitation record with a unique identifier
2. WHEN an invited user accepts an invitation, THE Ultimate Lists App SHALL grant the user access to the shared list
3. WHEN an invited user declines an invitation, THE Ultimate Lists App SHALL remove the invitation record
4. WHEN a list owner views a list, THE Ultimate Lists App SHALL display all users who have access to that list
5. WHEN a list owner revokes a user's access, THE Ultimate Lists App SHALL remove the user's permission and prevent further access to the list
### Requirement 6: Collaboration Permissions
**User Story:** As a list owner, I want to control what collaborators can do with my lists, so that I can maintain appropriate access levels.
#### Acceptance Criteria
1. WHEN a list owner invites a collaborator, THE Ultimate Lists App SHALL allow the owner to specify a permission level (editor or viewer)
2. WHEN a user with editor permission accesses a shared list, THE Ultimate Lists App SHALL allow the user to create, edit, and delete items
3. WHEN a user with viewer permission accesses a shared list, THE Ultimate Lists App SHALL allow the user to view items but prevent modifications
4. WHEN a user attempts an action beyond their permission level, THE Ultimate Lists App SHALL deny the action and display an error message
5. WHEN a list owner changes a collaborator's permission level, THE Ultimate Lists App SHALL update the permission and enforce the new access level immediately
### Requirement 7: User Profile Management
**User Story:** As a user, I want to manage my profile information, so that I can update my details and change my password.
#### Acceptance Criteria
1. WHEN a user updates their profile information, THE Ultimate Lists App SHALL save the changes and associate them with the user's account
2. WHEN a user changes their password, THE Ultimate Lists App SHALL validate the current password before allowing the change
3. WHEN a user changes their password, THE Ultimate Lists App SHALL hash the new password before storing it
4. WHEN a user updates their email address, THE Ultimate Lists App SHALL verify that the new email is not already in use
5. WHEN a user views their profile, THE Ultimate Lists App SHALL display their current information without exposing sensitive data like password hashes
### Requirement 8: Invitation Notifications
**User Story:** As an invited user, I want to be notified when someone shares a list with me, so that I can accept or decline the invitation.
#### Acceptance Criteria
1. WHEN a user receives an invitation, THE Ultimate Lists App SHALL display the invitation in the user's notifications or invitations list
2. WHEN a user views pending invitations, THE Ultimate Lists App SHALL display the list name and the inviter's information
3. WHEN a user has pending invitations, THE Ultimate Lists App SHALL display a notification indicator
4. WHEN a user accepts or declines an invitation, THE Ultimate Lists App SHALL remove it from the pending invitations list
5. WHEN an invitation is created, THE Ultimate Lists App SHALL record the invitation timestamp
### Requirement 9: Data Isolation and Security
**User Story:** As a user, I want my data to be secure and isolated from other users, so that my privacy is protected.
#### Acceptance Criteria
1. WHEN the system queries for lists, THE Ultimate Lists App SHALL enforce user-based access control at the database query level
2. WHEN the system queries for items, THE Ultimate Lists App SHALL ensure items are only returned if the user has access to the parent list
3. WHEN a user attempts to access a resource, THE Ultimate Lists App SHALL verify the user's authentication before processing the request
4. WHEN the system stores passwords, THE Ultimate Lists App SHALL use a secure hashing algorithm with salt
5. WHEN the system creates session tokens, THE Ultimate Lists App SHALL generate cryptographically secure random tokens
### Requirement 10: Migration of Existing Data
**User Story:** As an existing user of the app, I want my current lists to be preserved when the authentication system is added, so that I don't lose my data.
#### Acceptance Criteria
1. WHEN the authentication system is deployed, THE Ultimate Lists App SHALL provide a migration path for existing anonymous lists
2. WHEN an existing user registers for the first time, THE Ultimate Lists App SHALL associate any existing lists with the new user account
3. WHEN lists are migrated to a user account, THE Ultimate Lists App SHALL preserve all list data including items, metadata, and timestamps
4. WHEN the migration is complete, THE Ultimate Lists App SHALL ensure all lists have an associated owner
5. WHEN a user accesses the app after migration, THE Ultimate Lists App SHALL display all their migrated lists in their account

View File

@@ -0,0 +1,283 @@
# Implementation Plan
- [x] 1. Set up authentication infrastructure
- Install and configure NextAuth.js v5, bcryptjs, and zod
- Create auth configuration file with credentials provider
- Set up environment variables for auth secrets
- _Requirements: 1.1, 2.1_
- [x] 2. Update database schema for multi-user support
- Add User model with email, passwordHash, and name fields
- Add Collaborator model for list sharing
- Add Invitation model for sharing invitations
- Add ownerId field to List model
- Add necessary indexes for performance
- _Requirements: 1.1, 4.1, 5.1_
- [ ]* 2.1 Write property test for user registration
- **Property 1: Registration creates unique user**
- **Validates: Requirements 1.1**
- [ ]* 2.2 Write property test for duplicate email rejection
- **Property 2: Duplicate email rejection**
- **Validates: Requirements 1.2**
- [ ]* 2.3 Write property test for password hashing
- **Property 3: Password hashing on registration**
- **Validates: Requirements 1.3**
- [x] 3. Implement user registration functionality
- Create registration server action with email/password validation
- Implement password hashing with bcrypt
- Add email uniqueness check
- Create session after successful registration
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
- [ ]* 3.1 Write property test for registration session creation
- **Property 4: Registration creates session**
- **Validates: Requirements 1.4**
- [ ]* 3.2 Write property test for password length validation
- **Property 5: Password length validation**
- **Validates: Requirements 1.5**
- [x] 4. Implement authentication functionality
- Create login server action with credential validation
- Implement password verification with bcrypt
- Create session on successful login
- Handle invalid credentials with appropriate errors
- _Requirements: 2.1, 2.2_
- [ ]* 4.1 Write property test for valid credentials
- **Property 6: Valid credentials create session**
- **Validates: Requirements 2.1**
- [ ]* 4.2 Write property test for invalid credentials
- **Property 7: Invalid credentials rejection**
- **Validates: Requirements 2.2**
- [x] 5. Implement session management
- Configure JWT session strategy in NextAuth
- Implement session validation middleware
- Create logout functionality
- Handle session expiration
- _Requirements: 2.4, 2.5, 3.1, 3.2, 3.4_
- [ ]* 5.1 Write property test for session expiration
- **Property 8: Session expiration enforcement**
- **Validates: Requirements 2.4**
- [ ]* 5.2 Write property test for logout
- **Property 9: Logout terminates session**
- **Validates: Requirements 2.5**
- [ ]* 5.3 Write property test for secure token generation
- **Property 10: Secure token generation**
- **Validates: Requirements 3.1**
- [ ]* 5.4 Write property test for session persistence
- **Property 11: Session persistence**
- **Validates: Requirements 3.2**
- [ ]* 5.5 Write property test for session duration
- **Property 12: Session duration persistence**
- **Validates: Requirements 3.4**
- [x] 6. Create authentication UI components
- Build LoginForm component with email/password inputs
- Build RegisterForm component with validation
- Create authentication error display page
- Add loading states and error handling
- _Requirements: 1.1, 2.1_
- [x] 7. Implement route protection middleware
- Create Next.js middleware for authentication checks
- Protect main routes (/, /list/*, /profile/*)
- Redirect unauthenticated users to login
- _Requirements: 3.3, 3.5_
- [x] 8. Update list operations for ownership
- Modify createList to assign ownerId
- Update getLists to filter by user access
- Add ownership checks to updateList and deleteList
- Implement cascade deletion for user account deletion
- _Requirements: 4.1, 4.2, 4.3, 4.5_
- [ ]* 8.1 Write property test for list ownership assignment
- **Property 13: List ownership assignment**
- **Validates: Requirements 4.1**
- [ ]* 8.2 Write property test for list access filtering
- **Property 14: List access filtering**
- **Validates: Requirements 4.2, 4.4**
- [ ]* 8.3 Write property test for unauthorized access denial
- **Property 15: Unauthorized list access denial**
- **Validates: Requirements 4.3**
- [ ]* 8.4 Write property test for account deletion cascade
- **Property 16: Account deletion cascades to lists**
- **Validates: Requirements 4.5**
- [x] 9. Implement permission utilities
- Create canAccessList function
- Create canEditList function
- Create canDeleteList function
- Create getPermission function
- _Requirements: 4.3, 6.2, 6.3_
- [x] 10. Update item operations for access control
- Add access checks to createItem
- Add access checks to updateItem, deleteItem, toggleItem
- Enforce editor vs viewer permissions
- Filter items by list access in queries
- _Requirements: 6.2, 6.3, 9.2_
- [ ]* 10.1 Write property test for item access through list permission
- **Property 34: Item access through list permission**
- **Validates: Requirements 9.2**
- [x] 11. Implement invitation system
- Create inviteUserToList server action
- Create getInvitations server action
- Create acceptInvitation server action
- Create declineInvitation server action
- Add invitation expiration logic
- _Requirements: 5.1, 5.2, 5.3, 8.1, 8.5_
- [ ]* 11.1 Write property test for invitation creation
- **Property 17: Invitation creation with unique ID**
- **Validates: Requirements 5.1, 6.1**
- [ ]* 11.2 Write property test for invitation acceptance
- **Property 18: Invitation acceptance grants access**
- **Validates: Requirements 5.2**
- [ ]* 11.3 Write property test for invitation decline
- **Property 19: Invitation decline removes record**
- **Validates: Requirements 5.3**
- [ ]* 11.4 Write property test for invitation visibility
- **Property 30: Invitation visibility**
- **Validates: Requirements 8.1**
- [ ]* 11.5 Write property test for invitation details
- **Property 31: Invitation details completeness**
- **Validates: Requirements 8.2**
- [ ]* 11.6 Write property test for invitation removal
- **Property 32: Invitation removal after action**
- **Validates: Requirements 8.4**
- [ ]* 11.7 Write property test for invitation timestamp
- **Property 33: Invitation timestamp recording**
- **Validates: Requirements 8.5**
- [x] 12. Implement collaborator management
- Create getCollaborators server action
- Create updateCollaboratorPermission server action
- Create removeCollaborator server action
- Add permission level enforcement
- _Requirements: 5.4, 5.5, 6.1, 6.5_
- [ ]* 12.1 Write property test for collaborator list
- **Property 20: Collaborator list completeness**
- **Validates: Requirements 5.4**
- [ ]* 12.2 Write property test for access revocation
- **Property 21: Access revocation enforcement**
- **Validates: Requirements 5.5**
- [ ]* 12.3 Write property test for editor permissions
- **Property 22: Editor permission allows modifications**
- **Validates: Requirements 6.2**
- [ ]* 12.4 Write property test for viewer permissions
- **Property 23: Viewer permission prevents modifications**
- **Validates: Requirements 6.3, 6.4**
- [ ]* 12.5 Write property test for permission updates
- **Property 24: Permission update enforcement**
- **Validates: Requirements 6.5**
- [x] 13. Create sharing UI components
- Build InvitationCard component
- Build CollaboratorList component
- Build ShareListModal component
- Build PermissionBadge component
- Add invitations page
- _Requirements: 5.1, 5.4, 8.1, 8.2_
- [x] 14. Implement profile management
- Create profile page and ProfileForm component
- Implement updateProfile server action
- Create PasswordChangeForm component
- Implement updatePassword server action with current password verification
- Add email update with uniqueness check
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
- [ ]* 14.1 Write property test for profile updates
- **Property 25: Profile update persistence**
- **Validates: Requirements 7.1**
- [ ]* 14.2 Write property test for password change validation
- **Property 26: Password change requires current password**
- **Validates: Requirements 7.2**
- [ ]* 14.3 Write property test for password hashing on change
- **Property 27: Password hashing on change**
- **Validates: Requirements 7.3**
- [ ]* 14.4 Write property test for email uniqueness
- **Property 28: Email uniqueness validation**
- **Validates: Requirements 7.4**
- [ ]* 14.5 Write property test for profile data exposure
- **Property 29: Profile data exposure protection**
- **Validates: Requirements 7.5**
- [x] 15. Update existing components for multi-user
- Modify ListCard to show sharing indicator
- Update ListDetailView with share button and permission display
- Modify ItemForm to disable editing for viewers
- Update navigation to include profile and invitations links
- _Requirements: 6.3_
- [x] 16. Implement data migration for existing lists
- Create migration script for existing anonymous lists
- Add UI for users to claim existing lists on first registration
- Ensure all lists have an owner after migration
- Preserve all existing list and item data
- _Requirements: 10.2, 10.3, 10.4, 10.5_
- [ ]* 16.1 Write property test for migration data preservation
- **Property 35: Migration data preservation**
- **Validates: Requirements 10.3**
- [x] 17. Add security enhancements
- Implement rate limiting on login attempts
- Add CSRF protection
- Implement secure session cookie configuration
- Add input sanitization for all user inputs
- _Requirements: 9.3, 9.4, 9.5_
- [ ] 18. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 19. Update PWA service worker for authentication
- Handle authentication errors in offline mode
- Cache authentication-related pages appropriately
- Update manifest if needed
- Test offline behavior with authentication
- _Requirements: 4.4_
- [ ] 20. Final integration and testing
- Test complete authentication flow end-to-end
- Test list sharing workflow end-to-end
- Verify permission enforcement across all operations
- Test migration process with sample data
- Verify PWA functionality still works
- _Requirements: All_
- [ ] 21. Final Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.

110
AUTH_SETUP.md Normal file
View File

@@ -0,0 +1,110 @@
# Authentication Setup
This document describes the authentication infrastructure setup for the Ultimate Lists App.
## Installed Dependencies
- **next-auth@5.0.0-beta** - NextAuth.js v5 (Auth.js) for authentication
- **bcryptjs** - Password hashing library
- **zod** - Schema validation for forms and API inputs
- **@auth/prisma-adapter** - Prisma adapter for NextAuth
- **@types/bcryptjs** - TypeScript types for bcryptjs
## Configuration Files
### `auth.ts`
Main authentication configuration file that exports:
- `handlers` - HTTP handlers for the auth API routes
- `signIn` - Function to sign in users
- `signOut` - Function to sign out users
- `auth` - Function to get the current session
Features:
- Credentials provider for email/password authentication
- JWT session strategy
- Password verification with bcrypt
- Custom callbacks for JWT and session
- Custom auth pages configuration
### `auth.config.ts`
Edge-compatible auth configuration for middleware:
- Route authorization logic
- Custom auth pages
- Provider configuration placeholder
### `types/next-auth.d.ts`
TypeScript type extensions for NextAuth:
- Adds `id` field to Session user
- Adds `id` field to JWT token
- Defines User interface
### `app/api/auth/[...nextauth]/route.ts`
API route handler that exports GET and POST handlers from NextAuth.
## Environment Variables
The following environment variables have been added to `.env`:
```env
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_SECRET=your-secret-key-here-please-change-in-production
```
**Important:** Generate secure secrets for production using:
```bash
openssl rand -base64 32
```
## Next Steps
1. Update the Prisma schema to add User, Collaborator, and Invitation models (Task 2)
2. Implement user registration functionality (Task 3)
3. Implement authentication functionality (Task 4)
4. Create authentication UI components (Task 6)
5. Implement route protection middleware (Task 7)
## Usage
### Getting the current session
```typescript
import { auth } from "@/auth"
export default async function Page() {
const session = await auth()
if (!session?.user) {
// User is not authenticated
return <div>Please log in</div>
}
// User is authenticated
return <div>Welcome {session.user.email}</div>
}
```
### Sign in
```typescript
import { signIn } from "@/auth"
await signIn("credentials", {
email: "user@example.com",
password: "password123",
redirectTo: "/",
})
```
### Sign out
```typescript
import { signOut } from "@/auth"
await signOut({ redirectTo: "/auth/login" })
```
## Security Notes
- Passwords are hashed using bcrypt before storage
- JWT tokens are used for stateless sessions
- Session tokens are stored in httpOnly cookies
- All credentials are validated using Zod schemas
- Failed login attempts return null without revealing whether email exists

View File

@@ -0,0 +1,115 @@
# Push Notifications Setup for Docker
## Quick Setup (3 Steps)
### Step 1: Generate VAPID Keys
Run this **on your host machine** (not in Docker):
```bash
npx web-push generate-vapid-keys
```
Or if you prefer, you can run it in a temporary container:
```bash
docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
```
This will output:
```
=======================================
Public Key:
BNxxx...
Private Key:
xxx...
=======================================
```
### Step 2: Add Keys to .env File
Add these three lines to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
⚠️ **Important**:
- Replace `BNxxx...` with your actual public key
- Replace `xxx...` with your actual private key
- Replace `your-email@example.com` with your actual email
- Keep the VAPID_PRIVATE_KEY secret!
### Step 3: Update Database Schema
Run this to add the PushSubscription table:
```bash
docker-compose exec app npx prisma db push
```
Or if your containers aren't running yet:
```bash
docker-compose up -d
docker-compose exec app npx prisma db push
```
### Step 4: Rebuild and Restart
```bash
docker-compose down
docker-compose up --build
```
## That's It!
Your app now has push notifications enabled! When users open the app:
- After 3 seconds, they'll see a notification prompt
- They can enable notifications with one click
- Notifications will be sent automatically for list events
## Verify It's Working
1. Open your app at `http://localhost:3500`
2. Log in
3. Wait 3 seconds for the notification prompt
4. Click "Enable Notifications"
5. Open another browser/incognito window
6. Log in as a different user
7. Add an item to a shared list
8. See the notification in the first browser!
## Troubleshooting
### "Push notifications not configured" error
- Make sure VAPID keys are in `.env`
- Make sure you rebuilt the containers: `docker-compose up --build`
- Check logs: `docker-compose logs app`
### Database table missing
- Run: `docker-compose exec app npx prisma db push`
- Check if it succeeded: `docker-compose exec app npx prisma studio`
### Environment variables not loading
- Make sure `.env` is in the same directory as `docker-compose.yml`
- Restart containers: `docker-compose restart`
- Check if variables are loaded: `docker-compose exec app env | grep VAPID`
## Docker-Specific Notes
- The `web-push` package is automatically installed during `docker-compose up --build`
- VAPID keys are loaded from your `.env` file into the container
- The database schema update must be run inside the container
- All notification functionality works the same in Docker as locally
## Production Deployment
For production:
1. Generate production VAPID keys (different from dev)
2. Add them to your production environment variables
3. Run `npx prisma db push` in production container
4. Ensure your production URL uses HTTPS (required for push notifications)

153
DOCKER_SETUP.md Normal file
View File

@@ -0,0 +1,153 @@
# Docker Setup Guide
This guide explains how to run the Ultimate Lists App using Docker on port 3500.
## Prerequisites
- Docker and Docker Compose installed
- MySQL server running (external to Docker)
- Environment variables configured
## Configuration
### 1. Update next.config.js
The application needs to be configured for standalone output. Update your `next.config.js`:
```javascript
const nextConfig = {
output: 'standalone',
// ... rest of your config
}
```
### 2. Environment Variables
Create a `.env` file in the project root with your MySQL connection details:
```env
# MySQL Connection (external server)
DATABASE_URL="mysql://user:password@host:port/database"
# For Docker host machine MySQL, use:
# DATABASE_URL="mysql://user:password@host.docker.internal:3306/database"
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3500
NEXTAUTH_SECRET=your-secret-key-here
AUTH_SECRET=your-secret-key-here
# Optional: MySQL connection check variables
DB_HOST=host.docker.internal
DB_PORT=3306
DB_USER=your-db-user
DB_PASSWORD=your-db-password
```
**Important:** If your MySQL server is running on your host machine (localhost), use `host.docker.internal` instead of `localhost` in the DATABASE_URL.
### 3. Run Database Migrations
Before building the Docker image, ensure your database schema is up to date:
```bash
npm install
npx prisma generate
npx prisma db push
```
## Building and Running
### Option 1: Using Docker Compose (Recommended)
```bash
# Build and start the container
docker-compose up -d
# View logs
docker-compose logs -f app
# Stop the container
docker-compose down
```
### Option 2: Using Docker directly
```bash
# Build the image
docker build -t ultimate-lists-app .
# Run the container
docker run -d \
-p 3500:3500 \
--env-file .env \
--name ultimate-lists-app \
ultimate-lists-app
# View logs
docker logs -f ultimate-lists-app
# Stop the container
docker stop ultimate-lists-app
docker rm ultimate-lists-app
```
## Accessing the Application
Once running, access the application at:
- **http://localhost:3500**
## Troubleshooting
### Cannot connect to MySQL
If you see database connection errors:
1. Verify MySQL is running and accessible
2. Check that you're using `host.docker.internal` instead of `localhost` in DATABASE_URL
3. Ensure MySQL allows connections from Docker containers
4. Verify firewall settings allow the connection
### Build fails
If the build fails:
1. Ensure `output: 'standalone'` is set in `next.config.js`
2. Check that all dependencies are properly installed
3. Verify Prisma schema is valid
### Port already in use
If port 3500 is already in use:
```bash
# Find what's using the port
lsof -i :3500
# Change the port in docker-compose.yml or use a different port:
docker run -p 3501:3500 ...
```
## Development vs Production
This Docker setup is configured for production use. For development:
```bash
# Use the regular dev server
npm run dev
```
## Updating the Application
To update after code changes:
```bash
# Rebuild and restart
docker-compose up -d --build
# Or with Docker directly
docker build -t ultimate-lists-app .
docker stop ultimate-lists-app
docker rm ultimate-lists-app
docker run -d -p 3500:3500 --env-file .env --name ultimate-lists-app ultimate-lists-app
```

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
ENV AUTH_TRUST_HOST=true
EXPOSE 3000
CMD ["npm", "run", "dev"]

38
ITS_WORKING_HERE.md Normal file
View File

@@ -0,0 +1,38 @@
# ✅ IT'S WORKING HERE
**Date:** December 3, 2025
## Current Status
The Ultimate Lists application is fully functional at this checkpoint!
## What's Working
- ✅ User authentication (login/register)
- ✅ List creation and management
- ✅ Task creation, editing, and deletion
- ✅ Task completion toggle with optimistic updates
- ✅ Real-time sync (1-second polling) for cross-device updates
- ✅ List sharing with collaborators (owner/editor/viewer permissions)
- ✅ Invitation system
- ✅ User profile management
- ✅ Password change functionality
- ✅ Logout button
- ✅ PWA support with offline capabilities
- ✅ Responsive design for mobile and desktop
- ✅ Toast notifications with auto-dismiss
- ✅ Permission badges
- ✅ Compact item cards with right-aligned priority badges
- ✅ Clean UI without search/filter complexity
## Recent Changes
1. Removed SearchBar and FilterPanel from list detail view to fix hydration errors
2. Moved permission badge and share button above the add task bar
3. Added logout button to home page header
4. Fixed React hydration issues with mounted state checks
5. Implemented optimistic updates for instant UI feedback
6. Redesigned ItemCard layout for better UX
## Known Issues
None at this checkpoint - everything is working smoothly!
## Next Steps
Continue building additional features as needed.

160
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,160 @@
# Data Migration Guide
This guide explains how to migrate existing anonymous lists to the new user authentication system.
## Overview
The Ultimate Lists App now supports user authentication and private lists. This migration ensures that:
- All existing lists are preserved (Requirement 10.3)
- All lists have an associated owner (Requirement 10.4)
- Users can claim their existing lists when they register (Requirement 10.2)
- All list data including items, metadata, and timestamps remain unchanged (Requirement 10.3)
## Migration Strategy
### For New Deployments
If you're deploying the authentication system for the first time:
1. **Run the migration script** before enabling authentication:
```bash
npx tsx scripts/migrate-lists.ts
```
This script will:
- Create a system user if it doesn't exist
- Assign any orphaned lists to the system user
- Verify all lists have owners
2. **Deploy the application** with authentication enabled
3. **Users can claim lists** when they register:
- Lists created before registration are tracked in localStorage
- During registration, users are prompted to claim their lists
- Lists are transferred from system user to the new user account
### For Existing Users
When a user registers for the first time:
1. The app checks localStorage for any tracked list IDs
2. If lists are found, a modal appears asking the user to claim them
3. User can select which lists to claim
4. Selected lists are transferred to the user's account
5. All list data (items, metadata, timestamps) is preserved
## Technical Details
### Database Schema Changes
The migration adds:
- `User` table with authentication fields
- `ownerId` field on `List` table (required)
- `Collaborator` table for list sharing
- `Invitation` table for sharing invitations
### Migration Script
Location: `scripts/migrate-lists.ts`
The script:
- Creates a system user (`system@ultimatelists.app`)
- Assigns orphaned lists to the system user
- Verifies data integrity
- Provides a migration summary
### Client-Side Tracking
Location: `lib/anonymous-lists.ts`
Utilities for tracking anonymous lists:
- `trackAnonymousList(listId)` - Add a list to tracking
- `getAnonymousListIds()` - Get all tracked lists
- `clearAnonymousLists()` - Clear tracking after migration
- `hasAnonymousLists()` - Check if there are lists to migrate
### Migration Actions
Location: `lib/migration-actions.ts`
Server actions for migration:
- `getClaimableLists(listIds)` - Fetch lists that can be claimed
- `claimLists(listIds)` - Transfer lists to user account
- `hasExistingLists()` - Check if user has lists
- `migrateOrphanedListsToSystemUser()` - One-time migration script
### UI Components
Location: `components/ClaimListsModal.tsx`
Modal component that:
- Displays claimable lists with details
- Allows users to select which lists to claim
- Handles the claim process
- Clears localStorage after successful claim
## Testing the Migration
### Test Scenario 1: New User with Anonymous Lists
1. Create lists without being logged in
2. Register for an account
3. Verify the claim modal appears
4. Select lists to claim
5. Verify lists appear in the user's account
6. Verify all items and metadata are preserved
### Test Scenario 2: New User without Anonymous Lists
1. Register for an account without creating lists
2. Verify no claim modal appears
3. Verify user is redirected to the app
### Test Scenario 3: Existing User
1. Log in with an existing account
2. Verify existing lists are displayed
3. Verify all data is intact
## Rollback Plan
If issues occur during migration:
1. **Database Rollback**:
```bash
npx prisma migrate reset
```
2. **Restore from Backup**:
- Restore database from pre-migration backup
- Redeploy previous version of the application
3. **Manual Data Recovery**:
- Export lists from system user
- Manually assign to correct users
- Verify data integrity
## Monitoring
After migration, monitor:
- Number of orphaned lists (should be 0)
- Number of lists owned by system user
- User registration and claim rates
- Any errors in migration logs
## Support
If users experience issues:
1. Check browser console for errors
2. Verify localStorage contains list IDs
3. Check server logs for migration errors
4. Manually transfer lists if needed using admin tools
## Requirements Validation
This migration satisfies:
- ✓ Requirement 10.1: Authentication system provides migration path
- ✓ Requirement 10.2: Users can claim existing lists on registration
- ✓ Requirement 10.3: All list data is preserved during migration
- ✓ Requirement 10.4: All lists have an associated owner
- ✓ Requirement 10.5: Migrated lists appear in user's account

View File

@@ -0,0 +1,264 @@
# Web Push Notifications - Complete Implementation ✅
## What Was Implemented
A complete, self-hosted Web Push notification system for your PWA shopping list app.
## 📦 Files Created
### Core Implementation
- `lib/push-notifications.ts` - Server-side push notification logic with VAPID
- `lib/notification-helpers.ts` - Helper functions for specific notification events
- `hooks/usePushNotifications.ts` - React hook for managing subscriptions
- `components/NotificationSettings.tsx` - UI component for enabling/disabling notifications
### API Routes
- `app/api/push/vapid-key/route.ts` - Returns public VAPID key
- `app/api/push/subscribe/route.ts` - Saves push subscriptions
- `app/api/push/unsubscribe/route.ts` - Removes push subscriptions
### Database
- Updated `prisma/schema.prisma` - Added `PushSubscription` model
### Service Worker
- Updated `public/sw.js` - Added push event handlers
### Scripts & Config
- `scripts/generate-vapid-keys.js` - Generate VAPID keys
- Updated `package.json` - Added web-push dependency and npm script
- Updated `.env.example` - Added VAPID key placeholders
### Documentation
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup guide
- `PUSH_NOTIFICATIONS_SETUP.md` - Complete setup and configuration
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code integration examples
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture and testing guide
- `PUSH_NOTIFICATIONS_COMPLETE.md` - This file
## 🎯 How It Works
### The Push Notification Server
**You don't need an external service!** This implementation uses **VAPID** (Voluntary Application Server Identification), which is a standard protocol that allows your own server to send push notifications directly to browsers.
Here's the flow:
1. **Generate VAPID Keys** (one-time setup)
- Run `npm run vapid:generate`
- These are like JWT secrets - a public/private key pair
- Public key: shared with browsers
- Private key: kept secret on your server
2. **User Subscribes**
- User clicks "Enable" in NotificationSettings
- Browser creates a unique push subscription (includes endpoint URL)
- Subscription saved to your database
3. **Event Occurs**
- Someone adds an item to a shared list
- Your server action calls `notifyItemAdded()`
- Function looks up all subscriptions for list members
- Sends push notification using `web-push` library
4. **Browser Receives**
- Browser's push service receives the notification
- Service worker wakes up and displays it
- User sees notification even if app is closed
### The "Server" is Just Your App
The push notification "server" is simply:
- The `web-push` npm package (installed in your app)
- Your VAPID keys (in your .env file)
- Your existing Next.js API routes
No external service, no additional infrastructure needed!
## 🚀 Setup Instructions
### Step 1: Install Dependencies
```bash
npm install
```
### Step 2: Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
### Step 3: Update Database
```bash
npx prisma db push
```
### Step 4: Add UI Component
In `app/profile/page.tsx`:
```typescript
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div>
{/* Your existing content */}
<NotificationSettings />
</div>
);
}
```
### Step 5: Integrate Notifications
In your action files (see `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for details):
```typescript
import { notifyItemAdded } from '@/lib/notification-helpers';
// After creating an item:
await notifyItemAdded(listId, item.title, session.user.id);
```
## 📱 Notification Events
| Event | Function | When |
|-------|----------|------|
| Item added | `notifyItemAdded()` | Someone adds item to shared list |
| Item removed | `notifyItemRemoved()` | Someone removes item |
| Item completed | `notifyItemCompleted()` | Someone marks item complete |
| List invitation | `notifyListInvitation()` | Someone invites you |
| Invitation accepted | `notifyInvitationAccepted()` | Someone accepts your invite |
| Removed from list | `notifyRemovedFromList()` | Owner removes you |
## 🌐 Browser Support
| Platform | Support | Notes |
|----------|---------|-------|
| Chrome Desktop | ✅ Full | Works perfectly |
| Chrome Android | ✅ Full | Works perfectly |
| Firefox Desktop | ✅ Full | Works perfectly |
| Firefox Android | ✅ Full | Works perfectly |
| Safari macOS | ✅ 16.4+ | Works when installed |
| Safari iOS | ✅ 16.4+ | **Must be installed to home screen** |
| Edge | ✅ Full | Chromium-based |
### iOS Requirements
- iOS 16.4 or later
- Must use "Add to Home Screen" (won't work in Safari browser)
- Requires HTTPS in production
## 🔒 Security
- VAPID private key must be kept secret (like JWT secret)
- Subscriptions are tied to authenticated users
- Expired subscriptions automatically cleaned up
- No cross-user notification access
## 🧪 Testing
### Desktop (Chrome)
1. Start dev server: `npm run dev`
2. Open DevTools → Application → Service Workers
3. Enable notifications in profile
4. Trigger an action (add item, etc.)
5. See notification appear
### iOS (Real Device)
1. Deploy with HTTPS
2. Open in Safari
3. Share → Add to Home Screen
4. Open installed app
5. Enable notifications
6. Test from another device
## 📊 Database Schema
New table added:
```prisma
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @db.Text
p256dh String @db.Text
auth String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(...)
}
```
## 🎨 Customization
### Change Notification Content
Edit `lib/notification-helpers.ts` to customize titles, bodies, icons.
### Add Per-List Preferences
1. Add `notificationsEnabled` to `Collaborator` model
2. Check this field before sending notifications
3. Add UI toggle in list settings
### Add Notification Sounds
Update service worker push event:
```javascript
const options = {
...existing,
silent: false,
sound: '/notification-sound.mp3',
};
```
## 🐛 Troubleshooting
### "Push notifications not configured"
- Verify VAPID keys are in `.env`
- Restart dev server
### Notifications not appearing
- Check browser permission (must be "granted")
- Verify service worker is active
- Check console for errors
- Ensure HTTPS (or localhost)
### iOS not working
- Check iOS version (16.4+)
- Must be installed to home screen
- Check Settings → [App] → Notifications
## 📈 Performance
- Notifications sent asynchronously (non-blocking)
- Multiple devices per user supported
- Automatic cleanup of expired subscriptions
- TTL: 24 hours
## ✅ Production Checklist
- [ ] Generate production VAPID keys
- [ ] Add to production environment variables
- [ ] Run `npx prisma db push` on production DB
- [ ] Verify HTTPS is working
- [ ] Test on actual iOS device
- [ ] Monitor logs for errors
## 🎉 You're Done!
Your app now has:
- ✅ Self-hosted push notifications
- ✅ No external dependencies
- ✅ iOS and Android support
- ✅ Multi-device support
- ✅ Automatic subscription management
Users will receive notifications when:
- Items are added/removed/completed in shared lists
- They're invited to lists
- Their invitations are accepted
- They're removed from lists
All without any external services or additional infrastructure!

View File

@@ -0,0 +1,81 @@
# Push Notifications Docker Setup Checklist
## ✅ What's Already Done
- [x] Code implementation complete
- [x] Docker configuration updated
- [x] Environment variables added to docker-compose.yml
- [x] Dependencies added to package.json (will install on build)
## 🔧 What You Need to Do
### [ ] 1. Generate VAPID Keys
Run this command:
```bash
npx web-push generate-vapid-keys
```
Or if you don't have Node locally:
```bash
docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
```
### [ ] 2. Add Keys to .env
Copy the output and add to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:psbailey31@gmail.com
```
### [ ] 3. Update Database
Start containers and run migration:
```bash
docker-compose up -d
docker-compose exec app npx prisma db push
```
### [ ] 4. Rebuild Containers
```bash
docker-compose down
docker-compose up --build
```
## ✅ Verify It Works
1. Open http://localhost:3500
2. Log in
3. Wait 3 seconds
4. See notification prompt appear
5. Click "Enable Notifications"
6. Test by adding items from another browser
## 🎉 Done!
Once you complete these 4 steps, push notifications will work automatically!
## Quick Reference
**Check if VAPID keys are loaded:**
```bash
docker-compose exec app env | grep VAPID
```
**Check database tables:**
```bash
docker-compose exec app npx prisma studio
```
**View logs:**
```bash
docker-compose logs -f app
```
**Restart after .env changes:**
```bash
docker-compose restart
```

View File

@@ -0,0 +1,97 @@
# Web Push Notifications Implementation Guide
## Overview
This guide explains how to implement Web Push notifications in your PWA using VAPID (self-hosted, no external service needed).
## Architecture
1. **VAPID Keys**: Generate once, store securely (like JWT secrets)
2. **Client**: Request permission, subscribe to push notifications
3. **Database**: Store push subscriptions per user
4. **Server**: Send notifications when list events occur
5. **Service Worker**: Receive and display notifications
## Setup Steps
### 1. Generate VAPID Keys
Run this once to generate your VAPID keys:
```bash
npx web-push generate-vapid-keys
```
Add to `.env`:
```
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:your-email@example.com
```
### 2. Install Dependencies
```bash
npm install web-push
```
### 3. Database Schema
Add `PushSubscription` model to store user subscriptions (see prisma/schema.prisma changes).
### 4. Client-Side Flow
1. User grants notification permission
2. Service worker subscribes to push manager
3. Subscription sent to server and stored in database
4. Server sends notifications when events occur
5. Service worker displays notifications
### 5. Notification Triggers
Notifications are sent when:
- Someone adds/removes an item from a shared list
- Someone invites you to a list
- Someone accepts your list invitation
- Someone removes you from a list
## Testing
### Local Testing
- Use Chrome DevTools > Application > Service Workers
- Use Chrome DevTools > Application > Push Messaging to simulate
### iOS Testing
- Must be installed to home screen
- iOS 16.4+ required
- Test on actual device (simulator doesn't support push)
### Android Testing
- Works in browser and installed PWA
- Test on actual device for best results
## Privacy & Best Practices
- Always request permission explicitly (don't auto-prompt)
- Allow users to disable notifications
- Store subscriptions securely
- Handle subscription expiration/updates
- Respect user preferences per list
## Troubleshooting
### Notifications not appearing
- Check permission status
- Verify service worker is active
- Check browser console for errors
- Ensure VAPID keys are correct
### iOS specific issues
- Must be installed to home screen
- Check iOS version (16.4+)
- Verify manifest.json is correct
### Subscription fails
- Check VAPID public key matches server
- Verify service worker scope
- Check network requests in DevTools

View File

@@ -0,0 +1,183 @@
# ✅ Push Notifications - FULLY IMPLEMENTED
## What's Been Done
Your PWA now has a complete, self-hosted push notification system! Here's what was implemented:
### 1. Core Infrastructure ✅
- **Database**: Added `PushSubscription` model to Prisma schema
- **Server Logic**: Created `lib/push-notifications.ts` with VAPID support
- **Notification Helpers**: Created `lib/notification-helpers.ts` for event-specific notifications
- **API Routes**: 3 routes for subscription management
- **Service Worker**: Updated with push event handlers
- **React Hook**: Created `usePushNotifications` for client-side management
- **UI Component**: Created `NotificationSettings` component
### 2. Integration Complete ✅
- **Profile Page**: Added NotificationSettings component
- **Item Actions**: Integrated notifications for:
- ✅ Item added → `notifyItemAdded()`
- ✅ Item removed → `notifyItemRemoved()`
- ✅ Item completed → `notifyItemCompleted()`
- **Sharing Actions**: Integrated notifications for:
- ✅ List invitation → `notifyListInvitation()`
- ✅ Invitation accepted → `notifyInvitationAccepted()`
- ✅ Collaborator removed → `notifyRemovedFromList()`
### 3. Dependencies ✅
- Added `web-push` package
- Added `@types/web-push` for TypeScript
- Added npm script: `npm run vapid:generate`
## Setup Instructions
### Step 1: Install Dependencies
```bash
npm install
```
### Step 2: Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
### Step 3: Update Database
```bash
npx prisma db push
```
### Step 4: Restart Your Server
```bash
npm run dev
```
## How to Use
### For Users:
1. Go to Profile page
2. Click "Enable" in the Notification Settings section
3. Grant permission when browser prompts
4. Done! You'll now receive notifications
### Notifications Are Sent When:
- Someone adds an item to a shared list
- Someone removes an item from a shared list
- Someone completes an item in a shared list
- Someone invites you to a list
- Someone accepts your list invitation
- You're removed from a list
## Technical Details
### The "Server" Solution
You asked about the push notification server - **it's your own Next.js app!**
- Uses VAPID (Voluntary Application Server Identification)
- No external services needed (no Firebase, no third-party APIs)
- The `web-push` npm package handles the protocol
- Your VAPID keys authenticate your server to browsers
- Completely self-hosted and free
### How It Works
1. User enables notifications → Browser creates subscription
2. Subscription saved to your database
3. Event occurs (item added, etc.) → Your server action runs
4. Server sends push via `web-push` library
5. Browser's push service delivers to device
6. Service worker displays notification
### Browser Support
- ✅ Chrome/Edge (Desktop & Android)
- ✅ Firefox (Desktop & Android)
- ✅ Safari 16.4+ (macOS & iOS when installed to home screen)
## Files Created
### Core Files
- `lib/push-notifications.ts` - Server push logic
- `lib/notification-helpers.ts` - Event-specific notifications
- `hooks/usePushNotifications.ts` - React hook
- `components/NotificationSettings.tsx` - UI component
- `app/api/push/vapid-key/route.ts` - API route
- `app/api/push/subscribe/route.ts` - API route
- `app/api/push/unsubscribe/route.ts` - API route
- `scripts/generate-vapid-keys.js` - Key generation script
### Documentation
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup
- `PUSH_NOTIFICATIONS_COMPLETE.md` - Full overview
- `PUSH_NOTIFICATIONS_SETUP.md` - Detailed setup
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code examples
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture guide
- `PUSH_NOTIFICATIONS_IMPLEMENTED.md` - This file
### Modified Files
- `prisma/schema.prisma` - Added PushSubscription model
- `public/sw.js` - Added push event handlers
- `package.json` - Added dependencies and script
- `.env.example` - Added VAPID key placeholders
- `app/profile/page.tsx` - Added NotificationSettings component
- `lib/actions.ts` - Integrated notification calls
- `lib/sharing-actions.ts` - Integrated notification calls
## Testing
### Desktop (Chrome)
1. Start dev server: `npm run dev`
2. Go to profile, enable notifications
3. Open another browser/incognito window
4. Log in as different user
5. Add item to shared list
6. See notification in first browser
### iOS (Real Device)
1. Deploy with HTTPS
2. Open in Safari
3. Share → Add to Home Screen
4. Open installed app
5. Enable notifications in profile
6. Test from another device
## Production Checklist
- [ ] Run `npm install`
- [ ] Run `npm run vapid:generate`
- [ ] Add VAPID keys to production `.env`
- [ ] Run `npx prisma db push` on production database
- [ ] Verify HTTPS is working
- [ ] Test notifications on actual devices
- [ ] Monitor logs for errors
## What Makes This Special
1. **Self-Hosted**: No external dependencies or costs
2. **Privacy-Focused**: All data stays in your database
3. **Multi-Device**: Users can subscribe on multiple devices
4. **Automatic Cleanup**: Expired subscriptions removed automatically
5. **Non-Blocking**: Notifications sent asynchronously
6. **Graceful Degradation**: If notifications fail, actions still succeed
## Next Steps (Optional Enhancements)
- Add per-list notification preferences
- Add quiet hours (don't notify during certain times)
- Add notification history/inbox
- Add action buttons to notifications
- Add notification grouping/batching
- Add sound customization
## Support
All files compile without errors. The implementation is complete and ready to use!
For questions, refer to:
- `PUSH_NOTIFICATIONS_QUICK_START.md` for setup
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for code examples
- `PUSH_NOTIFICATIONS_SETUP.md` for troubleshooting

View File

@@ -0,0 +1,338 @@
# Push Notifications Integration Example
This shows exactly how to add notifications to your existing code.
## Step 1: Import the notification helpers
At the top of your action files, add:
```typescript
import {
notifyItemAdded,
notifyItemRemoved,
notifyItemCompleted,
notifyListInvitation,
notifyInvitationAccepted,
notifyRemovedFromList,
} from '@/lib/notification-helpers';
```
## Step 2: Add notification calls after actions
### Example: Adding an item
**Before:**
```typescript
export async function createItem(listId: string, data: any) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.create({
data: {
...data,
listId,
},
});
revalidatePath(`/list/${listId}`);
return item;
}
```
**After:**
```typescript
export async function createItem(listId: string, data: any) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.create({
data: {
...data,
listId,
},
});
// 🔔 Add this line to send notifications
await notifyItemAdded(listId, item.title, session.user.id);
revalidatePath(`/list/${listId}`);
return item;
}
```
### Example: Deleting an item
**Before:**
```typescript
export async function deleteItem(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
await prisma.item.delete({
where: { id: itemId },
});
revalidatePath('/');
}
```
**After:**
```typescript
export async function deleteItem(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
// 🔔 Get item details before deleting
const item = await prisma.item.findUnique({
where: { id: itemId },
});
await prisma.item.delete({
where: { id: itemId },
});
// 🔔 Send notification
if (item) {
await notifyItemRemoved(item.listId, item.title, session.user.id);
}
revalidatePath('/');
}
```
### Example: Toggling item completion
**Before:**
```typescript
export async function toggleItemComplete(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.findUnique({
where: { id: itemId },
});
const updated = await prisma.item.update({
where: { id: itemId },
data: { completed: !item?.completed },
});
revalidatePath('/');
return updated;
}
```
**After:**
```typescript
export async function toggleItemComplete(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.findUnique({
where: { id: itemId },
});
const updated = await prisma.item.update({
where: { id: itemId },
data: { completed: !item?.completed },
});
// 🔔 Notify when item is completed (not uncompleted)
if (updated.completed) {
await notifyItemCompleted(updated.listId, updated.title, session.user.id);
}
revalidatePath('/');
return updated;
}
```
### Example: Inviting to a list
**Before:**
```typescript
export async function inviteToList(listId: string, email: string, permission: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.create({
data: {
listId,
email,
permission,
inviterId: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return invitation;
}
```
**After:**
```typescript
export async function inviteToList(listId: string, email: string, permission: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.create({
data: {
listId,
email,
permission,
inviterId: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// 🔔 Notify the invitee
await notifyListInvitation(invitation.id, email);
return invitation;
}
```
### Example: Accepting an invitation
**Before:**
```typescript
export async function acceptInvitation(invitationId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
});
await prisma.collaborator.create({
data: {
userId: session.user.id,
listId: invitation.listId,
permission: invitation.permission,
},
});
return invitation;
}
```
**After:**
```typescript
export async function acceptInvitation(invitationId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
});
await prisma.collaborator.create({
data: {
userId: session.user.id,
listId: invitation.listId,
permission: invitation.permission,
},
});
// 🔔 Notify the person who sent the invitation
await notifyInvitationAccepted(invitationId);
return invitation;
}
```
### Example: Removing a collaborator
**Before:**
```typescript
export async function removeCollaborator(listId: string, userId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
await prisma.collaborator.deleteMany({
where: {
listId,
userId,
},
});
revalidatePath(`/list/${listId}`);
}
```
**After:**
```typescript
export async function removeCollaborator(listId: string, userId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
// 🔔 Get list details before removing
const list = await prisma.list.findUnique({
where: { id: listId },
});
await prisma.collaborator.deleteMany({
where: {
listId,
userId,
},
});
// 🔔 Notify the removed user
if (list) {
await notifyRemovedFromList(userId, list.title, session.user.id);
}
revalidatePath(`/list/${listId}`);
}
```
## Step 3: Add NotificationSettings to Profile Page
In `app/profile/page.tsx`, add the component:
```typescript
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-2xl font-bold">Profile Settings</h1>
{/* Your existing profile components */}
<ProfileForm />
<PasswordChangeForm />
{/* 🔔 Add notification settings */}
<NotificationSettings />
</div>
);
}
```
## That's It!
The notification system will:
- ✅ Automatically handle permission requests
- ✅ Store subscriptions per user/device
- ✅ Send notifications to all relevant users
- ✅ Exclude the user who triggered the action
- ✅ Clean up expired subscriptions
- ✅ Work across multiple devices
## Optional: Error Handling
If you want to handle notification failures gracefully:
```typescript
try {
await notifyItemAdded(listId, item.title, session.user.id);
} catch (error) {
// Log but don't fail the action
console.error('Failed to send notification:', error);
}
```
Notifications are non-critical, so it's fine to let them fail silently without affecting the main action.

View File

@@ -0,0 +1,82 @@
# Push Notifications - Quick Start
## 🚀 5-Minute Setup
### 1. Install & Generate Keys
```bash
npm install
npm run vapid:generate
```
### 2. Add Keys to .env
```env
VAPID_PUBLIC_KEY=<copy from output>
VAPID_PRIVATE_KEY=<copy from output>
VAPID_SUBJECT=mailto:your-email@example.com
```
### 3. Update Database
```bash
npx prisma db push
```
### 4. Add to Profile Page
```typescript
// app/profile/page.tsx
import NotificationSettings from '@/components/NotificationSettings';
// Add <NotificationSettings /> to your page
```
### 5. Send Notifications
```typescript
// In your action files
import { notifyItemAdded } from '@/lib/notification-helpers';
// After creating an item:
await notifyItemAdded(listId, item.title, session.user.id);
```
## ✅ Done!
Users can now:
- Enable notifications in their profile
- Receive push notifications on all their devices
- Get notified about list activity even when the app is closed
## 📱 iOS Support
For iOS notifications:
1. Deploy with HTTPS
2. User must "Add to Home Screen"
3. Requires iOS 16.4+
## 🔧 Available Notification Functions
```typescript
// Item notifications
notifyItemAdded(listId, itemTitle, userId)
notifyItemRemoved(listId, itemTitle, userId)
notifyItemCompleted(listId, itemTitle, userId)
// Invitation notifications
notifyListInvitation(invitationId, email)
notifyInvitationAccepted(invitationId)
// Collaboration notifications
notifyRemovedFromList(userId, listTitle, removedByUserId)
```
## 📚 Full Documentation
- **PUSH_NOTIFICATIONS_SETUP.md** - Complete setup guide
- **PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md** - Code examples
- **PUSH_NOTIFICATIONS_GUIDE.md** - Architecture & testing
## 🎯 Key Features
- ✅ Self-hosted (no external services)
- ✅ Works on iOS, Android, Desktop
- ✅ Multiple devices per user
- ✅ Automatic subscription cleanup
- ✅ Non-blocking (won't slow down your app)

272
PUSH_NOTIFICATIONS_SETUP.md Normal file
View File

@@ -0,0 +1,272 @@
# Push Notifications Setup Guide
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
This will install `web-push` which is now in package.json.
### 2. Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output and add to your `.env` file:
```env
VAPID_PUBLIC_KEY=your-generated-public-key
VAPID_PRIVATE_KEY=your-generated-private-key
VAPID_SUBJECT=mailto:your-email@example.com
```
### 3. Update Database Schema
```bash
npx prisma db push
```
This adds the `PushSubscription` table to your database.
### 4. Add Notification Settings to Profile Page
Add the `NotificationSettings` component to your profile page:
```tsx
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div>
{/* Your existing profile content */}
<NotificationSettings />
</div>
);
}
```
### 5. Integrate Notifications into Your Actions
Update your existing action files to send notifications:
#### In `lib/actions.ts` (or wherever you handle items):
```typescript
import { notifyItemAdded, notifyItemRemoved, notifyItemCompleted } from '@/lib/notification-helpers';
// After creating an item:
export async function createItem(listId: string, data: ItemData) {
// ... your existing code to create item
const item = await prisma.item.create({ ... });
// Send notification
await notifyItemAdded(listId, item.title, session.user.id);
return item;
}
// After deleting an item:
export async function deleteItem(itemId: string) {
const item = await prisma.item.findUnique({ where: { id: itemId } });
// ... your existing delete code
if (item) {
await notifyItemRemoved(item.listId, item.title, session.user.id);
}
}
// After completing an item:
export async function toggleItemComplete(itemId: string) {
const item = await prisma.item.update({ ... });
if (item.completed) {
await notifyItemCompleted(item.listId, item.title, session.user.id);
}
}
```
#### In `lib/sharing-actions.ts`:
```typescript
import { notifyListInvitation, notifyInvitationAccepted, notifyRemovedFromList } from '@/lib/notification-helpers';
// After creating an invitation:
export async function inviteToList(listId: string, email: string, permission: string) {
// ... your existing code
const invitation = await prisma.invitation.create({ ... });
// Send notification
await notifyListInvitation(invitation.id, email);
return invitation;
}
// After accepting an invitation:
export async function acceptInvitation(invitationId: string) {
// ... your existing code
await notifyInvitationAccepted(invitationId);
}
// After removing a collaborator:
export async function removeCollaborator(listId: string, userId: string) {
const list = await prisma.list.findUnique({ where: { id: listId } });
// ... your existing code
if (list) {
await notifyRemovedFromList(userId, list.title, session.user.id);
}
}
```
## How It Works
### Architecture
1. **Client requests permission** → User clicks "Enable" in NotificationSettings
2. **Service worker subscribes** → Browser creates a push subscription
3. **Subscription saved to DB** → Stored in PushSubscription table
4. **Events trigger notifications** → Server sends push via web-push library
5. **Service worker displays** → User sees notification
### Self-Hosted Solution
This implementation uses **VAPID** (Voluntary Application Server Identification), which means:
- ✅ No external services required (no Firebase, no third-party APIs)
- ✅ Works entirely from your own server
- ✅ Free and open-source
- ✅ Full control over your data
### Browser Support
- ✅ Chrome/Edge (Desktop & Android)
- ✅ Firefox (Desktop & Android)
- ✅ Safari 16.4+ (iOS & macOS) - **Must be installed to home screen on iOS**
- ❌ Safari < 16.4
## Testing
### Local Testing (Desktop)
1. Start your dev server: `npm run dev`
2. Open Chrome DevTools → Application → Service Workers
3. Go to your profile page and enable notifications
4. Trigger an action (add item, invite someone, etc.)
5. You should see a notification appear
### iOS Testing (Real Device Required)
1. Deploy to a server with HTTPS (required for PWA)
2. Open Safari on iOS 16.4+
3. Tap Share → Add to Home Screen
4. Open the installed app
5. Enable notifications in profile
6. Test by triggering actions from another device
### Testing Push Manually
Use Chrome DevTools to simulate a push:
1. DevTools → Application → Service Workers
2. Find your service worker
3. Click "Push" to simulate receiving a notification
## Notification Events
The following events trigger notifications:
| Event | Recipient | Trigger |
|-------|-----------|---------|
| Item Added | All list collaborators (except adder) | Someone adds an item to a shared list |
| Item Removed | All list collaborators (except remover) | Someone removes an item |
| Item Completed | All list collaborators (except completer) | Someone marks an item complete |
| List Invitation | Invitee | Someone invites you to a list |
| Invitation Accepted | Inviter | Someone accepts your invitation |
| Removed from List | Removed user | Owner removes you from a list |
## Customization
### Notification Content
Edit `lib/notification-helpers.ts` to customize notification titles, bodies, and icons.
### Notification Preferences
To add per-list notification preferences:
1. Add a `notificationsEnabled` field to the `Collaborator` model
2. Update `NotificationSettings` to show per-list toggles
3. Check this field in `notifyListCollaborators` before sending
### Notification Sounds
Add to service worker push event:
```javascript
const options = {
// ... existing options
silent: false,
sound: '/notification-sound.mp3',
};
```
## Troubleshooting
### "Push notifications not configured" error
- Check that VAPID keys are in `.env`
- Restart your dev server after adding keys
### Notifications not appearing
- Check browser permission (should be "granted")
- Verify service worker is active (DevTools → Application)
- Check browser console for errors
- Ensure you're testing on HTTPS (or localhost)
### iOS notifications not working
- Must be iOS 16.4 or later
- Must be installed to home screen (not in Safari browser)
- Check Settings → [Your App] → Notifications
### Subscription fails
- Verify VAPID public key matches between client and server
- Check service worker is registered and active
- Ensure you're on HTTPS (or localhost)
## Security Notes
- ⚠️ **Never commit VAPID_PRIVATE_KEY to git**
- ✅ VAPID keys are like JWT secrets - keep them secure
- ✅ Subscriptions are tied to users - no cross-user notification access
- ✅ Expired subscriptions are automatically cleaned up
## Production Checklist
- [ ] Generate production VAPID keys
- [ ] Add VAPID keys to production environment variables
- [ ] Run database migration: `npx prisma db push`
- [ ] Test on actual iOS device (if targeting iOS)
- [ ] Verify HTTPS is working (required for push)
- [ ] Test notification delivery
- [ ] Monitor for expired subscriptions in logs
## Performance
- Notifications are sent asynchronously (non-blocking)
- Failed subscriptions are automatically removed
- Multiple devices per user are supported
- TTL is set to 24 hours (notifications expire if not delivered)
## Next Steps
Consider adding:
- Notification preferences per list
- Quiet hours (don't notify during certain times)
- Notification history/inbox
- Rich notifications with action buttons
- Notification grouping/batching
if

189
PWA_AUTH_GUIDE.md Normal file
View File

@@ -0,0 +1,189 @@
# PWA Authentication Guide
## Overview
The Ultimate Lists App PWA has been updated to handle authentication properly in offline scenarios. This guide explains how the service worker manages authentication-related requests and what users can expect when offline.
## Service Worker Updates
### Cache Version
The service worker cache has been updated to version 2 (`ultimate-lists-v2`) to ensure proper cache invalidation and updates.
### Authentication Pages Caching
The following authentication-related pages are now cached during service worker installation:
- `/auth/login` - Login page
- `/auth/register` - Registration page
- `/auth/error` - Authentication error page
This allows users to view these pages even when offline, though actual authentication requires an internet connection.
### Authentication Request Handling
#### API Authentication Requests (`/api/auth/*`)
Authentication API calls (login, register, session validation) always require a network connection. When offline:
- The service worker returns a 503 Service Unavailable response
- A JSON error message explains that authentication requires internet connectivity
- The error message: "Authentication requires an internet connection. Please check your connection and try again."
#### Authentication Pages (`/auth/*`)
Authentication pages use a cache-first strategy with network fallback:
1. Try to fetch the latest version from the network
2. Cache successful responses for offline access
3. If offline, serve the cached version
4. If no cached version exists, redirect to the offline page
### Authentication Error Handling
The service worker now properly handles authentication errors (401, 403):
- Authentication errors are **not cached**
- This prevents stale authentication states from being served
- Users always get fresh authentication status from the server
### Offline Page Enhancements
The offline page (`/app/offline/page.tsx`) has been enhanced with:
1. **Online Detection**: Automatically detects when the connection is restored
2. **Auto-redirect**: Redirects to the home page when back online
3. **Authentication Notice**: Displays a clear message that login/registration requires internet
4. **Visual Feedback**: Shows different states for offline vs. back online
## User Experience
### When Online
- All authentication features work normally
- Login, registration, and session management function as expected
- Pages are cached for faster subsequent loads
### When Offline
#### Authenticated Users
- Can view cached lists and tasks
- Can make changes that will sync when back online
- Session remains valid (until expiration)
- Cannot perform new authentication actions
#### Unauthenticated Users
- Can view cached authentication pages (login/register forms)
- Cannot actually log in or register (requires internet)
- See clear error messages explaining the need for connectivity
- Are directed to the offline page with authentication guidance
## Testing Offline Behavior
### Manual Testing
1. **Test Offline Login Attempt**:
- Open the app while online
- Navigate to `/auth/login`
- Disconnect from the internet (or use DevTools offline mode)
- Try to submit login credentials
- Verify error message appears
2. **Test Cached Authentication Pages**:
- Visit `/auth/login` while online
- Disconnect from the internet
- Refresh the page
- Verify the login page loads from cache
3. **Test Online Detection**:
- Navigate to `/offline` while offline
- Reconnect to the internet
- Verify automatic redirect to home page
### Chrome DevTools Testing
1. Open Chrome DevTools (F12)
2. Go to Application tab → Service Workers
3. Check "Offline" to simulate offline mode
4. Test authentication flows
5. Verify cache contents in Application → Cache Storage
## Technical Implementation
### Service Worker Strategy
```javascript
// Authentication API calls - Network only
if (url.pathname.startsWith('/api/auth/')) {
// Always try network, return 503 if offline
}
// Authentication pages - Cache-first with network fallback
if (url.pathname.startsWith('/auth/')) {
// Try network, fall back to cache, then offline page
}
```
### Error Response Format
When authentication API calls fail offline:
```json
{
"error": "Authentication requires an internet connection. Please check your connection and try again."
}
```
### Cache Strategy Summary
| Resource Type | Strategy | Offline Behavior |
|--------------|----------|------------------|
| Authentication API | Network only | Return 503 error |
| Authentication Pages | Cache-first | Serve cached version |
| Static Assets | Cache-first | Serve cached version |
| API Responses | Stale-while-revalidate | Serve cached, update in background |
| Regular Pages | Stale-while-revalidate | Serve cached or offline page |
## Security Considerations
1. **No Credential Caching**: User credentials are never cached by the service worker
2. **Session Validation**: Sessions are validated server-side on each request
3. **Error State Handling**: Authentication errors (401, 403) are not cached
4. **Secure Token Storage**: Session tokens are managed by NextAuth.js, not the service worker
## Future Enhancements
Potential improvements for authentication in offline scenarios:
- Queue authentication attempts for when connection is restored
- Show estimated time until session expiration
- Provide offline-specific UI hints for authenticated users
- Implement background sync for authentication state updates
## Troubleshooting
### Service Worker Not Updating
If changes aren't reflected:
1. Unregister the old service worker in DevTools
2. Clear cache storage
3. Hard refresh the page (Ctrl+Shift+R)
### Authentication Not Working Offline
This is expected behavior. Authentication requires:
- Active internet connection
- Server communication for credential validation
- Session token generation and validation
### Cached Pages Showing Old Content
The service worker updates cached pages when online. To force an update:
1. Ensure you're online
2. The service worker checks for updates every minute
3. Or manually update in DevTools → Application → Service Workers → Update

168
SAFARI_PERFORMANCE_FIX.md Normal file
View File

@@ -0,0 +1,168 @@
# Safari/iPhone Performance Optimization
## Issues Fixed
### 1. 300ms Click Delay (Primary Issue)
**Problem**: Safari on iOS has a historical 300ms delay on click events to detect double-tap-to-zoom gestures.
**Solutions Applied**:
- Added `touch-action: manipulation` CSS to all interactive elements (buttons, links, inputs)
- Added `format-detection` meta tag to disable automatic phone number detection
- Updated viewport to allow user scaling (prevents iOS from adding delays)
- Added explicit touch-action rules in globals.css
### 2. Service Worker Overhead
**Problem**: Service worker was intercepting all fetch requests, including cross-origin requests, adding latency.
**Solutions Applied**:
- Added check to skip cross-origin requests in service worker
- Service worker now only handles same-origin requests
- Reduced unnecessary cache lookups
### 3. Touch Interaction Optimization
**Problem**: Missing touch-specific CSS optimizations for iOS Safari.
**Solutions Applied**:
- Added `-webkit-touch-callout: none` to prevent context menu delays
- Added `touch-action: manipulation` to prevent double-tap zoom delays
- Optimized tap highlight colors
### 4. Next.js Configuration
**Problem**: Missing performance optimizations in Next.js config.
**Solutions Applied**:
- Enabled `reactStrictMode` for better development debugging
- Enabled `optimizeFonts` for faster font loading
- Already had compression and minification enabled
## Testing the Fixes
### On iPhone Safari:
1. Clear Safari cache: Settings > Safari > Clear History and Website Data
2. Close all Safari tabs
3. Reopen the app
4. Test clicking on list cards and navigation links
5. Should feel instant now (no 300ms delay)
### Expected Results:
- Links should respond immediately to taps
- No delay between tap and navigation
- Smooth, native-app-like feel
- Faster page transitions
## Additional Recommendations
### If Still Experiencing Slowness:
1. **Check Network Speed**
- Open Safari Developer Tools on Mac (Develop > [Your iPhone])
- Check Network tab for slow requests
- Look for large bundle sizes or slow API calls
2. **Disable Service Worker Temporarily**
- Comment out `<ServiceWorkerRegistration />` in layout.tsx
- Test if performance improves
- If yes, service worker needs further optimization
3. **Check for Memory Issues**
- Close other Safari tabs
- Restart iPhone
- Check if other apps are using memory
4. **Verify Build Optimization**
```bash
npm run build
npm start
```
- Test production build (much faster than dev mode)
5. **Enable Safari Debug Console**
- Settings > Safari > Advanced > Web Inspector
- Connect iPhone to Mac
- Safari > Develop > [Your iPhone] > [Your Site]
- Check for JavaScript errors or warnings
## Performance Metrics to Monitor
### Good Performance Targets:
- **First Contentful Paint (FCP)**: < 1.8s
- **Time to Interactive (TTI)**: < 3.8s
- **Total Blocking Time (TBT)**: < 300ms
- **Cumulative Layout Shift (CLS)**: < 0.1
- **Largest Contentful Paint (LCP)**: < 2.5s
### Measure with Lighthouse:
```bash
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit (with iPhone user agent)
lighthouse https://your-app-url --preset=perf --emulated-form-factor=mobile
```
## Changes Made
### Files Modified:
1. `app/layout.tsx` - Updated viewport and meta tags
2. `app/globals.css` - Added touch-action CSS rules
3. `next.config.js` - Added performance optimizations
4. `public/sw.js` - Optimized fetch event handler
### Key CSS Addition:
```css
/* Remove 300ms click delay on iOS Safari */
a, button, input, select, textarea {
touch-action: manipulation;
}
```
This single CSS rule is the most important fix for the click delay issue.
## Rollback Instructions
If these changes cause issues:
1. **Revert viewport changes**:
```typescript
maximumScale: 1,
userScalable: false,
```
2. **Remove touch-action CSS**:
- Remove the touch-action rules from globals.css
3. **Revert service worker**:
- Remove the cross-origin check in sw.js
## Further Optimization Ideas
If you need even better performance:
1. **Implement Route Prefetching**:
- Next.js Link components prefetch by default
- Ensure prefetch is not disabled
2. **Code Splitting**:
- Use dynamic imports for heavy components
- Lazy load modals and forms
3. **Image Optimization**:
- Use Next.js Image component
- Implement lazy loading for images
4. **Reduce JavaScript Bundle Size**:
- Analyze bundle with `npm run build`
- Remove unused dependencies
- Use tree-shaking
5. **Implement Virtual Scrolling**:
- For long lists of items
- Use react-window or react-virtual
## Support
If issues persist after these fixes:
1. Check browser console for errors
2. Test on different iOS versions
3. Compare with other devices
4. Profile with Safari Web Inspector

257
SECURITY_ENHANCEMENTS.md Normal file
View File

@@ -0,0 +1,257 @@
# Security Enhancements
This document describes the security enhancements implemented for the Ultimate Lists App as part of task 17.
## Overview
The following security measures have been implemented to protect the application and user data:
1. **Rate Limiting** (Requirement 9.3)
2. **CSRF Protection** (Requirement 9.4)
3. **Secure Session Cookie Configuration** (Requirement 9.5)
4. **Input Sanitization** (Requirement 9.5)
## 1. Rate Limiting
### Implementation
A comprehensive rate limiting system has been implemented in `lib/rate-limit.ts` to prevent brute force attacks and abuse.
### Features
- **In-memory rate limiting** with automatic cleanup to prevent memory leaks
- **Configurable limits** for different operations
- **Client identification** using IP address from headers (X-Forwarded-For, X-Real-IP)
- **Graceful degradation** in test environments where headers are not available
### Rate Limit Configurations
| Operation | Max Requests | Time Window | Purpose |
|-----------|-------------|-------------|---------|
| Login | 5 | 15 minutes | Prevent brute force attacks on user accounts |
| Registration | 3 | 1 hour | Prevent spam account creation |
| Password Reset | 3 | 1 hour | Prevent password reset abuse |
| Invitations | 10 | 1 hour | Prevent invitation spam |
### Usage
Rate limiting is automatically applied to:
- User registration (`registerUser`)
- User login (`loginUser`)
- List invitations (`inviteUserToList`)
On successful login, the rate limit is reset for that user to allow immediate subsequent logins.
### Error Messages
When rate limits are exceeded, users receive clear error messages indicating:
- The reason for the block
- How long they need to wait before trying again
Example: "Too many login attempts. Please try again in 12 minutes."
## 2. CSRF Protection
### Implementation
CSRF protection is implemented through multiple layers:
1. **SameSite Cookie Attribute** (`auth.ts`)
- Session cookies are configured with `sameSite: 'lax'`
- Prevents cookies from being sent in cross-site requests
- Protects against CSRF attacks
2. **Security Headers** (`middleware.ts`)
- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing
- `X-Frame-Options: DENY` - Prevents clickjacking attacks
- Additional headers for defense in depth
3. **NextAuth Built-in CSRF Protection**
- NextAuth.js v5 includes built-in CSRF token validation
- Automatically validates CSRF tokens on authentication requests
## 3. Secure Session Cookie Configuration
### Implementation
Session cookies are configured with security best practices in `auth.ts`:
```typescript
cookies: {
sessionToken: {
name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
options: {
httpOnly: true, // Prevent JavaScript access (XSS protection)
sameSite: 'lax', // CSRF protection
path: '/',
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
},
},
}
```
### Security Features
- **httpOnly**: Prevents client-side JavaScript from accessing the cookie, protecting against XSS attacks
- **secure**: Ensures cookies are only sent over HTTPS in production
- **sameSite**: Prevents CSRF attacks by restricting cross-site cookie sending
- **__Secure- prefix**: In production, uses the `__Secure-` prefix which requires the secure flag
### Session Configuration
- **Strategy**: JWT (stateless)
- **Max Age**: 30 days
- **Update Age**: 24 hours (session refreshed daily)
## 4. Input Sanitization
### Implementation
Comprehensive input sanitization has been implemented in `lib/validation.ts` to prevent various injection attacks.
### Sanitization Functions
#### `sanitizeString(input)`
- Trims whitespace
- Removes null bytes (`\0`)
- Removes control characters (except newlines and tabs)
- Used for all text inputs
#### `sanitizeEmail(input)`
- Converts to lowercase
- Trims whitespace
- Removes internal whitespace
- Removes control characters
- Used for email inputs
#### `sanitizeHtml(input)`
- Removes script tags and their content
- Removes event handlers (onclick, onerror, etc.)
- Removes javascript: protocol
- Removes data: protocol
- Strips all HTML tags
- Encodes special HTML characters (&, <, >, ", ', /)
- Prevents XSS attacks
#### `sanitizeSqlInput(input)`
- Removes SQL comment markers (-- and /* */)
- Removes semicolons
- Defense-in-depth measure (primary protection is parameterized queries)
### Application
Input sanitization is applied to:
- All user registration and login inputs
- List titles and descriptions
- Item titles, descriptions, notes, and tags
- Email addresses for invitations
- All user profile updates
## 5. Additional Security Headers
### Implementation
Security headers are added in `middleware.ts` to all responses:
```typescript
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: [comprehensive policy]
Permissions-Policy: camera=(), microphone=(), geolocation=()
```
### Purpose
- **X-Content-Type-Options**: Prevents MIME type sniffing
- **X-XSS-Protection**: Enables browser XSS filter
- **X-Frame-Options**: Prevents clickjacking
- **Referrer-Policy**: Controls referrer information leakage
- **Content-Security-Policy**: Restricts resource loading to prevent XSS
- **Permissions-Policy**: Restricts access to browser features
## Testing
### Test Coverage
Comprehensive tests have been added in `lib/__tests__/security.test.ts`:
- **Rate Limiting Tests** (11 tests)
- Request allowance within limits
- Request blocking when limits exceeded
- Rate limit reset after window expiration
- Separate tracking for different keys
- Rate limit reset functionality
- Configuration validation
- Client identifier extraction
- **Input Sanitization Tests** (19 tests)
- String sanitization
- Email sanitization
- HTML sanitization
- SQL input sanitization
- **Input Validation Tests** (11 tests)
- Email validation
- Password validation
All 41 security tests pass successfully.
## Production Considerations
### Rate Limiting
The current implementation uses in-memory storage for rate limits. For production deployments with multiple servers, consider:
- Using Redis or similar distributed cache
- Implementing rate limiting at the load balancer level
- Monitoring rate limit hits for security analysis
### Session Security
- Ensure `NEXTAUTH_SECRET` is set to a strong, random value
- Use HTTPS in production (enforced by secure cookie flag)
- Monitor session creation and expiration patterns
### Input Sanitization
- All user inputs are sanitized before storage
- Parameterized queries are used for database operations (Prisma ORM)
- Regular security audits recommended
### Security Headers
- CSP policy may need adjustment based on third-party integrations
- Monitor CSP violations in production
- Consider adding Subresource Integrity (SRI) for external scripts
## Compliance
These security enhancements help meet common security standards:
- **OWASP Top 10**: Addresses injection, broken authentication, XSS, and CSRF
- **GDPR**: Protects user data through secure authentication and access control
- **PCI DSS**: Implements security best practices for web applications
## Maintenance
### Regular Updates
- Keep dependencies updated (NextAuth, bcrypt, etc.)
- Review and update rate limit configurations based on usage patterns
- Monitor security advisories for Next.js and related packages
### Monitoring
Consider implementing:
- Logging of rate limit violations
- Alerting on suspicious authentication patterns
- Regular security audits and penetration testing
## References
- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/)
- [NextAuth.js Security](https://next-auth.js.org/configuration/options#security)
- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
- [OWASP Rate Limiting](https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html)

View File

@@ -0,0 +1,83 @@
# Setup Push Notifications - Required Steps
## You MUST do these 3 steps for notifications to work:
### Step 1: Install the web-push package
```bash
npm install
```
This installs the `web-push` package that was added to package.json.
### Step 2: Generate VAPID keys
```bash
npm run vapid:generate
```
This will output something like:
```
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
**Copy these 3 lines and add them to your `.env` file.**
⚠️ **Important**: Keep the VAPID_PRIVATE_KEY secret! Don't commit it to git.
### Step 3: Update your database
```bash
npx prisma db push
```
This adds the `PushSubscription` table to your database.
### Step 4: Restart your server
```bash
npm run dev
```
## That's it!
Now when users open your app:
- After 3 seconds, they'll see a notification prompt at the bottom-right
- They can click "Enable Notifications" to subscribe
- Or they can dismiss it and enable later from their profile page
## What happens automatically:
✅ Notification prompt appears 3 seconds after app loads
✅ Only shows if user hasn't been asked before
✅ Users can dismiss temporarily ("Later") or permanently ("Don't ask again")
✅ Notifications are sent when:
- Items are added/removed/completed in shared lists
- Users are invited to lists
- Invitations are accepted
- Collaborators are removed
## Testing
1. Open your app in Chrome
2. Wait 3 seconds
3. Click "Enable Notifications" when the prompt appears
4. Open another browser/incognito window
5. Log in as a different user
6. Add an item to a shared list
7. See the notification in the first browser!
## Troubleshooting
**"Push notifications not configured" error**
- Make sure you ran `npm run vapid:generate`
- Make sure you added the keys to `.env`
- Restart your dev server
**Prompt doesn't appear**
- Check browser console for errors
- Make sure you're on localhost or HTTPS
- Clear localStorage and refresh if you dismissed it permanently
**Notifications don't send**
- Check server console for errors
- Verify VAPID keys are correct in `.env`
- Make sure database was updated with `npx prisma db push`

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const userId = session.user.id
const listId = params.id
// Check if user has access to this list
const list = await prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
select: {
updatedAt: true,
items: {
select: {
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 1,
},
},
})
if (!list) {
return NextResponse.json(
{ error: 'List not found or access denied' },
{ status: 404 }
)
}
// Get the most recent update time (either list or its items)
const listUpdated = list.updatedAt.getTime()
const itemUpdated = list.items[0]?.updatedAt.getTime() || 0
const lastUpdated = Math.max(listUpdated, itemUpdated)
return NextResponse.json(
{ lastUpdated: new Date(lastUpdated).toISOString() },
{
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
}
)
} catch (error) {
console.error('Error fetching last updated timestamp:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
import { subscribeToPush } from '@/lib/push-notifications';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const subscription = await request.json();
if (!subscription.endpoint || !subscription.keys) {
return NextResponse.json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
await subscribeToPush(session.user.id, subscription);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to subscribe to push:', error);
return NextResponse.json(
{ error: 'Failed to subscribe to push notifications' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
import { unsubscribeFromPush } from '@/lib/push-notifications';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { endpoint } = await request.json();
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint required' },
{ status: 400 }
);
}
await unsubscribeFromPush(session.user.id, endpoint);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to unsubscribe from push:', error);
return NextResponse.json(
{ error: 'Failed to unsubscribe from push notifications' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { getVapidPublicKey } from '@/lib/push-notifications';
export async function GET() {
try {
const publicKey = await getVapidPublicKey();
if (!publicKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 503 }
);
}
return NextResponse.json({ publicKey });
} catch (error) {
console.error('Failed to get VAPID public key:', error);
return NextResponse.json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
}

109
app/auth/error/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-12">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-red-100 mb-4">
<svg
className="h-8 w-8 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
Authentication Error
</h1>
<p className="text-sm sm:text-base text-gray-600 mb-8">
{getErrorMessage(error)}
</p>
</div>
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
<div className="space-y-4">
<Link
href="/auth/login"
className="block w-full px-4 py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-center font-medium text-sm sm:text-base"
>
Try Logging In Again
</Link>
<Link
href="/auth/register"
className="block w-full px-4 py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 text-gray-700 rounded-lg transition-colors text-center font-medium text-sm sm:text-base"
>
Create New Account
</Link>
</div>
{error && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<p className="text-xs text-gray-500">
Error code: <code className="font-mono">{error}</code>
</p>
</div>
)}
</div>
</div>
</div>
)
}
export default function AuthErrorPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-600">Loading...</div>
</div>
}>
<ErrorContent />
</Suspense>
)
}

53
app/auth/login/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-12">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
Welcome Back
</h1>
<p className="text-sm sm:text-base text-gray-600">
Log in to access your lists
</p>
</div>
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
<LoginForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link
href="/auth/register"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
>
Sign up
</Link>
</p>
</div>
</div>
</div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-600">Loading...</div>
</div>
}>
<LoginContent />
</Suspense>
)
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-12">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
Create Account
</h1>
<p className="text-sm sm:text-base text-gray-600">
Sign up to start organizing your tasks
</p>
</div>
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
<RegisterForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link
href="/auth/login"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
>
Log in
</Link>
</p>
</div>
</div>
</div>
</div>
)
}
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-600">Loading...</div>
</div>
}>
<RegisterContent />
</Suspense>
)
}

View File

@@ -63,6 +63,17 @@ mark {
} }
} }
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-in-right { .animate-slide-in-right {
animation: slide-in-right 0.3s ease-out; animation: slide-in-right 0.3s ease-out;
} }
@@ -75,6 +86,10 @@ mark {
animation: scale-in 0.2s ease-out; animation: scale-in 0.2s ease-out;
} }
.animate-slide-up {
animation: slide-up 0.4s ease-out;
}
/* Smooth transitions for interactive elements */ /* Smooth transitions for interactive elements */
.transition-smooth { .transition-smooth {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
@@ -104,4 +119,19 @@ mark {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
} }
/* Remove 300ms click delay on iOS Safari */
a, button, input, select, textarea {
touch-action: manipulation;
}
/* Optimize for touch interactions */
* {
-webkit-touch-callout: none;
}
/* Prevent double-tap zoom on interactive elements */
button, a, input[type="button"], input[type="submit"] {
touch-action: manipulation;
}
} }

101
app/invitations/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { redirect } from 'next/navigation'
import { getInvitations } from '@/lib/sharing-actions'
import InvitationCard from '@/components/InvitationCard'
import { Mail } from 'lucide-react'
import Link from 'next/link'
type Invitation = {
id: string
email: string
permission: 'editor' | 'viewer'
status: string
createdAt: Date
expiresAt: Date
listId: string
inviterId: string
list: {
id: string
title: string
color: string
}
inviter: {
id: string
name: string | null
email: string
}
}
export default async function InvitationsPage() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/login')
}
const invitations = await getInvitations() as Invitation[]
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<Link
href="/"
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
>
← Back to Lists
</Link>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Mail size={24} className="text-blue-600" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
Invitations
</h1>
<p className="text-gray-600 mt-1">
{invitations.length === 0
? 'No pending invitations'
: `${invitations.length} pending ${invitations.length === 1 ? 'invitation' : 'invitations'}`}
</p>
</div>
</div>
</div>
{/* Invitations List */}
{invitations.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 sm:p-12 text-center">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail size={32} className="text-gray-400" />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No Invitations
</h2>
<p className="text-gray-600 mb-6">
When someone shares a list with you, you'll see the invitation here.
</p>
<Link
href="/"
className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 active:bg-blue-800 transition-colors font-medium"
>
Go to My Lists
</Link>
</div>
</div>
) : (
<div className="space-y-4">
{invitations.map((invitation) => (
<InvitationCard
key={invitation.id}
invitation={invitation}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,8 @@
import type { Metadata } from 'next' import type { Metadata, Viewport } from 'next'
import './globals.css' import './globals.css'
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration' import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
import OfflineIndicator from '@/components/OfflineIndicator' import OfflineIndicator from '@/components/OfflineIndicator'
import NotificationPrompt from '@/components/NotificationPrompt'
import { ToastProvider } from '@/components/ToastContainer' import { ToastProvider } from '@/components/ToastContainer'
import ErrorBoundary from '@/components/ErrorBoundary' import ErrorBoundary from '@/components/ErrorBoundary'
@@ -9,14 +10,6 @@ export const metadata: Metadata = {
title: 'Ultimate Lists', title: 'Ultimate Lists',
description: 'The ultimate todo list app with advanced task management', description: 'The ultimate todo list app with advanced task management',
manifest: '/manifest.json', manifest: '/manifest.json',
themeColor: '#3B82F6',
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
},
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: 'black-translucent', statusBarStyle: 'black-translucent',
@@ -39,6 +32,15 @@ export const metadata: Metadata = {
}, },
} }
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
viewportFit: 'cover',
themeColor: '#3B82F6',
}
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@@ -65,7 +67,8 @@ export default function RootLayout({
<meta name="theme-color" content="#3B82F6" /> <meta name="theme-color" content="#3B82F6" />
<meta name="background-color" content="#ffffff" /> <meta name="background-color" content="#ffffff" />
{/* iOS Splash Screens - Optional but recommended */} {/* iOS Performance Optimizations */}
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head> </head>
<body className="bg-gray-50 min-h-screen"> <body className="bg-gray-50 min-h-screen">
@@ -73,6 +76,7 @@ export default function RootLayout({
<ToastProvider> <ToastProvider>
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
<OfflineIndicator /> <OfflineIndicator />
<NotificationPrompt />
{children} {children}
</ToastProvider> </ToastProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -1,9 +1,14 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getItem } from '@/lib/actions' import { getItem } from '@/lib/actions'
import { canEditList } from '@/lib/permissions'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import ItemForm from '@/components/ItemForm' import ItemForm from '@/components/ItemForm'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
export const dynamic = 'force-dynamic'
interface PageProps { interface PageProps {
params: { params: {
id: string id: string
@@ -12,12 +17,20 @@ interface PageProps {
} }
export default async function EditItemPage({ params }: PageProps) { export default async function EditItemPage({ params }: PageProps) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
notFound()
}
const item = await getItem(params.itemId) const item = await getItem(params.itemId)
if (!item || item.listId !== params.id) { if (!item || item.listId !== params.id) {
notFound() notFound()
} }
// Check if user can edit this list
const canEdit = await canEditList(session.user.id, params.id)
return ( return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-3xl"> <main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-3xl">
{/* Header */} {/* Header */}
@@ -34,7 +47,7 @@ export default async function EditItemPage({ params }: PageProps) {
{/* Form */} {/* Form */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 sm:p-6 lg:p-8"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 sm:p-6 lg:p-8">
<ItemForm listId={params.id} item={item} mode="edit" /> <ItemForm listId={params.id} item={item} mode="edit" canEdit={canEdit} />
</div> </div>
</main> </main>
) )

View File

@@ -1,11 +1,16 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getItem } from '@/lib/actions' import { getItem } from '@/lib/actions'
import { canEditList } from '@/lib/permissions'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import ItemDetail from '@/components/ItemDetail' import ItemDetail from '@/components/ItemDetail'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, Edit } from 'lucide-react' import { ArrowLeft, Edit } from 'lucide-react'
import DeleteItemButton from '@/components/DeleteItemButton' import DeleteItemButton from '@/components/DeleteItemButton'
import ToggleItemButton from '@/components/ToggleItemButton' import ToggleItemButton from '@/components/ToggleItemButton'
export const dynamic = 'force-dynamic'
interface PageProps { interface PageProps {
params: { params: {
id: string id: string
@@ -14,12 +19,29 @@ interface PageProps {
} }
export default async function ItemDetailPage({ params }: PageProps) { export default async function ItemDetailPage({ params }: PageProps) {
const item = await getItem(params.itemId) const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!item || item.listId !== params.id) {
notFound() notFound()
} }
const itemData = await getItem(params.itemId)
if (!itemData || itemData.listId !== params.id) {
notFound()
}
// Type assertion for the item with list relation
const item = itemData as typeof itemData & {
list: {
title: string
color: string
ownerId: string
}
}
// Check if user can edit this list
const canEdit = await canEditList(session.user.id, params.id)
return ( return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-4xl"> <main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-4xl">
{/* Header Actions */} {/* Header Actions */}
@@ -33,21 +55,40 @@ export default async function ItemDetailPage({ params }: PageProps) {
</Link> </Link>
<div className="flex items-center gap-2 w-full sm:w-auto"> <div className="flex items-center gap-2 w-full sm:w-auto">
<ToggleItemButton itemId={item.id} completed={item.completed} /> {canEdit && (
<Link <>
href={`/list/${params.id}/item/${params.itemId}/edit`} <ToggleItemButton itemId={item.id} completed={item.completed} />
className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors tap-target text-sm sm:text-base font-medium" <Link
> href={`/list/${params.id}/item/${params.itemId}/edit`}
<Edit size={18} className="flex-shrink-0" /> className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
<span>Edit</span> >
</Link> <Edit size={18} className="flex-shrink-0" />
<DeleteItemButton itemId={item.id} listId={params.id} /> <span>Edit</span>
</Link>
<DeleteItemButton itemId={item.id} listId={params.id} />
</>
)}
</div> </div>
</div> </div>
{/* Item Detail */} {/* Item Detail */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 sm:p-6 lg:p-8"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 sm:p-6 lg:p-8">
<ItemDetail item={item} listTitle={item.list.title} /> <ItemDetail
item={{
id: item.id,
title: item.title,
description: item.description,
completed: item.completed,
priority: item.priority,
dueDate: item.dueDate,
tags: item.tags,
notes: item.notes,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
listId: item.listId,
}}
listTitle={item.list.title}
/>
</div> </div>
</main> </main>
) )

View File

@@ -1,17 +1,36 @@
import { getListWithItems } from '@/lib/actions' import { getListWithItems } from '@/lib/actions'
import { getPermission } from '@/lib/permissions'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import InlineItemCreate from '@/components/InlineItemCreate' import InlineItemCreate from '@/components/InlineItemCreate'
import ListDetailView from '@/components/ListDetailView' import ListDetailView from '@/components/ListDetailView'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function ListDetailPage({ params }: { params: { id: string } }) { export default async function ListDetailPage({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
notFound()
}
const list = await getListWithItems(params.id) const list = await getListWithItems(params.id)
if (!list) { if (!list) {
notFound() notFound()
} }
// Get user's permission for this list
const permission = await getPermission(session.user.id, params.id)
if (!permission) {
notFound()
}
const canShare = permission === 'owner'
const canEdit = permission === 'owner' || permission === 'editor'
const completedItems = list.items.filter(item => item.completed) const completedItems = list.items.filter(item => item.completed)
return ( return (
@@ -39,13 +58,48 @@ export default async function ListDetailPage({ params }: { params: { id: string
</p> </p>
</div> </div>
{/* Inline Item Creation */} {/* Permission Badge and Share Button */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<InlineItemCreate listId={list.id} /> <ListDetailView
listId={list.id}
initialItems={list.items}
permission={permission}
canShare={canShare}
listTitle={list.title}
ownerId={list.ownerId}
ownerName={list.owner?.name}
ownerEmail={list.owner?.email || ''}
collaborators={list.collaborators.map(c => ({
...c,
permission: c.permission as 'editor' | 'viewer'
}))}
showOnlyHeader={true}
/>
</div> </div>
{/* Search, Filter, and Items List */} {/* Inline Item Creation - only show if user can edit */}
<ListDetailView listId={list.id} initialItems={list.items} /> {canEdit && (
<div className="mb-6 sm:mb-8">
<InlineItemCreate listId={list.id} />
</div>
)}
{/* Items List */}
<ListDetailView
listId={list.id}
initialItems={list.items}
permission={permission}
canShare={canShare}
listTitle={list.title}
ownerId={list.ownerId}
ownerName={list.owner?.name}
ownerEmail={list.owner?.email || ''}
collaborators={list.collaborators.map(c => ({
...c,
permission: c.permission as 'editor' | 'viewer'
}))}
showOnlyItems={true}
/>
</main> </main>
) )
} }

View File

@@ -1,8 +1,30 @@
'use client'; 'use client';
import { WifiOff } from 'lucide-react'; import { WifiOff } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export default function OfflinePage() { export default function OfflinePage() {
const [isOnline, setIsOnline] = useState(false);
const router = useRouter();
useEffect(() => {
// Check if we're back online
const handleOnline = () => {
setIsOnline(true);
// Automatically redirect after a short delay
setTimeout(() => {
router.push('/');
}, 1000);
};
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, [router]);
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
@@ -13,36 +35,52 @@ export default function OfflinePage() {
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-4"> <h1 className="text-3xl font-bold text-gray-900 mb-4">
You're Offline {isOnline ? "You're Back Online!" : "You're Offline"}
</h1> </h1>
<p className="text-gray-600 mb-8"> {isOnline ? (
It looks like you've lost your internet connection. Don't worry, your data is safe and will sync when you're back online. <p className="text-gray-600 mb-8">
</p> Your connection has been restored. Redirecting...
</p>
) : (
<>
<p className="text-gray-600 mb-8">
It looks like you've lost your internet connection. Don't worry, your data is safe and will sync when you're back online.
</p>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-left"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-left mb-6">
<h2 className="font-semibold text-gray-900 mb-3">What you can do:</h2> <h2 className="font-semibold text-gray-900 mb-3">What you can do:</h2>
<ul className="space-y-2 text-gray-600"> <ul className="space-y-2 text-gray-600">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-600 mr-2">•</span> <span className="text-blue-600 mr-2">•</span>
<span>View your cached lists and tasks</span> <span>View your cached lists and tasks</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-600 mr-2">•</span> <span className="text-blue-600 mr-2">•</span>
<span>Make changes that will sync later</span> <span>Make changes that will sync later</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-600 mr-2">•</span> <span className="text-blue-600 mr-2">•</span>
<span>Check your connection and try again</span> <span>Check your connection and try again</span>
</li> </li>
</ul> </ul>
</div> </div>
<div className="bg-amber-50 rounded-lg border border-amber-200 p-4 text-left mb-6">
<h3 className="font-semibold text-amber-900 mb-2 text-sm">Note about authentication:</h3>
<p className="text-amber-800 text-sm">
Login and registration require an internet connection. If you need to sign in, please connect to the internet first.
</p>
</div>
</>
)}
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="mt-8 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors" className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isOnline}
> >
Try Again {isOnline ? 'Redirecting...' : 'Try Again'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -3,15 +3,48 @@ import ListCard from '@/components/ListCard'
import NewListButton from '@/components/NewListButton' import NewListButton from '@/components/NewListButton'
import GlobalSearch from '@/components/GlobalSearch' import GlobalSearch from '@/components/GlobalSearch'
import EmptyState from '@/components/EmptyState' import EmptyState from '@/components/EmptyState'
import LogoutButton from '@/components/LogoutButton'
import Link from 'next/link'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { redirect } from 'next/navigation'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function Home() { export default async function Home() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/login')
}
const lists = await getLists() const lists = await getLists()
return ( return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl"> <main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6 sm:mb-8"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900">Ultimate Lists</h1> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900">Ultimate Lists</h1>
<NewListButton /> <div className="flex items-center gap-3">
{session?.user && (
<>
<Link
href="/invitations"
className="px-3 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Invitations
</Link>
<Link
href="/profile"
className="px-3 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Profile
</Link>
<LogoutButton />
</>
)}
<NewListButton />
</div>
</div> </div>
{/* Global Search */} {/* Global Search */}

126
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,126 @@
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { getUserProfile } from '@/lib/auth-actions'
import ProfileForm from '@/components/ProfileForm'
import PasswordChangeForm from '@/components/PasswordChangeForm'
import NotificationSettings from '@/components/NotificationSettings'
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 getServerSession(authOptions)
if (!session?.user?.id) {
redirect('/auth/login')
}
const userProfile = await getUserProfile(session.user.id)
if (!userProfile) {
redirect('/auth/login')
}
return (
<div className="min-h-screen bg-gray-50 px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href="/"
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Lists
</Link>
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900">
Profile Settings
</h1>
<p className="text-sm sm:text-base text-gray-600 mt-2">
Manage your account information and security settings
</p>
</div>
{/* Profile Information Section */}
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8 mb-6">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
Profile Information
</h2>
<p className="text-sm text-gray-600 mb-6">
Update your name and email address
</p>
<ProfileForm
initialName={userProfile.name}
initialEmail={userProfile.email}
userId={userProfile.id}
/>
</div>
{/* Password Section */}
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8 mb-6">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
Change Password
</h2>
<p className="text-sm text-gray-600 mb-6">
Update your password to keep your account secure
</p>
<PasswordChangeForm userId={userProfile.id} />
</div>
{/* Notification Settings */}
<div className="mb-6">
<NotificationSettings />
</div>
{/* Account Information */}
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
Account Information
</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Account ID</span>
<span className="text-gray-900 font-mono text-xs">{userProfile.id}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Member Since</span>
<span className="text-gray-900">
{new Date(userProfile.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Last Updated</span>
<span className="text-gray-900">
{new Date(userProfile.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import { useState, useEffect } from 'react'
import { claimLists, getClaimableLists, type ClaimableList } from '@/lib/migration-actions'
import Modal from './Modal'
import LoadingSpinner from './LoadingSpinner'
interface ClaimListsModalProps {
isOpen: boolean
onClose: () => void
onComplete: () => void
}
/**
* Modal for claiming existing anonymous lists after registration
* Requirements: 10.2, 10.5 - Migration UI for users to claim existing lists
*/
export default function ClaimListsModal({ isOpen, onClose, onComplete }: ClaimListsModalProps) {
const [claimableLists, setClaimableLists] = useState<ClaimableList[]>([])
const [selectedLists, setSelectedLists] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
loadClaimableLists()
}
}, [isOpen])
const loadClaimableLists = async () => {
try {
setLoading(true)
setError(null)
// Get list IDs from localStorage
const storedListIds = getStoredListIds()
if (storedListIds.length === 0) {
setClaimableLists([])
setLoading(false)
return
}
// Fetch lists from database
const lists = await getClaimableLists(storedListIds)
setClaimableLists(lists)
// Select all lists by default
setSelectedLists(new Set(lists.map(l => l.id)))
} catch (err) {
console.error('Error loading claimable lists:', err)
setError('Failed to load your lists. Please try again.')
} finally {
setLoading(false)
}
}
const getStoredListIds = (): string[] => {
try {
// Check for list IDs in localStorage
// This assumes the app previously stored list IDs locally
const stored = localStorage.getItem('anonymousListIds')
if (stored) {
return JSON.parse(stored)
}
return []
} catch (err) {
console.error('Error reading localStorage:', err)
return []
}
}
const toggleListSelection = (listId: string) => {
const newSelection = new Set(selectedLists)
if (newSelection.has(listId)) {
newSelection.delete(listId)
} else {
newSelection.add(listId)
}
setSelectedLists(newSelection)
}
const handleClaimLists = async () => {
try {
setClaiming(true)
setError(null)
const listIds = Array.from(selectedLists)
const result = await claimLists(listIds)
if (result.success) {
// Clear localStorage after successful claim
localStorage.removeItem('anonymousListIds')
onComplete()
} else {
setError(result.error)
}
} catch (err) {
console.error('Error claiming lists:', err)
setError('Failed to claim lists. Please try again.')
} finally {
setClaiming(false)
}
}
const handleSkip = () => {
// User chose not to claim lists
localStorage.removeItem('anonymousListIds')
onClose()
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="Claim Your Lists">
<div className="space-y-4">
{loading ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : claimableLists.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600">No lists found to claim.</p>
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Continue
</button>
</div>
) : (
<>
<p className="text-gray-700">
We found {claimableLists.length} list{claimableLists.length !== 1 ? 's' : ''} from your previous session.
Would you like to add {claimableLists.length !== 1 ? 'them' : 'it'} to your account?
</p>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="max-h-64 overflow-y-auto space-y-2">
{claimableLists.map((list) => (
<label
key={list.id}
className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 transition-colors"
>
<input
type="checkbox"
checked={selectedLists.has(list.id)}
onChange={() => toggleListSelection(list.id)}
className="mt-1 h-4 w-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: list.color }}
/>
<span className="font-medium text-gray-900 truncate">
{list.title}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{list.itemCount} item{list.itemCount !== 1 ? 's' : ''} ·
Created {new Date(list.createdAt).toLocaleDateString()}
</p>
</div>
</label>
))}
</div>
<div className="flex gap-3 pt-4 border-t">
<button
onClick={handleSkip}
disabled={claiming}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Skip
</button>
<button
onClick={handleClaimLists}
disabled={claiming || selectedLists.size === 0}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{claiming ? (
<>
<LoadingSpinner />
<span>Claiming...</span>
</>
) : (
`Claim ${selectedLists.size} List${selectedLists.size !== 1 ? 's' : ''}`
)}
</button>
</div>
</>
)}
</div>
</Modal>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useState } from 'react'
import { UserMinus, ChevronDown } from 'lucide-react'
import PermissionBadge from './PermissionBadge'
import { updateCollaboratorPermission, removeCollaborator } from '@/lib/sharing-actions'
import { useRouter } from 'next/navigation'
interface Collaborator {
id: string
permission: 'editor' | 'viewer'
user: {
id: string
name: string | null
email: string
}
}
interface CollaboratorListProps {
listId: string
collaborators: Collaborator[]
isOwner: boolean
ownerId: string
ownerName: string | null
ownerEmail: string
}
export default function CollaboratorList({
listId,
collaborators,
isOwner,
ownerId,
ownerName,
ownerEmail,
}: CollaboratorListProps) {
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const handlePermissionChange = async (collaboratorId: string, newPermission: 'editor' | 'viewer') => {
setLoadingStates(prev => ({ ...prev, [collaboratorId]: true }))
setError(null)
try {
await updateCollaboratorPermission(listId, collaboratorId, newPermission)
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update permission')
} finally {
setLoadingStates(prev => ({ ...prev, [collaboratorId]: false }))
}
}
const handleRemove = async (collaboratorId: string) => {
if (!confirm('Are you sure you want to remove this collaborator?')) {
return
}
setLoadingStates(prev => ({ ...prev, [collaboratorId]: true }))
setError(null)
try {
await removeCollaborator(listId, collaboratorId)
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove collaborator')
setLoadingStates(prev => ({ ...prev, [collaboratorId]: false }))
}
}
return (
<div className="space-y-3">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Owner */}
<div className="flex items-center justify-between p-3 bg-purple-50 border border-purple-100 rounded-lg">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{ownerName || ownerEmail}
</p>
{ownerName && (
<p className="text-sm text-gray-600 truncate">{ownerEmail}</p>
)}
</div>
<PermissionBadge permission="owner" size="sm" />
</div>
{/* Collaborators */}
{collaborators.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
No collaborators yet. Share this list to collaborate!
</p>
) : (
collaborators.map((collaborator) => (
<div
key={collaborator.id}
className="flex items-center justify-between gap-3 p-3 bg-white border border-gray-200 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{collaborator.user.name || collaborator.user.email}
</p>
{collaborator.user.name && (
<p className="text-sm text-gray-600 truncate">
{collaborator.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isOwner ? (
<>
<div className="relative">
<select
value={collaborator.permission}
onChange={(e) =>
handlePermissionChange(
collaborator.id,
e.target.value as 'editor' | 'viewer'
)
}
disabled={loadingStates[collaborator.id]}
className="appearance-none bg-white border border-gray-300 rounded-lg pl-3 pr-8 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed cursor-pointer"
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<ChevronDown
size={14}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
/>
</div>
<button
onClick={() => handleRemove(collaborator.id)}
disabled={loadingStates[collaborator.id]}
className="p-2 text-red-600 hover:bg-red-50 active:bg-red-100 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Remove collaborator"
>
<UserMinus size={18} />
</button>
</>
) : (
<PermissionBadge permission={collaborator.permission} size="sm" />
)}
</div>
</div>
))
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { deleteItem } from '@/lib/actions' import { deleteItem } from '@/lib/actions'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
@@ -17,19 +17,25 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
const { showToast } = useToast() const { showToast } = useToast()
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const [isPending, startTransition] = useTransition()
const handleDelete = async (e: React.MouseEvent) => { const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setIsDeleting(true) setIsDeleting(true)
try { try {
await deleteItem(itemId) await deleteItem(itemId)
showToast('Task deleted successfully', 'success') showToast('Task deleted successfully', 'success')
if (listId) {
router.push(`/list/${listId}`) // Reset state immediately
} else { setShowConfirm(false)
setIsDeleting(false)
// Use startTransition to refresh the route
startTransition(() => {
router.refresh() router.refresh()
} })
} catch (error) { } catch (error) {
console.error('Error deleting item:', error) console.error('Error deleting item:', error)
showToast('Failed to delete task', 'error') showToast('Failed to delete task', 'error')
@@ -55,14 +61,14 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={isDeleting || isPending}
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-red-300 text-white rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium" className="px-3 sm:px-4 py-2 sm:py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-red-300 text-white rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
> >
{isDeleting ? 'Deleting...' : 'Confirm'} {isDeleting || isPending ? 'Deleting...' : 'Confirm'}
</button> </button>
<button <button
onClick={handleCancel} onClick={handleCancel}
disabled={isDeleting} disabled={isDeleting || isPending}
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium" className="px-3 sm:px-4 py-2 sm:py-2.5 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
> >
Cancel Cancel

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { createItem } from '@/lib/actions' import { createItem } from '@/lib/actions'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
@@ -18,6 +18,20 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [keepFormOpen, setKeepFormOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Focus input when form becomes visible
useEffect(() => {
if ((isCreating || keepFormOpen) && inputRef.current) {
// Small delay to ensure the input is rendered
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, 50)
}
}, [isCreating, keepFormOpen])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -37,16 +51,31 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
setError('') setError('')
try { try {
await createItem(listId, sanitizeString(title)) // Clear form immediately for better UX
showToast('Task added successfully', 'success') const itemTitle = sanitizeString(title)
setTitle('') setTitle('')
setIsCreating(false) setKeepFormOpen(true) // Mark that we want to keep the form open
router.refresh()
// Create item and refresh
await createItem(listId, itemTitle)
showToast('Task added successfully', 'success')
// The revalidatePath in createItem will refresh the data
// but we'll keep the form open due to keepFormOpen state
// Refocus the input after a short delay to keep keyboard open
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, 100)
} catch (error) { } catch (error) {
console.error('Error creating item:', error) console.error('Error creating item:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to add task' const errorMessage = error instanceof Error ? error.message : 'Failed to add task'
setError(errorMessage) setError(errorMessage)
showToast(errorMessage, 'error') showToast(errorMessage, 'error')
// Restore form on error
setIsCreating(true)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -55,6 +84,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const handleCancel = () => { const handleCancel = () => {
setTitle('') setTitle('')
setIsCreating(false) setIsCreating(false)
setKeepFormOpen(false) // Reset the keep form open state
// Reset zoom on iOS Safari
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -63,7 +97,16 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
} }
} }
if (!isCreating) { const handleBlur = (e: React.FocusEvent) => {
// Only handle blur if we're not keeping the form open
// This prevents the keyboard from closing when we want it to stay open
if (!keepFormOpen) {
// Force viewport reset on iOS Safari
window.scrollTo(0, 0)
}
}
if (!isCreating && !keepFormOpen) {
return ( return (
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
@@ -79,6 +122,7 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
<form onSubmit={handleSubmit} className="bg-white border-2 border-blue-400 rounded-lg p-4 sm:p-5 shadow-sm"> <form onSubmit={handleSubmit} className="bg-white border-2 border-blue-400 rounded-lg p-4 sm:p-5 shadow-sm">
<div className="mb-3 sm:mb-4"> <div className="mb-3 sm:mb-4">
<input <input
ref={inputRef}
type="text" type="text"
value={title} value={title}
onChange={(e) => { onChange={(e) => {
@@ -86,10 +130,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
setError('') setError('')
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder="Enter task title..." placeholder="Enter task title..."
autoFocus autoFocus
maxLength={200} 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 ${ className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500' error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`} }`}
aria-invalid={error ? 'true' : 'false'} aria-invalid={error ? 'true' : 'false'}

View File

@@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { Check, X, Clock } from 'lucide-react'
import PermissionBadge from './PermissionBadge'
import { acceptInvitation, declineInvitation } from '@/lib/sharing-actions'
import { useRouter } from 'next/navigation'
interface InvitationCardProps {
invitation: {
id: string
permission: 'editor' | 'viewer'
createdAt: Date
list: {
id: string
title: string
color: string
}
inviter: {
name: string | null
email: string
}
}
onUpdate?: () => void
}
export default function InvitationCard({ invitation, onUpdate }: InvitationCardProps) {
const [isAccepting, setIsAccepting] = useState(false)
const [isDeclining, setIsDeclining] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const handleAccept = async () => {
setIsAccepting(true)
setError(null)
try {
await acceptInvitation(invitation.id)
onUpdate?.()
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to accept invitation')
setIsAccepting(false)
}
}
const handleDecline = async () => {
setIsDeclining(true)
setError(null)
try {
await declineInvitation(invitation.id)
onUpdate?.()
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to decline invitation')
setIsDeclining(false)
}
}
const formatDate = (date: Date) => {
const now = new Date()
const diffMs = now.getTime() - new Date(date).getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return new Date(date).toLocaleDateString()
}
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start gap-3 mb-3">
<div
className="w-3 h-3 rounded-full flex-shrink-0 mt-1"
style={{ backgroundColor: invitation.list.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 text-base sm:text-lg truncate">
{invitation.list.title}
</h3>
<p className="text-sm text-gray-600 mt-1">
Invited by {invitation.inviter.name || invitation.inviter.email}
</p>
</div>
<PermissionBadge permission={invitation.permission} size="sm" />
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mb-4">
<Clock size={12} />
<span>{formatDate(invitation.createdAt)}</span>
</div>
{error && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleAccept}
disabled={isAccepting || isDeclining}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 active:bg-blue-800 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
<Check size={16} />
{isAccepting ? 'Accepting...' : 'Accept'}
</button>
<button
onClick={handleDecline}
disabled={isAccepting || isDeclining}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-white text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
<X size={16} />
{isDeclining ? 'Declining...' : 'Decline'}
</button>
</div>
</div>
)
}

View File

@@ -33,24 +33,33 @@ function ItemCard({ item, listId }: ItemCardProps) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
return ( return (
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-5 hover:shadow-md active:shadow-lg transition-all group"> <div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-3.5 hover:shadow-md active:shadow-lg transition-all group">
<div className="flex items-start gap-3 sm:gap-4"> <div className="flex items-center gap-2 sm:gap-3">
{/* Completion Toggle */} {/* Completion Toggle */}
<div className="pt-0.5 sm:pt-1 flex-shrink-0"> <div className="flex-shrink-0">
<ToggleItemButton itemId={item.id} completed={item.completed} variant="icon" /> <ToggleItemButton itemId={item.id} completed={item.completed} variant="icon" />
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Link href={`/list/${listId}/item/${item.id}`} className="block"> <Link href={`/list/${listId}/item/${item.id}`} className="block">
<h3 className={`font-medium mb-1 text-sm sm:text-base ${ <div className="flex items-center justify-between gap-2">
item.completed ? 'text-gray-500 line-through' : 'text-gray-900' <h3 className={`font-medium text-sm sm:text-base ${
}`}> item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
{item.title} }`}>
</h3> {item.title}
</h3>
{/* Priority Badge - moved to right */}
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border flex-shrink-0 ${priorityColor}`}>
<AlertCircle size={12} className="flex-shrink-0" />
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
</span>
</div>
{item.description && ( {item.description && (
<p className={`text-xs sm:text-sm mb-2 line-clamp-2 ${ <p className={`text-xs sm:text-sm mt-1 line-clamp-1 ${
item.completed ? 'text-gray-400' : 'text-gray-600' item.completed ? 'text-gray-400' : 'text-gray-600'
}`}> }`}>
{item.description} {item.description}
@@ -58,42 +67,37 @@ function ItemCard({ item, listId }: ItemCardProps) {
)} )}
{/* Metadata */} {/* Metadata */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-2"> {(item.dueDate || tags.length > 0) && (
{/* Priority Badge */} <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${priorityColor}`}> {/* Due Date */}
<AlertCircle size={12} className="flex-shrink-0" /> {item.dueDate && (
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span> <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs whitespace-nowrap ${
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span> isOverdue
</span> ? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
: 'bg-gray-100 text-gray-700'
{/* Due Date */} }`}>
{item.dueDate && ( <Calendar size={12} className="flex-shrink-0" />
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs whitespace-nowrap ${ {new Date(item.dueDate).toLocaleDateString('en-US', {
isOverdue month: 'short',
? 'bg-red-100 text-red-700 border border-red-200 font-semibold' day: 'numeric',
: 'bg-gray-100 text-gray-700' })}
}`}> {isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
<Calendar size={12} className="flex-shrink-0" /> {isOverdue && <span className="sm:hidden">!</span>}
{new Date(item.dueDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
{isOverdue && <span className="sm:hidden">!</span>}
</span>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="inline-flex items-center gap-1">
<Tag size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && ` +${tags.length - 2}`}
</span> </span>
</div> )}
)}
</div> {/* Tags */}
{tags.length > 0 && (
<div className="inline-flex items-center gap-1">
<Tag size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && ` +${tags.length - 2}`}
</span>
</div>
)}
</div>
)}
</Link> </Link>
</div> </div>

View File

@@ -26,9 +26,10 @@ interface ItemFormProps {
notes: string | null notes: string | null
} }
mode?: 'create' | 'edit' mode?: 'create' | 'edit'
canEdit?: boolean
} }
export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) { export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }: ItemFormProps) {
const router = useRouter() const router = useRouter()
const { showToast } = useToast() const { showToast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -157,6 +158,11 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!canEdit) {
showToast('You do not have permission to edit this item', 'error')
return
}
if (!validateForm()) { if (!validateForm()) {
showToast('Please fix validation errors', 'error') showToast('Please fix validation errors', 'error')
return return
@@ -238,6 +244,14 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
return ( return (
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6"> <form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6">
{!canEdit && (
<div className="p-3 sm:p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs sm:text-sm text-yellow-800">
You have view-only access to this list. You cannot edit items.
</p>
</div>
)}
{/* Title */} {/* Title */}
<div> <div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
@@ -249,11 +263,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onBlur={() => handleBlur('title')} onBlur={() => handleBlur('title')}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
errors.title && touched.title errors.title && touched.title
? 'border-red-500 focus:ring-red-500' ? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500' : 'border-gray-300 focus:ring-blue-500'
}`} } ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Enter task title" placeholder="Enter task title"
maxLength={200} maxLength={200}
aria-invalid={errors.title && touched.title ? 'true' : 'false'} aria-invalid={errors.title && touched.title ? 'true' : 'false'}
@@ -276,12 +291,13 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
onBlur={() => handleBlur('description')} onBlur={() => handleBlur('description')}
disabled={!canEdit}
rows={3} rows={3}
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 ${ className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
errors.description && touched.description errors.description && touched.description
? 'border-red-500 focus:ring-red-500' ? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500' : 'border-gray-300 focus:ring-blue-500'
}`} } ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add a description" placeholder="Add a description"
maxLength={2000} maxLength={2000}
aria-invalid={errors.description && touched.description ? 'true' : 'false'} aria-invalid={errors.description && touched.description ? 'true' : 'false'}
@@ -303,7 +319,8 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
id="priority" id="priority"
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')} onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
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" disabled={!canEdit}
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-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
> >
<option value="low">Low</option> <option value="low">Low</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
@@ -322,11 +339,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={dueDate} value={dueDate}
onChange={(e) => setDueDate(e.target.value)} onChange={(e) => setDueDate(e.target.value)}
onBlur={() => handleBlur('dueDate')} 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 ${ disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base tap-target ${
errors.dueDate && touched.dueDate errors.dueDate && touched.dueDate
? 'border-red-500 focus:ring-red-500' ? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500' : 'border-gray-300 focus:ring-blue-500'
}`} } ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
min="1900-01-01" min="1900-01-01"
max="2100-12-31" max="2100-12-31"
aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'} aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'}
@@ -351,11 +369,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${ disabled={!canEdit}
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
errors.tagInput errors.tagInput
? 'border-red-500 focus:ring-red-500' ? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500' : 'border-gray-300 focus:ring-blue-500'
}`} } ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add a tag" placeholder="Add a tag"
maxLength={50} maxLength={50}
aria-invalid={errors.tagInput ? 'true' : 'false'} aria-invalid={errors.tagInput ? 'true' : 'false'}
@@ -364,7 +383,7 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
<button <button
type="button" type="button"
onClick={handleAddTag} onClick={handleAddTag}
disabled={tags.length >= 20} disabled={tags.length >= 20 || !canEdit}
className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 disabled:bg-gray-100 disabled:text-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium" className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 disabled:bg-gray-100 disabled:text-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
> >
Add Add
@@ -389,7 +408,8 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
<button <button
type="button" type="button"
onClick={() => handleRemoveTag(tag)} onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none" disabled={!canEdit}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Remove ${tag} tag`} aria-label={`Remove ${tag} tag`}
> >
× ×
@@ -410,12 +430,13 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
onBlur={() => handleBlur('notes')} onBlur={() => handleBlur('notes')}
disabled={!canEdit}
rows={5} rows={5}
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 ${ className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
errors.notes && touched.notes errors.notes && touched.notes
? 'border-red-500 focus:ring-red-500' ? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500' : 'border-gray-300 focus:ring-blue-500'
}`} } ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add additional notes" placeholder="Add additional notes"
maxLength={5000} maxLength={5000}
aria-invalid={errors.notes && touched.notes ? 'true' : 'false'} aria-invalid={errors.notes && touched.notes ? 'true' : 'false'}
@@ -442,15 +463,17 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
onClick={() => router.back()} onClick={() => router.back()}
className="w-full sm:w-auto px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base" className="w-full sm:w-auto px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
> >
Cancel {canEdit ? 'Cancel' : 'Back'}
</button>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button> </button>
{canEdit && (
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button>
)}
</div> </div>
</form> </form>
) )

View File

@@ -2,7 +2,7 @@
import { memo, useState } from 'react' import { memo, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Edit2 } from 'lucide-react' import { Edit2, Users } from 'lucide-react'
import Modal from './Modal' import Modal from './Modal'
import ListForm from './ListForm' import ListForm from './ListForm'
import DeleteListButton from './DeleteListButton' import DeleteListButton from './DeleteListButton'
@@ -15,6 +15,11 @@ interface List {
id: string id: string
completed: boolean completed: boolean
}> }>
collaborators?: Array<{
id: string
userId: string
permission: string
}>
} }
function ListCard({ list }: { list: List }) { function ListCard({ list }: { list: List }) {
@@ -22,6 +27,7 @@ function ListCard({ list }: { list: List }) {
const completedCount = list.items.filter(item => item.completed).length const completedCount = list.items.filter(item => item.completed).length
const totalCount = list.items.length const totalCount = list.items.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
const hasCollaborators = list.collaborators && list.collaborators.length > 0
const handleEditClick = (e: React.MouseEvent) => { const handleEditClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
@@ -45,6 +51,12 @@ function ListCard({ list }: { list: List }) {
style={{ backgroundColor: list.color }} style={{ backgroundColor: list.color }}
/> />
<h3 className="font-semibold text-gray-900 truncate text-base sm:text-lg">{list.title}</h3> <h3 className="font-semibold text-gray-900 truncate text-base sm:text-lg">{list.title}</h3>
{hasCollaborators && list.collaborators && (
<div className="flex items-center gap-1 text-gray-500 flex-shrink-0" title={`Shared with ${list.collaborators.length} ${list.collaborators.length === 1 ? 'person' : 'people'}`}>
<Users size={16} />
<span className="text-xs">{list.collaborators.length}</span>
</div>
)}
</div> </div>
<div className="flex items-center gap-0.5 sm:gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={handleDeleteClick}> <div className="flex items-center gap-0.5 sm:gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={handleDeleteClick}>
<button <button

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import { useState, useCallback, useMemo, useEffect } from 'react' import { useState, useMemo } from 'react'
import { Share2 } from 'lucide-react'
import ItemCard from './ItemCard' import ItemCard from './ItemCard'
import SearchBar from './SearchBar'
import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import { searchItems, getItemsByFilter } from '@/lib/actions' import ShareListModal from './ShareListModal'
import { useSearch, useFilters, useScrollPosition } from '@/hooks' import PermissionBadge from './PermissionBadge'
import { useScrollPosition } from '@/hooks'
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
interface Item { interface Item {
id: string id: string
@@ -23,122 +23,103 @@ interface Item {
listId: string listId: string
} }
interface ItemWithList extends Item {
list: {
id: string
title: string
color: string
}
}
interface ListDetailViewProps { interface ListDetailViewProps {
listId: string listId: string
initialItems: Item[] initialItems: Item[]
permission?: 'owner' | 'editor' | 'viewer'
canShare?: boolean
listTitle?: string
ownerId?: string
ownerName?: string | null
ownerEmail?: string
collaborators?: Array<{
id: string
permission: 'editor' | 'viewer'
user: {
id: string
name: string | null
email: string
}
}>
showOnlyHeader?: boolean
showOnlyItems?: boolean
} }
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) { export default function ListDetailView({
const [searchResults, setSearchResults] = useState<ItemWithList[]>([]) listId,
const [isSearching, setIsSearching] = useState(false) initialItems,
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems) permission,
canShare = false,
listTitle = '',
ownerId = '',
ownerName = null,
ownerEmail = '',
collaborators = [],
showOnlyHeader = false,
showOnlyItems = false
}: ListDetailViewProps) {
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
// Use scroll position preservation hook // Conditionally use hooks based on view mode
useScrollPosition({ key: `list-${listId}` }) const shouldUseHooks = !showOnlyHeader
// Use scroll position preservation hook (only for items view)
useScrollPosition({ key: shouldUseHooks ? `list-${listId}` : 'disabled' })
// Extract all unique tags from items // Use real-time sync hook to poll for updates (only for items view)
const availableTags = useMemo(() => { useRealtimeSync({
const tagSet = new Set<string>() listId,
initialItems.forEach(item => { enabled: shouldUseHooks,
const tags = Array.isArray(item.tags) ? item.tags : [] pollInterval: 1000, // Poll every 1 second for faster updates
tags.forEach(tag => {
if (typeof tag === 'string') {
tagSet.add(tag)
}
})
})
return Array.from(tagSet).sort()
}, [initialItems])
// Use search hook
const handleSearchCallback = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setIsSearching(false)
return
}
setIsSearching(true)
try {
const results = await searchItems(query, listId)
setSearchResults(results as ItemWithList[])
} catch (error) {
console.error('Search error:', error)
setSearchResults([])
} finally {
setIsSearching(false)
}
}, [listId])
const { debouncedQuery } = useSearch({
onSearch: handleSearchCallback
}) })
// Use filters hook
const handleFiltersCallback = useCallback(async (newFilters: any) => {
try {
const results = await getItemsByFilter(listId, {
priority: newFilters.priority,
completed: newFilters.completed,
tags: newFilters.tags,
dueDateRange: newFilters.dueDateRange,
sortBy: newFilters.sortBy,
sortOrder: newFilters.sortOrder
})
// Extract items from pagination result
const items = 'items' in results ? results.items : results
setFilteredItems(items as Item[])
} catch (error) {
console.error('Filter error:', error)
setFilteredItems(initialItems)
}
}, [listId, initialItems])
const { filters, setFilters } = useFilters({
onFiltersChange: handleFiltersCallback
})
// Apply initial filters on mount
useEffect(() => {
handleFiltersCallback(filters)
}, []) // Only run on mount
// Determine which items to display // Determine which items to display
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems const completedItems = useMemo(() => initialItems.filter(item => item.completed), [initialItems])
const completedItems = displayItems.filter(item => item.completed) const incompleteItems = useMemo(() => initialItems.filter(item => !item.completed), [initialItems])
const incompleteItems = displayItems.filter(item => !item.completed)
return ( // If showing only header, return just the permission badge and share button
<div className="space-y-6"> if (showOnlyHeader) {
{/* Search Bar */} return (
<SearchBar <>
onSearch={handleSearchCallback} {(permission || canShare) && (
placeholder="Search tasks in this list..." <div className="flex items-center justify-between gap-4">
/> {permission && (
<div>
{/* Filter Panel */} <PermissionBadge permission={permission} />
<FilterPanel </div>
filters={filters} )}
onFiltersChange={setFilters} {canShare && (
availableTags={availableTags} <button
/> onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
{/* Results */} >
{debouncedQuery.trim() ? ( <Share2 size={16} />
<SearchResults Share
results={searchResults} </button>
query={debouncedQuery} )}
</div>
)}
{/* Share Modal */}
<ShareListModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
listId={listId}
listTitle={listTitle}
isOwner={permission === 'owner'}
ownerId={ownerId}
ownerName={ownerName}
ownerEmail={ownerEmail}
initialCollaborators={collaborators}
/> />
) : ( </>
<div className="space-y-6"> )
}
// If showing only items, return just the items list
if (showOnlyItems) {
return (
<div className="space-y-6">
{/* Incomplete Items */} {/* Incomplete Items */}
{incompleteItems.length > 0 && ( {incompleteItems.length > 0 && (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -168,19 +149,92 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
)} )}
{/* Empty State */} {/* Empty State */}
{displayItems.length === 0 && ( {initialItems.length === 0 && (
<EmptyState <EmptyState
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'} icon="inbox"
title="No tasks found" title="No tasks found"
description={ description="Add your first task using the button above"
filters.priority || filters.completed !== null || filters.tags.length > 0
? 'Try adjusting your filters to see more tasks'
: 'Add your first task using the button above'
}
/> />
)} )}
</div>
)
}
// Default: show everything (for backward compatibility)
return (
<div className="space-y-6">
{/* Permission Badge and Share Button */}
{(permission || canShare) && (
<div className="flex items-center justify-between gap-4">
{permission && (
<div>
<PermissionBadge permission={permission} />
</div>
)}
{canShare && (
<button
onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<Share2 size={16} />
Share
</button>
)}
</div> </div>
)} )}
{/* Items */}
<div className="space-y-6">
{/* Incomplete Items */}
{incompleteItems.length > 0 && (
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-900 mb-3">
To Do ({incompleteItems.length})
</h2>
<div className="space-y-2">
{incompleteItems.map((item) => (
<ItemCard key={item.id} item={item} listId={listId} />
))}
</div>
</div>
)}
{/* Completed Items */}
{completedItems.length > 0 && (
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-500 mb-3">
Completed ({completedItems.length})
</h2>
<div className="space-y-2">
{completedItems.map((item) => (
<ItemCard key={item.id} item={item} listId={listId} />
))}
</div>
</div>
)}
{/* Empty State */}
{initialItems.length === 0 && (
<EmptyState
icon="inbox"
title="No tasks found"
description="Add your first task using the button above"
/>
)}
</div>
{/* Share Modal */}
<ShareListModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
listId={listId}
listTitle={listTitle}
isOwner={permission === 'owner'}
ownerId={ownerId}
ownerName={ownerName}
ownerEmail={ownerEmail}
initialCollaborators={collaborators}
/>
</div> </div>
) )
} }

179
components/LoginForm.tsx Normal file
View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => 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 && (
<p id="email-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password *
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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 && (
<p id="password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.password}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 sm:px-5 py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50 tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { signOut } from 'next-auth/react'
import { LogOut } from 'lucide-react'
export default function LogoutButton() {
const handleLogout = async () => {
await signOut({ callbackUrl: '/auth/login' })
}
return (
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
aria-label="Log out"
>
<LogOut size={16} />
<span>Log out</span>
</button>
)
}

View File

@@ -42,15 +42,20 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in"> <div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in"
onClick={onClose}
>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm" className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
onClick={onClose}
/> />
{/* Modal */} {/* Modal */}
<div className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in"> <div
className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 sticky top-0 bg-white z-10"> <div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2> <h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
<button <button

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
export default function NotificationDebug() {
const [mounted, setMounted] = useState(false);
const [debug, setDebug] = useState<any>({});
useEffect(() => {
setMounted(true);
const info = {
notificationSupported: 'Notification' in window,
serviceWorkerSupported: 'serviceWorker' in navigator,
pushManagerSupported: 'PushManager' in window,
permission: 'Notification' in window ? Notification.permission : 'N/A',
localStorage: typeof localStorage !== 'undefined' ? localStorage.getItem('notification-prompt-dismissed') : 'N/A',
};
setDebug(info);
}, []);
if (!mounted) {
return null;
}
return (
<div className="fixed bottom-4 left-4 bg-black text-white text-xs p-3 rounded-lg max-w-xs z-50 font-mono">
<div className="font-bold mb-2">Push Debug:</div>
<div>Notification: {debug.notificationSupported ? '✓' : '✗'}</div>
<div>ServiceWorker: {debug.serviceWorkerSupported ? '✓' : '✗'}</div>
<div>PushManager: {debug.pushManagerSupported ? '✓' : '✗'}</div>
<div>Permission: {debug.permission}</div>
<div>Dismissed: {debug.localStorage || 'No'}</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useEffect, useState } from 'react';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { Bell, X } from 'lucide-react';
export default function NotificationPrompt() {
const { permission, isSubscribed, subscribe } = usePushNotifications();
const [showPrompt, setShowPrompt] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
// Check if we should show the prompt
const shouldShow =
'Notification' in window &&
'serviceWorker' in navigator &&
permission === 'default' && // Not yet asked
!isSubscribed && // Not subscribed
!dismissed && // Not dismissed this session
!localStorage.getItem('notification-prompt-dismissed'); // Not permanently dismissed
if (shouldShow) {
// Show prompt after a short delay (better UX)
const timer = setTimeout(() => {
setShowPrompt(true);
}, 3000); // 3 seconds after app loads
return () => clearTimeout(timer);
}
}, [mounted, permission, isSubscribed, dismissed]);
const handleEnable = async () => {
const success = await subscribe();
if (success) {
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDismissed(true);
};
const handleDismissForever = () => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('notification-prompt-dismissed', 'true');
}
setShowPrompt(false);
setDismissed(true);
};
if (!mounted || !showPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 left-4 sm:left-auto sm:w-96 z-50 animate-slide-up">
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Bell className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 mb-1">
Stay Updated
</h3>
<p className="text-sm text-gray-600 mb-3">
Get notified when items are added, completed, or when you're invited to lists
</p>
<div className="flex gap-2">
<button
onClick={handleEnable}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Enable Notifications
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-gray-600 text-sm font-medium hover:text-gray-900 transition-colors"
>
Later
</button>
</div>
<button
onClick={handleDismissForever}
className="text-xs text-gray-500 hover:text-gray-700 mt-2 underline"
>
Don't ask again
</button>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { Bell, BellOff } from 'lucide-react';
export default function NotificationSettings() {
const { permission, isSubscribed, isLoading, error, subscribe, unsubscribe } =
usePushNotifications();
const handleToggle = async () => {
if (isSubscribed) {
await unsubscribe();
} else {
await subscribe();
}
};
// Don't show if notifications aren't supported
if (!('Notification' in window) || !('serviceWorker' in navigator)) {
return null;
}
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
{isSubscribed ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
Push Notifications
</h3>
<p className="text-sm text-gray-600 mt-1">
Get notified when someone adds items, shares lists, or invites you to collaborate
</p>
{permission === 'denied' && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">
Notifications are blocked. Please enable them in your browser settings.
</p>
</div>
)}
{error && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{isSubscribed && (
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm text-green-800">
✓ You'll receive notifications on this device
</p>
</div>
)}
</div>
<button
onClick={handleToggle}
disabled={isLoading || permission === 'denied'}
className={`ml-4 px-4 py-2 rounded-md font-medium transition-colors ${
isSubscribed
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
: 'bg-blue-600 text-white hover:bg-blue-700'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isLoading ? 'Loading...' : isSubscribed ? 'Disable' : 'Enable'}
</button>
</div>
<div className="mt-4 text-xs text-gray-500">
<p>💡 Tip: Install this app to your home screen for the best notification experience</p>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import { useOfflineSync } from '@/hooks' import { useOfflineSync } from '@/hooks'
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react' import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
@@ -9,8 +10,18 @@ import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
* Validates: Requirements 4.5 * Validates: Requirements 4.5
*/ */
export default function OfflineIndicator() { export default function OfflineIndicator() {
const [mounted, setMounted] = useState(false)
const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync() const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync()
useEffect(() => {
setMounted(true)
}, [])
// Don't render on server to avoid hydration mismatch
if (!mounted) {
return null
}
// Don't show anything if online and no pending syncs // Don't show anything if online and no pending syncs
if (isOnline && queueLength === 0 && !syncError) { if (isOnline && queueLength === 0 && !syncError) {
return null return null

View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
Current Password *
</label>
<input
type="password"
id="currentPassword"
value={currentPassword}
onChange={(e) => 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 && (
<p id="current-password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.currentPassword}
</p>
)}
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
New Password *
</label>
<input
type="password"
id="newPassword"
value={newPassword}
onChange={(e) => 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 && (
<p id="new-password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.newPassword}
</p>
)}
{!errors.newPassword && touched.newPassword && (
<p className="mt-1.5 text-xs sm:text-sm text-gray-500">
Password must be at least 8 characters
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password *
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => 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 && (
<p id="confirm-password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.confirmPassword}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 sm:px-5 py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Updating...' : 'Update Password'}
</button>
</form>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { Shield, Eye, Edit3 } from 'lucide-react'
type Permission = 'owner' | 'editor' | 'viewer'
interface PermissionBadgeProps {
permission: Permission
size?: 'sm' | 'md'
}
export default function PermissionBadge({ permission, size = 'md' }: PermissionBadgeProps) {
const config = {
owner: {
icon: Shield,
label: 'Owner',
className: 'bg-purple-100 text-purple-700 border-purple-200',
},
editor: {
icon: Edit3,
label: 'Editor',
className: 'bg-blue-100 text-blue-700 border-blue-200',
},
viewer: {
icon: Eye,
label: 'Viewer',
className: 'bg-gray-100 text-gray-700 border-gray-200',
},
}
const { icon: Icon, label, className } = config[permission]
const sizeClasses = size === 'sm' ? 'text-xs px-2 py-1' : 'text-sm px-3 py-1.5'
const iconSize = size === 'sm' ? 12 : 14
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border font-medium ${className} ${sizeClasses}`}
>
<Icon size={iconSize} />
{label}
</span>
)
}

203
components/ProfileForm.tsx Normal file
View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Name *
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => 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 && (
<p id="name-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => 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 && (
<p id="email-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.email}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !hasChanges}
className="w-full px-4 sm:px-5 py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</form>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
interface RealtimeUpdaterProps {
listId?: string
pollInterval?: number
}
/**
* Component that enables real-time updates for a page
* Just include this component and it will poll for updates
*/
export default function RealtimeUpdater({ listId, pollInterval = 3000 }: RealtimeUpdaterProps) {
useRealtimeSync({
listId,
enabled: true,
pollInterval,
})
return null
}

348
components/RegisterForm.tsx Normal file
View File

@@ -0,0 +1,348 @@
'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'
import ClaimListsModal from './ClaimListsModal'
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 [showClaimModal, setShowClaimModal] = 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')
// Check if there are lists to claim from localStorage
// Requirement 10.2 - Add UI for users to claim existing lists on first registration
const hasAnonymousLists = checkForAnonymousLists()
if (hasAnonymousLists) {
// Show claim modal
setShowClaimModal(true)
} else {
// No lists to claim, proceed to app
router.push(callbackUrl)
router.refresh()
}
}
} catch (err) {
console.error('Registration error:', err)
showToast('An error occurred. Please try again.', 'error')
} finally {
setIsSubmitting(false)
}
}
const checkForAnonymousLists = (): boolean => {
try {
const stored = localStorage.getItem('anonymousListIds')
if (stored) {
const listIds = JSON.parse(stored)
return Array.isArray(listIds) && listIds.length > 0
}
return false
} catch (err) {
console.error('Error checking for anonymous lists:', err)
return false
}
}
const handleClaimComplete = () => {
setShowClaimModal(false)
showToast('Lists claimed successfully!', 'success')
router.push(callbackUrl)
router.refresh()
}
const handleClaimClose = () => {
setShowClaimModal(false)
router.push(callbackUrl)
router.refresh()
}
return (
<>
<ClaimListsModal
isOpen={showClaimModal}
onClose={handleClaimClose}
onComplete={handleClaimComplete}
/>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Name *
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => 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 && (
<p id="name-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => 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 && (
<p id="email-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password *
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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 && (
<p id="password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.password}
</p>
)}
{!errors.password && touched.password && (
<p className="mt-1.5 text-xs sm:text-sm text-gray-500">
Password must be at least 8 characters
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm Password *
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => 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 && (
<p id="confirm-password-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.confirmPassword}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 sm:px-5 py-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50 tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo } from 'react' import { memo, useState, useEffect } from 'react'
import { Search, X } from 'lucide-react' import { Search, X } from 'lucide-react'
import { useSearch } from '@/hooks' import { useSearch } from '@/hooks'
@@ -17,18 +17,25 @@ function SearchBar({
debounceMs = 300, debounceMs = 300,
initialValue = '' initialValue = ''
}: SearchBarProps) { }: SearchBarProps) {
const [mounted, setMounted] = useState(false)
const { query, setQuery, clearSearch, isDebouncing } = useSearch({ const { query, setQuery, clearSearch, isDebouncing } = useSearch({
debounceMs, debounceMs,
onSearch onSearch
}) })
useEffect(() => {
setMounted(true)
}, [])
return ( return (
<div className="relative"> <div className="relative">
<div className="relative"> <div className="relative">
<Search {mounted && (
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" <Search
size={20} className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/> size={20}
/>
)}
<input <input
type="text" type="text"
value={query} value={query}
@@ -36,7 +43,7 @@ function SearchBar({
placeholder={placeholder} placeholder={placeholder}
className="w-full pl-10 sm:pl-12 pr-10 sm:pr-12 py-3 sm:py-3.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm sm:text-base transition-shadow" className="w-full pl-10 sm:pl-12 pr-10 sm:pr-12 py-3 sm:py-3.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm sm:text-base transition-shadow"
/> />
{query && ( {mounted && query && (
<button <button
onClick={clearSearch} onClick={clearSearch}
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 active:text-gray-700 transition-colors tap-target-sm" className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 active:text-gray-700 transition-colors tap-target-sm"
@@ -45,7 +52,7 @@ function SearchBar({
<X size={20} /> <X size={20} />
</button> </button>
)} )}
{isDebouncing && ( {mounted && isDebouncing && (
<div className="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2"> <div className="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div> </div>

View File

@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { Users, Mail, Send } from 'lucide-react'
import Modal from './Modal'
import CollaboratorList from './CollaboratorList'
import { inviteUserToList, getCollaborators } from '@/lib/sharing-actions'
import { useRouter } from 'next/navigation'
interface Collaborator {
id: string
permission: 'editor' | 'viewer'
user: {
id: string
name: string | null
email: string
}
}
interface ShareListModalProps {
isOpen: boolean
onClose: () => void
listId: string
listTitle: string
isOwner: boolean
ownerId: string
ownerName: string | null
ownerEmail: string
initialCollaborators?: Collaborator[]
}
export default function ShareListModal({
isOpen,
onClose,
listId,
listTitle,
isOwner,
ownerId,
ownerName,
ownerEmail,
initialCollaborators = [],
}: ShareListModalProps) {
const [email, setEmail] = useState('')
const [permission, setPermission] = useState<'editor' | 'viewer'>('editor')
const [isInviting, setIsInviting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [collaborators, setCollaborators] = useState<Collaborator[]>(initialCollaborators)
const router = useRouter()
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
setIsInviting(true)
setError(null)
setSuccess(null)
try {
await inviteUserToList(listId, email, permission)
setSuccess(`Invitation sent to ${email}`)
setEmail('')
setPermission('editor')
// Refresh collaborators list
const updatedCollaborators = await getCollaborators(listId) as Collaborator[]
setCollaborators(updatedCollaborators)
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send invitation')
} finally {
setIsInviting(false)
}
}
const handleClose = () => {
setEmail('')
setPermission('editor')
setError(null)
setSuccess(null)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={handleClose} title={`Share "${listTitle}"`}>
<div className="space-y-6">
{/* Invite Form - Only show if user is owner */}
{isOwner && (
<div>
<div className="flex items-center gap-2 mb-3">
<Mail size={18} className="text-gray-600" />
<h3 className="font-medium text-gray-900">Invite Collaborator</h3>
</div>
<form onSubmit={handleInvite} className="space-y-3">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="colleague@example.com"
required
disabled={isInviting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div>
<div>
<label htmlFor="permission" className="block text-sm font-medium text-gray-700 mb-1">
Permission Level
</label>
<select
id="permission"
value={permission}
onChange={(e) => setPermission(e.target.value as 'editor' | 'viewer')}
disabled={isInviting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="editor">Editor - Can add, edit, and delete items</option>
<option value="viewer">Viewer - Can only view items</option>
</select>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700">
{success}
</div>
)}
<button
type="submit"
disabled={isInviting || !email}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 active:bg-blue-800 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors font-medium"
>
<Send size={18} />
{isInviting ? 'Sending...' : 'Send Invitation'}
</button>
</form>
</div>
)}
{/* Collaborators List */}
<div>
<div className="flex items-center gap-2 mb-3">
<Users size={18} className="text-gray-600" />
<h3 className="font-medium text-gray-900">
{isOwner ? 'People with Access' : 'Collaborators'}
</h3>
</div>
<CollaboratorList
listId={listId}
collaborators={collaborators}
isOwner={isOwner}
ownerId={ownerId}
ownerName={ownerName}
ownerEmail={ownerEmail}
/>
</div>
</div>
</Modal>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react' import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
export type ToastType = 'success' | 'error' | 'info' export type ToastType = 'success' | 'error' | 'info'
@@ -13,12 +13,26 @@ interface ToastProps {
} }
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) { export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
const onCloseRef = useRef(onClose)
// Keep the ref updated
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
useEffect(() => { useEffect(() => {
if (duration > 0) { if (duration > 0) {
const timer = setTimeout(onClose, duration) console.log(`[Toast] Setting timer for ${duration}ms`)
return () => clearTimeout(timer) const timer = setTimeout(() => {
console.log('[Toast] Auto-dismissing')
onCloseRef.current()
}, duration)
return () => {
console.log('[Toast] Clearing timer')
clearTimeout(timer)
}
} }
}, [duration, onClose]) }, [duration])
const icons = { const icons = {
success: <CheckCircle size={20} className="flex-shrink-0" />, success: <CheckCircle size={20} className="flex-shrink-0" />,

View File

@@ -27,11 +27,17 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]) const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((message: string, type: ToastType) => { const showToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(7) const id = `${Date.now()}-${Math.random().toString(36).substring(7)}`
setToasts((prev) => [...prev, { id, message, type }]) console.log('[ToastContainer] Showing toast:', id, message)
setToasts((prev) => {
// Limit to 3 toasts at a time
const newToasts = [...prev, { id, message, type }]
return newToasts.slice(-3)
})
}, []) }, [])
const removeToast = useCallback((id: string) => { const removeToast = useCallback((id: string) => {
console.log('[ToastContainer] Removing toast:', id)
setToasts((prev) => prev.filter((toast) => toast.id !== id)) setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, []) }, [])

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useOptimistic } from 'react'
import { useRouter } from 'next/navigation'
import { toggleItem } from '@/lib/actions' import { toggleItem } from '@/lib/actions'
import { CheckCircle2, Circle } from 'lucide-react' import { CheckCircle2, Circle } from 'lucide-react'
import { useToast } from './ToastContainer' import { useToast } from './ToastContainer'
@@ -12,19 +13,31 @@ interface ToggleItemButtonProps {
} }
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) { export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
const router = useRouter()
const { showToast } = useToast() const { showToast } = useToast()
const [isToggling, setIsToggling] = useState(false) const [isToggling, setIsToggling] = useState(false)
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(completed)
const handleToggle = async (e: React.MouseEvent) => { const handleToggle = async (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isToggling) return
setIsToggling(true) setIsToggling(true)
// Optimistically update UI
setOptimisticCompleted(!completed)
try { try {
await toggleItem(itemId) await toggleItem(itemId)
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success') showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
router.refresh()
} catch (error) { } catch (error) {
console.error('Error toggling item:', error) console.error('Error toggling item:', error)
showToast('Failed to update task', 'error') showToast('Failed to update task', 'error')
// Revert optimistic update on error
setOptimisticCompleted(completed)
} finally { } finally {
setIsToggling(false) setIsToggling(false)
} }
@@ -36,9 +49,9 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
onClick={handleToggle} onClick={handleToggle}
disabled={isToggling} disabled={isToggling}
className="hover:scale-110 active:scale-95 transition-transform disabled:opacity-50 tap-target-sm" className="hover:scale-110 active:scale-95 transition-transform disabled:opacity-50 tap-target-sm"
aria-label={completed ? 'Mark incomplete' : 'Mark complete'} aria-label={optimisticCompleted ? 'Mark incomplete' : 'Mark complete'}
> >
{completed ? ( {optimisticCompleted ? (
<CheckCircle2 size={22} className="text-green-500" /> <CheckCircle2 size={22} className="text-green-500" />
) : ( ) : (
<Circle size={22} className="text-gray-400 hover:text-gray-600 active:text-gray-700" /> <Circle size={22} className="text-gray-400 hover:text-gray-600 active:text-gray-700" />
@@ -53,7 +66,7 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
disabled={isToggling} disabled={isToggling}
className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 disabled:bg-gray-50 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium" className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 disabled:bg-gray-50 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
> >
{completed ? ( {optimisticCompleted ? (
<> <>
<CheckCircle2 size={18} className="text-green-500 flex-shrink-0" /> <CheckCircle2 size={18} className="text-green-500 flex-shrink-0" />
<span>Mark Incomplete</span> <span>Mark Incomplete</span>

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3500:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=${DATABASE_URL}
- AUTH_URL=http://localhost:3500
- NEXTAUTH_URL=http://localhost:3500
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET}
- AUTH_TRUST_HOST=true
# Push Notifications (VAPID)
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
restart: unless-stopped
depends_on:
- db-check
networks:
- app-network
# Health check service to ensure MySQL is accessible
db-check:
image: mysql:8.0
command: >
sh -c "
until mysqladmin ping -h$${DB_HOST} -P$${DB_PORT} -u$${DB_USER} -p$${DB_PASSWORD} --silent; do
echo 'Waiting for MySQL...';
sleep 2;
done;
echo 'MySQL is ready!';
"
environment:
- DB_HOST=${DB_HOST:-localhost}
- DB_PORT=${DB_PORT:-3306}
- DB_USER=${DB_USER:-root}
- DB_PASSWORD=${DB_PASSWORD}
networks:
- app-network
networks:
app-network:
driver: bridge

View File

@@ -10,3 +10,4 @@ export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition' export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
export { useOfflineSync } from './useOfflineSync' export { useOfflineSync } from './useOfflineSync'
export type { QueuedMutation } from './useOfflineSync' export type { QueuedMutation } from './useOfflineSync'
export { useRealtimeSync } from './useRealtimeSync'

View File

@@ -0,0 +1,170 @@
'use client';
import { useState, useEffect } from 'react';
export function usePushNotifications() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
checkSubscription();
}
}, []);
const checkSubscription = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
} catch (err) {
console.error('Failed to check subscription:', err);
}
};
const requestPermission = async () => {
if (!('Notification' in window)) {
setError('Notifications not supported');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (err) {
console.error('Failed to request permission:', err);
setError('Failed to request permission');
return false;
}
};
const subscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setError('Push notifications not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
// Request permission if not granted
if (permission !== 'granted') {
const granted = await requestPermission();
if (!granted) {
setError('Permission denied');
setIsLoading(false);
return false;
}
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const response = await fetch('/api/push/vapid-key');
const { publicKey } = await response.json();
if (!publicKey) {
throw new Error('VAPID public key not available');
}
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
const saveResponse = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription.toJSON()),
});
if (!saveResponse.ok) {
throw new Error('Failed to save subscription');
}
setIsSubscribed(true);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to subscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to subscribe');
setIsLoading(false);
return false;
}
};
const unsubscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return false;
}
setIsLoading(true);
setError(null);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push manager
await subscription.unsubscribe();
// Remove subscription from server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
setIsSubscribed(false);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to unsubscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
setIsLoading(false);
return false;
}
};
return {
permission,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
requestPermission,
};
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

72
hooks/useRealtimeSync.ts Normal file
View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
interface UseRealtimeSyncOptions {
listId?: string
enabled?: boolean
pollInterval?: number
onUpdate?: () => void
}
/**
* Hook to enable real-time synchronization of list data
* Polls the server for updates by refreshing the route
*/
export function useRealtimeSync({
listId,
enabled = true,
pollInterval = 3000, // Poll every 3 seconds
onUpdate
}: UseRealtimeSyncOptions) {
const router = useRouter()
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const refresh = useCallback(() => {
console.log('[RealtimeSync] Refreshing...', listId ? `list ${listId}` : 'page')
router.refresh()
if (onUpdate) {
onUpdate()
}
}, [router, onUpdate, listId])
useEffect(() => {
if (!enabled) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
return
}
// Start polling
intervalRef.current = setInterval(refresh, pollInterval)
// Cleanup on unmount
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [enabled, pollInterval, refresh])
// Also refresh when the page becomes visible again
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && enabled) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [enabled, refresh])
return {
refresh,
}
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
/**
* Hook for real-time updates via polling
* Refreshes data at regular intervals to show changes from other users/browsers
*/
export function useRealtimeUpdates(intervalMs: number = 3000) {
const router = useRouter()
const refresh = useCallback(() => {
router.refresh()
}, [router])
useEffect(() => {
// Poll for updates
const interval = setInterval(() => {
refresh()
}, intervalMs)
// Also refresh when window regains focus
const handleFocus = () => {
refresh()
}
window.addEventListener('focus', handleFocus)
return () => {
clearInterval(interval)
window.removeEventListener('focus', handleFocus)
}
}, [intervalMs, refresh])
return { refresh }
}

View File

@@ -0,0 +1,222 @@
/**
* Tests for user registration functionality
* This file tests the core registration logic without session creation
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { prisma } from '../db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
// Mock the auth module to avoid Next.js dependencies in tests
vi.mock('@/auth', () => ({
signIn: vi.fn(),
}))
// Import after mocking
import { registerUser, loginUser } from '../auth-actions'
import { signIn } from 'next-auth/react'
describe('registerUser', () => {
// Clean up test users after each test
afterEach(async () => {
await prisma.user.deleteMany({
where: {
email: {
contains: 'test-',
},
},
})
})
it('should create a new user with valid credentials', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const name = 'Test User'
const result = await registerUser(email, password, name)
expect(result.success).toBe(true)
if (result.success) {
expect(result.userId).toBeDefined()
expect(typeof result.userId).toBe('string')
// Verify user was created in database
const user = await prisma.user.findUnique({
where: { email },
})
expect(user).toBeDefined()
expect(user?.email).toBe(email)
expect(user?.name).toBe(name)
expect(user?.passwordHash).toBeDefined()
expect(user?.passwordHash).not.toBe(password) // Password should be hashed
}
})
it('should reject registration with duplicate email', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
// Create first user
const result1 = await registerUser(email, password)
expect(result1.success).toBe(true)
// Try to create second user with same email
const result2 = await registerUser(email, password)
expect(result2.success).toBe(false)
if (!result2.success) {
expect(result2.error).toContain('already registered')
}
})
it('should hash the password before storing', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(true)
const user = await prisma.user.findUnique({
where: { email },
})
expect(user?.passwordHash).toBeDefined()
expect(user?.passwordHash).not.toBe(password)
// Verify the hash is valid
const isValid = await bcrypt.compare(password, user!.passwordHash)
expect(isValid).toBe(true)
})
it('should reject password shorter than 8 characters', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'short'
const result = await registerUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('at least 8 characters')
}
})
it('should reject invalid email format', async () => {
const email = 'invalid-email'
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Invalid email')
}
})
it('should create user without name (optional field)', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(true)
const user = await prisma.user.findUnique({
where: { email },
})
expect(user).toBeDefined()
expect(user?.name).toBeNull()
})
})
describe('loginUser', () => {
// Clean up test users after each test
afterEach(async () => {
await prisma.user.deleteMany({
where: {
email: {
contains: 'test-login-',
},
},
})
vi.clearAllMocks()
})
it('should successfully login with valid credentials', async () => {
const email = `test-login-${Date.now()}@example.com`
const password = 'password123'
// Create a test user first
await registerUser(email, password, 'Test User')
// Mock signIn to succeed
vi.mocked(signIn).mockResolvedValueOnce(undefined as any)
// Attempt login
const result = await loginUser(email, password)
expect(result.success).toBe(true)
expect(signIn).toHaveBeenCalledWith('credentials', {
email,
password,
redirect: false,
})
})
it('should reject login with invalid credentials', async () => {
const email = `test-login-${Date.now()}@example.com`
const password = 'password123'
// Create a test user first
await registerUser(email, password, 'Test User')
// Mock signIn to throw error (invalid credentials)
vi.mocked(signIn).mockRejectedValueOnce(new Error('Invalid credentials'))
// Attempt login with wrong password
const result = await loginUser(email, 'wrongpassword')
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Invalid email or password')
}
})
it('should reject login with non-existent email', async () => {
const email = `test-login-nonexistent-${Date.now()}@example.com`
const password = 'password123'
// Mock signIn to throw error (user not found)
vi.mocked(signIn).mockRejectedValueOnce(new Error('User not found'))
// Attempt login with non-existent email
const result = await loginUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Invalid email or password')
}
})
it('should reject login with invalid email format', async () => {
const email = 'invalid-email'
const password = 'password123'
const result = await loginUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Invalid email')
}
})
it('should reject login with empty password', async () => {
const email = `test-login-${Date.now()}@example.com`
const password = ''
const result = await loginUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Password is required')
}
})
})

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest'
/**
* Middleware Route Protection Tests
*
* These tests verify the route protection logic defined in middleware.ts
* Testing the actual middleware requires mocking NextAuth which is complex,
* so we test the route matching logic here.
*/
describe('Middleware Route Protection Logic', () => {
// Helper to check if a path is a protected route
const isProtectedRoute = (pathname: string): boolean => {
return (
pathname === "/" ||
pathname.startsWith("/list") ||
pathname.startsWith("/profile") ||
pathname.startsWith("/invitations")
)
}
// Helper to check if a path is an auth page
const isAuthPage = (pathname: string): boolean => {
return pathname.startsWith("/auth")
}
describe('Protected Routes', () => {
it('should identify root path as protected', () => {
expect(isProtectedRoute("/")).toBe(true)
})
it('should identify list routes as protected', () => {
expect(isProtectedRoute("/list")).toBe(true)
expect(isProtectedRoute("/list/123")).toBe(true)
expect(isProtectedRoute("/list/123/item/456")).toBe(true)
})
it('should identify profile routes as protected', () => {
expect(isProtectedRoute("/profile")).toBe(true)
expect(isProtectedRoute("/profile/settings")).toBe(true)
})
it('should identify invitations routes as protected', () => {
expect(isProtectedRoute("/invitations")).toBe(true)
})
})
describe('Auth Pages', () => {
it('should identify login page as auth page', () => {
expect(isAuthPage("/auth/login")).toBe(true)
})
it('should identify register page as auth page', () => {
expect(isAuthPage("/auth/register")).toBe(true)
})
it('should identify error page as auth page', () => {
expect(isAuthPage("/auth/error")).toBe(true)
})
it('should not identify non-auth pages as auth pages', () => {
expect(isAuthPage("/")).toBe(false)
expect(isAuthPage("/list/123")).toBe(false)
})
})
describe('Route Classification', () => {
it('should correctly classify various routes', () => {
// Protected routes
expect(isProtectedRoute("/")).toBe(true)
expect(isProtectedRoute("/list/abc123")).toBe(true)
expect(isProtectedRoute("/profile")).toBe(true)
// Auth pages (not protected, but special handling)
expect(isAuthPage("/auth/login")).toBe(true)
expect(isAuthPage("/auth/register")).toBe(true)
// Public routes (not protected)
expect(isProtectedRoute("/api/auth/signin")).toBe(false)
expect(isProtectedRoute("/offline")).toBe(false)
})
})
describe('Middleware Matcher Pattern', () => {
// Test that the matcher pattern would correctly exclude certain paths
const shouldBeExcludedByMatcher = (pathname: string): boolean => {
// Based on the matcher config in middleware.ts
return (
pathname.startsWith("/api") ||
pathname.startsWith("/_next/static") ||
pathname.startsWith("/_next/image") ||
pathname === "/favicon.ico" ||
/\.(svg|png|jpg|jpeg|gif|webp|ico|json|js)$/.test(pathname)
)
}
it('should exclude API routes from middleware', () => {
expect(shouldBeExcludedByMatcher("/api/lists")).toBe(true)
expect(shouldBeExcludedByMatcher("/api/auth/signin")).toBe(true)
})
it('should exclude static files from middleware', () => {
expect(shouldBeExcludedByMatcher("/_next/static/chunks/main.js")).toBe(true)
expect(shouldBeExcludedByMatcher("/_next/image/logo.png")).toBe(true)
})
it('should exclude public assets from middleware', () => {
expect(shouldBeExcludedByMatcher("/favicon.ico")).toBe(true)
expect(shouldBeExcludedByMatcher("/icon-192.png")).toBe(true)
expect(shouldBeExcludedByMatcher("/manifest.json")).toBe(true)
expect(shouldBeExcludedByMatcher("/sw.js")).toBe(true)
})
it('should not exclude regular pages from middleware', () => {
expect(shouldBeExcludedByMatcher("/")).toBe(false)
expect(shouldBeExcludedByMatcher("/list/123")).toBe(false)
expect(shouldBeExcludedByMatcher("/auth/login")).toBe(false)
expect(shouldBeExcludedByMatcher("/profile")).toBe(false)
})
})
})

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prisma } from '../db'
import { claimLists, getClaimableLists, hasExistingLists } from '../migration-actions'
import bcrypt from 'bcryptjs'
// Mock auth
vi.mock('@/auth', () => ({
auth: vi.fn(),
}))
const { auth } = await import('@/auth')
describe('Migration Actions', () => {
let testUser: any
let testLists: any[]
beforeEach(async () => {
// Clean up test data
await prisma.list.deleteMany({})
await prisma.user.deleteMany({})
// Create test user
const passwordHash = await bcrypt.hash('password123', 10)
testUser = await prisma.user.create({
data: {
email: 'test@example.com',
name: 'Test User',
passwordHash,
},
})
// Create test lists owned by test user
testLists = await Promise.all([
prisma.list.create({
data: {
title: 'Test List 1',
color: '#FF0000',
ownerId: testUser.id,
},
}),
prisma.list.create({
data: {
title: 'Test List 2',
color: '#00FF00',
ownerId: testUser.id,
},
}),
])
})
describe('getClaimableLists', () => {
it('should return lists that exist in the database', async () => {
const listIds = testLists.map(l => l.id)
const claimable = await getClaimableLists(listIds)
expect(claimable).toHaveLength(2)
expect(claimable[0].title).toBe('Test List 1')
expect(claimable[1].title).toBe('Test List 2')
expect(claimable[0].itemCount).toBe(0)
})
it('should return empty array for non-existent list IDs', async () => {
const claimable = await getClaimableLists(['non-existent-id'])
expect(claimable).toHaveLength(0)
})
it('should return empty array for empty input', async () => {
const claimable = await getClaimableLists([])
expect(claimable).toHaveLength(0)
})
})
describe('claimLists', () => {
let newUser: any
beforeEach(async () => {
// Create a new user to claim lists
const passwordHash = await bcrypt.hash('password456', 10)
newUser = await prisma.user.create({
data: {
email: 'newuser@example.com',
name: 'New User',
passwordHash,
},
})
})
it('should transfer lists to the authenticated user', async () => {
// Mock auth to return new user
vi.mocked(auth).mockResolvedValue({
user: { id: newUser.id, email: newUser.email, name: newUser.name },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const listIds = testLists.map(l => l.id)
const result = await claimLists(listIds)
expect(result.success).toBe(true)
expect(result.migratedCount).toBe(2)
// Verify lists are now owned by new user
const updatedLists = await prisma.list.findMany({
where: { id: { in: listIds } },
})
updatedLists.forEach(list => {
expect(list.ownerId).toBe(newUser.id)
})
})
it('should preserve all list data during migration', async () => {
// Mock auth
vi.mocked(auth).mockResolvedValue({
user: { id: newUser.id, email: newUser.email, name: newUser.name },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const originalList = testLists[0]
const listIds = [originalList.id]
await claimLists(listIds)
// Verify list data is preserved
const updatedList = await prisma.list.findUnique({
where: { id: originalList.id },
})
expect(updatedList).toBeDefined()
expect(updatedList!.title).toBe(originalList.title)
expect(updatedList!.color).toBe(originalList.color)
expect(updatedList!.createdAt.getTime()).toBe(originalList.createdAt.getTime())
// Only ownerId should change
expect(updatedList!.ownerId).toBe(newUser.id)
})
it('should return error when not authenticated', async () => {
// Mock auth to return null
vi.mocked(auth).mockResolvedValue(null)
const result = await claimLists([testLists[0].id])
expect(result.success).toBe(false)
expect(result.error).toContain('Unauthorized')
})
it('should handle empty list IDs', async () => {
// Mock auth
vi.mocked(auth).mockResolvedValue({
user: { id: newUser.id, email: newUser.email, name: newUser.name },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const result = await claimLists([])
expect(result.success).toBe(true)
expect(result.migratedCount).toBe(0)
})
})
describe('hasExistingLists', () => {
it('should return true when user has lists', async () => {
// Mock auth
vi.mocked(auth).mockResolvedValue({
user: { id: testUser.id, email: testUser.email, name: testUser.name },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const result = await hasExistingLists()
expect(result).toBe(true)
})
it('should return false when user has no lists', async () => {
// Create user with no lists
const passwordHash = await bcrypt.hash('password789', 10)
const emptyUser = await prisma.user.create({
data: {
email: 'empty@example.com',
name: 'Empty User',
passwordHash,
},
})
// Mock auth
vi.mocked(auth).mockResolvedValue({
user: { id: emptyUser.id, email: emptyUser.email, name: emptyUser.name },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const result = await hasExistingLists()
expect(result).toBe(false)
})
it('should return false when not authenticated', async () => {
// Mock auth to return null
vi.mocked(auth).mockResolvedValue(null)
const result = await hasExistingLists()
expect(result).toBe(false)
})
})
})

View File

@@ -0,0 +1,268 @@
/**
* Tests for permission utility functions
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { prisma } from '../db'
import {
canAccessList,
canEditList,
canDeleteList,
getPermission,
} from '../permissions'
import bcrypt from 'bcryptjs'
describe('Permission Utilities', () => {
let testUser1Id: string
let testUser2Id: string
let testListId: string
beforeEach(async () => {
// Create test users
const passwordHash = await bcrypt.hash('password123', 10)
const user1 = await prisma.user.create({
data: {
email: `test-perm-user1-${Date.now()}@example.com`,
passwordHash,
name: 'Test User 1',
},
})
testUser1Id = user1.id
const user2 = await prisma.user.create({
data: {
email: `test-perm-user2-${Date.now()}@example.com`,
passwordHash,
name: 'Test User 2',
},
})
testUser2Id = user2.id
// Create a test list owned by user1
const list = await prisma.list.create({
data: {
title: 'Test List',
color: '#3B82F6',
ownerId: testUser1Id,
},
})
testListId = list.id
})
afterEach(async () => {
// Clean up test data
await prisma.list.deleteMany({
where: {
id: testListId,
},
})
await prisma.user.deleteMany({
where: {
id: {
in: [testUser1Id, testUser2Id],
},
},
})
})
describe('canAccessList', () => {
it('should return true for list owner', async () => {
const result = await canAccessList(testUser1Id, testListId)
expect(result).toBe(true)
})
it('should return false for non-owner without collaboration', async () => {
const result = await canAccessList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return true for editor collaborator', async () => {
// Add user2 as editor
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'editor',
},
})
const result = await canAccessList(testUser2Id, testListId)
expect(result).toBe(true)
})
it('should return true for viewer collaborator', async () => {
// Add user2 as viewer
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'viewer',
},
})
const result = await canAccessList(testUser2Id, testListId)
expect(result).toBe(true)
})
it('should return false for invalid user ID', async () => {
const result = await canAccessList('', testListId)
expect(result).toBe(false)
})
it('should return false for invalid list ID', async () => {
const result = await canAccessList(testUser1Id, '')
expect(result).toBe(false)
})
})
describe('canEditList', () => {
it('should return true for list owner', async () => {
const result = await canEditList(testUser1Id, testListId)
expect(result).toBe(true)
})
it('should return false for non-owner without collaboration', async () => {
const result = await canEditList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return true for editor collaborator', async () => {
// Add user2 as editor
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'editor',
},
})
const result = await canEditList(testUser2Id, testListId)
expect(result).toBe(true)
})
it('should return false for viewer collaborator', async () => {
// Add user2 as viewer
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'viewer',
},
})
const result = await canEditList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return false for invalid user ID', async () => {
const result = await canEditList('', testListId)
expect(result).toBe(false)
})
it('should return false for invalid list ID', async () => {
const result = await canEditList(testUser1Id, '')
expect(result).toBe(false)
})
})
describe('canDeleteList', () => {
it('should return true for list owner', async () => {
const result = await canDeleteList(testUser1Id, testListId)
expect(result).toBe(true)
})
it('should return false for non-owner', async () => {
const result = await canDeleteList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return false for editor collaborator', async () => {
// Add user2 as editor
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'editor',
},
})
const result = await canDeleteList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return false for viewer collaborator', async () => {
// Add user2 as viewer
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'viewer',
},
})
const result = await canDeleteList(testUser2Id, testListId)
expect(result).toBe(false)
})
it('should return false for invalid user ID', async () => {
const result = await canDeleteList('', testListId)
expect(result).toBe(false)
})
it('should return false for invalid list ID', async () => {
const result = await canDeleteList(testUser1Id, '')
expect(result).toBe(false)
})
})
describe('getPermission', () => {
it('should return "owner" for list owner', async () => {
const result = await getPermission(testUser1Id, testListId)
expect(result).toBe('owner')
})
it('should return null for non-owner without collaboration', async () => {
const result = await getPermission(testUser2Id, testListId)
expect(result).toBeNull()
})
it('should return "editor" for editor collaborator', async () => {
// Add user2 as editor
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'editor',
},
})
const result = await getPermission(testUser2Id, testListId)
expect(result).toBe('editor')
})
it('should return "viewer" for viewer collaborator', async () => {
// Add user2 as viewer
await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'viewer',
},
})
const result = await getPermission(testUser2Id, testListId)
expect(result).toBe('viewer')
})
it('should return null for invalid user ID', async () => {
const result = await getPermission('', testListId)
expect(result).toBeNull()
})
it('should return null for invalid list ID', async () => {
const result = await getPermission(testUser1Id, '')
expect(result).toBeNull()
})
})
})

View File

@@ -0,0 +1,337 @@
/**
* Security enhancements tests
* Tests for rate limiting, input sanitization, and security headers
*
* Requirements: 9.3, 9.4, 9.5
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
checkRateLimit,
resetRateLimit,
LOGIN_RATE_LIMIT,
REGISTER_RATE_LIMIT,
INVITATION_RATE_LIMIT,
getClientIdentifier
} from '../rate-limit'
import {
sanitizeString,
sanitizeEmail,
sanitizeHtml,
sanitizeSqlInput,
validateEmail,
validatePassword
} from '../validation'
describe('Rate Limiting', () => {
beforeEach(() => {
// Reset rate limits before each test
resetRateLimit('test:key')
resetRateLimit('login:test@example.com')
resetRateLimit('register:192.168.1.1')
})
describe('checkRateLimit', () => {
it('should allow requests within the limit', () => {
const result = checkRateLimit('test:key', {
maxRequests: 5,
windowMs: 60000,
})
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(4)
})
it('should block requests exceeding the limit', () => {
const config = {
maxRequests: 3,
windowMs: 60000,
}
// Make 3 requests (should all succeed)
for (let i = 0; i < 3; i++) {
const result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
}
// 4th request should be blocked
const result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
expect(result.remaining).toBe(0)
expect(result.retryAfter).toBeGreaterThan(0)
})
it('should reset after window expires', () => {
const config = {
maxRequests: 2,
windowMs: 100, // 100ms window
}
// Make 2 requests
checkRateLimit('test:key', config)
checkRateLimit('test:key', config)
// 3rd request should be blocked
let result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
// Wait for window to expire
return new Promise<void>((resolve) => {
setTimeout(() => {
// Should be allowed again
result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
resolve()
}, 150)
})
})
it('should track different keys separately', () => {
const config = {
maxRequests: 2,
windowMs: 60000,
}
// Make 2 requests for key1
checkRateLimit('test:key1', config)
checkRateLimit('test:key1', config)
// key1 should be blocked
let result = checkRateLimit('test:key1', config)
expect(result.allowed).toBe(false)
// key2 should still be allowed
result = checkRateLimit('test:key2', config)
expect(result.allowed).toBe(true)
})
})
describe('resetRateLimit', () => {
it('should reset rate limit for a key', () => {
const config = {
maxRequests: 2,
windowMs: 60000,
}
// Exhaust the limit
checkRateLimit('test:key', config)
checkRateLimit('test:key', config)
// Should be blocked
let result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
// Reset the limit
resetRateLimit('test:key')
// Should be allowed again
result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
})
})
describe('Rate limit configurations', () => {
it('should have appropriate login rate limit', () => {
expect(LOGIN_RATE_LIMIT.maxRequests).toBe(5)
expect(LOGIN_RATE_LIMIT.windowMs).toBe(15 * 60 * 1000) // 15 minutes
})
it('should have appropriate registration rate limit', () => {
expect(REGISTER_RATE_LIMIT.maxRequests).toBe(3)
expect(REGISTER_RATE_LIMIT.windowMs).toBe(60 * 60 * 1000) // 1 hour
})
it('should have appropriate invitation rate limit', () => {
expect(INVITATION_RATE_LIMIT.maxRequests).toBe(10)
expect(INVITATION_RATE_LIMIT.windowMs).toBe(60 * 60 * 1000) // 1 hour
})
})
describe('getClientIdentifier', () => {
it('should extract IP from X-Forwarded-For header', () => {
const headers = new Headers()
headers.set('x-forwarded-for', '192.168.1.1, 10.0.0.1')
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('192.168.1.1')
})
it('should extract IP from X-Real-IP header', () => {
const headers = new Headers()
headers.set('x-real-ip', '192.168.1.1')
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('192.168.1.1')
})
it('should return unknown when no IP headers present', () => {
const headers = new Headers()
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('unknown')
})
})
})
describe('Input Sanitization', () => {
describe('sanitizeString', () => {
it('should trim whitespace', () => {
expect(sanitizeString(' hello ')).toBe('hello')
expect(sanitizeString('\n\thello\t\n')).toBe('hello')
})
it('should remove null bytes', () => {
expect(sanitizeString('hello\0world')).toBe('helloworld')
})
it('should remove control characters', () => {
expect(sanitizeString('hello\x00\x01\x02world')).toBe('helloworld')
})
it('should handle null and undefined', () => {
expect(sanitizeString(null)).toBe('')
expect(sanitizeString(undefined)).toBe('')
})
it('should preserve newlines and tabs', () => {
expect(sanitizeString('hello\nworld')).toBe('hello\nworld')
expect(sanitizeString('hello\tworld')).toBe('hello\tworld')
})
})
describe('sanitizeEmail', () => {
it('should convert to lowercase', () => {
expect(sanitizeEmail('TEST@EXAMPLE.COM')).toBe('test@example.com')
})
it('should trim whitespace', () => {
expect(sanitizeEmail(' test@example.com ')).toBe('test@example.com')
})
it('should remove internal whitespace', () => {
expect(sanitizeEmail('test @example.com')).toBe('test@example.com')
})
it('should remove control characters', () => {
expect(sanitizeEmail('test\x00@example.com')).toBe('test@example.com')
})
it('should handle null and undefined', () => {
expect(sanitizeEmail(null)).toBe('')
expect(sanitizeEmail(undefined)).toBe('')
})
})
describe('sanitizeHtml', () => {
it('should remove HTML tags', () => {
expect(sanitizeHtml('<p>hello</p>')).toBe('hello')
expect(sanitizeHtml('<div><span>test</span></div>')).toBe('test')
})
it('should remove script tags and content', () => {
expect(sanitizeHtml('<script>alert("xss")</script>hello')).toBe('hello')
})
it('should remove event handlers', () => {
expect(sanitizeHtml('<div onclick="alert()">test</div>')).toBe('test')
expect(sanitizeHtml('<img onerror="alert()" />')).toBe('')
})
it('should remove javascript: protocol', () => {
expect(sanitizeHtml('javascript:alert()')).toBe('alert()')
})
it('should encode special HTML characters', () => {
// Test encoding of special characters that aren't part of tags
const result = sanitizeHtml('&"\'/')
expect(result).toContain('&amp;')
expect(result).toContain('&quot;')
expect(result).toContain('&#x27;')
expect(result).toContain('&#x2F;')
// Note: The regex /<[^>]*>/g will match anything between < and >
// This is intentional for security - it's better to be overly cautious
// and remove potential tags than to risk XSS
const result2 = sanitizeHtml('test & value')
expect(result2).toBe('test &amp; value')
})
it('should handle empty input', () => {
expect(sanitizeHtml('')).toBe('')
})
})
describe('sanitizeSqlInput', () => {
it('should remove SQL comment markers', () => {
expect(sanitizeSqlInput('test -- comment')).toBe('test comment')
expect(sanitizeSqlInput('test /* comment */')).toBe('test comment ')
})
it('should remove semicolons', () => {
expect(sanitizeSqlInput('test; DROP TABLE')).toBe('test DROP TABLE')
})
it('should handle empty input', () => {
expect(sanitizeSqlInput('')).toBe('')
})
})
})
describe('Input Validation', () => {
describe('validateEmail', () => {
it('should accept valid emails', () => {
const result = validateEmail('test@example.com')
expect(result.isValid).toBe(true)
})
it('should reject invalid emails', () => {
expect(validateEmail('invalid').isValid).toBe(false)
expect(validateEmail('test@').isValid).toBe(false)
expect(validateEmail('@example.com').isValid).toBe(false)
})
it('should reject empty emails', () => {
expect(validateEmail('').isValid).toBe(false)
})
it('should reject emails that are too long', () => {
const longEmail = 'a'.repeat(256) + '@example.com'
expect(validateEmail(longEmail).isValid).toBe(false)
})
it('should sanitize email before validation', () => {
const result = validateEmail(' TEST@EXAMPLE.COM ')
expect(result.isValid).toBe(true)
})
})
describe('validatePassword', () => {
it('should accept valid passwords', () => {
const result = validatePassword('password123')
expect(result.isValid).toBe(true)
})
it('should reject passwords shorter than 8 characters', () => {
expect(validatePassword('short').isValid).toBe(false)
expect(validatePassword('1234567').isValid).toBe(false)
})
it('should reject empty passwords', () => {
expect(validatePassword('').isValid).toBe(false)
})
it('should reject passwords longer than 128 characters', () => {
const longPassword = 'a'.repeat(129)
expect(validatePassword(longPassword).isValid).toBe(false)
})
it('should accept passwords exactly 8 characters', () => {
expect(validatePassword('12345678').isValid).toBe(true)
})
it('should accept passwords exactly 128 characters', () => {
const maxPassword = 'a'.repeat(128)
expect(validatePassword(maxPassword).isValid).toBe(true)
})
})
})

View File

@@ -0,0 +1,243 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../db'
import {
getCollaborators,
updateCollaboratorPermission,
removeCollaborator,
} from '../sharing-actions'
import bcrypt from 'bcryptjs'
describe('Collaborator Management', () => {
let testUser1Id: string
let testUser2Id: string
let testListId: string
let testCollaboratorId: string
beforeAll(async () => {
// Clean up any existing test data
await prisma.collaborator.deleteMany({
where: {
user: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
},
})
await prisma.list.deleteMany({
where: {
owner: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
},
})
await prisma.user.deleteMany({
where: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
})
// Create test users
const hashedPassword = await bcrypt.hash('password123', 10)
const user1 = await prisma.user.create({
data: {
email: 'collab-test1@example.com',
name: 'Test User 1',
passwordHash: hashedPassword,
},
})
testUser1Id = user1.id
const user2 = await prisma.user.create({
data: {
email: 'collab-test2@example.com',
name: 'Test User 2',
passwordHash: hashedPassword,
},
})
testUser2Id = user2.id
// Create a test list owned by user1
const list = await prisma.list.create({
data: {
title: 'Test List',
color: '#3b82f6',
ownerId: testUser1Id,
},
})
testListId = list.id
// Create a collaborator (user2 on user1's list)
const collaborator = await prisma.collaborator.create({
data: {
userId: testUser2Id,
listId: testListId,
permission: 'viewer',
},
})
testCollaboratorId = collaborator.id
})
afterAll(async () => {
// Clean up test data
await prisma.collaborator.deleteMany({
where: {
user: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
},
})
await prisma.list.deleteMany({
where: {
owner: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
},
})
await prisma.user.deleteMany({
where: {
email: {
in: ['collab-test1@example.com', 'collab-test2@example.com'],
},
},
})
})
describe('getCollaborators', () => {
it('should return all collaborators for a list', async () => {
// Note: This test would need proper session mocking
// For now, we're just testing the database query logic
const collaborators = await prisma.collaborator.findMany({
where: { listId: testListId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
expect(collaborators).toHaveLength(1)
expect(collaborators[0].userId).toBe(testUser2Id)
expect(collaborators[0].permission).toBe('viewer')
expect(collaborators[0].user.email).toBe('collab-test2@example.com')
})
})
describe('updateCollaboratorPermission', () => {
it('should update collaborator permission from viewer to editor', async () => {
// Update permission directly via Prisma (simulating the server action)
const updated = await prisma.collaborator.update({
where: {
id: testCollaboratorId,
listId: testListId,
},
data: {
permission: 'editor',
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
expect(updated.permission).toBe('editor')
expect(updated.userId).toBe(testUser2Id)
})
it('should update collaborator permission from editor to viewer', async () => {
// Update permission back to viewer
const updated = await prisma.collaborator.update({
where: {
id: testCollaboratorId,
listId: testListId,
},
data: {
permission: 'viewer',
},
})
expect(updated.permission).toBe('viewer')
})
})
describe('removeCollaborator', () => {
it('should remove a collaborator from a list', async () => {
// First verify the collaborator exists
const beforeDelete = await prisma.collaborator.findUnique({
where: { id: testCollaboratorId },
})
expect(beforeDelete).not.toBeNull()
// Delete the collaborator
await prisma.collaborator.delete({
where: {
id: testCollaboratorId,
listId: testListId,
},
})
// Verify the collaborator is gone
const afterDelete = await prisma.collaborator.findUnique({
where: { id: testCollaboratorId },
})
expect(afterDelete).toBeNull()
})
})
describe('Permission enforcement', () => {
it('should enforce that only owners can manage collaborators', async () => {
// Verify that the list has an owner
const list = await prisma.list.findUnique({
where: { id: testListId },
select: { ownerId: true },
})
expect(list).not.toBeNull()
expect(list?.ownerId).toBe(testUser1Id)
})
it('should validate permission values', async () => {
// Test that only 'editor' and 'viewer' are valid permissions
const validPermissions = ['editor', 'viewer']
for (const permission of validPermissions) {
// This should not throw
expect(() => {
if (permission !== 'editor' && permission !== 'viewer') {
throw new Error('Invalid permission')
}
}).not.toThrow()
}
// Invalid permission should fail
expect(() => {
const invalidPermission = 'admin'
if (invalidPermission !== 'editor' && invalidPermission !== 'viewer') {
throw new Error('Invalid permission')
}
}).toThrow('Invalid permission')
})
})
})

View File

@@ -2,6 +2,8 @@
import { prisma } from './db' import { prisma } from './db'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { import {
validateListTitle, validateListTitle,
validateItemTitle, validateItemTitle,
@@ -10,6 +12,11 @@ import {
validateTags, validateTags,
sanitizeString, sanitizeString,
} from './validation' } from './validation'
import {
notifyItemAdded,
notifyItemRemoved,
notifyItemCompleted,
} from './notification-helpers'
// Retry utility for database operations // Retry utility for database operations
async function retryOperation<T>( async function retryOperation<T>(
@@ -47,10 +54,49 @@ async function retryOperation<T>(
export async function getLists() { export async function getLists() {
try { try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view lists')
}
const userId = session.user.id
// Get lists where user is owner or collaborator
return await retryOperation(() => return await retryOperation(() =>
prisma.list.findMany({ prisma.list.findMany({
where: {
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
include: { include: {
items: true, items: true,
owner: {
select: {
id: true,
name: true,
email: true,
},
},
collaborators: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
}, },
orderBy: { orderBy: {
updatedAt: 'desc', updatedAt: 'desc',
@@ -59,12 +105,23 @@ export async function getLists() {
) )
} catch (error) { } catch (error) {
console.error('Error fetching lists:', error) console.error('Error fetching lists:', error)
if (error instanceof Error && error.message.includes('Unauthorized')) {
throw error
}
throw new Error('Failed to load lists. Please check your connection and try again.') throw new Error('Failed to load lists. Please check your connection and try again.')
} }
} }
export async function createList(title: string, color: string = '#3B82F6') { export async function createList(title: string, color: string = '#3B82F6') {
try { try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to create lists')
}
const userId = session.user.id
// Validate and sanitize input // Validate and sanitize input
const titleResult = validateListTitle(title) const titleResult = validateListTitle(title)
if (!titleResult.isValid) { if (!titleResult.isValid) {
@@ -83,6 +140,7 @@ export async function createList(title: string, color: string = '#3B82F6') {
data: { data: {
title: sanitizedTitle, title: sanitizedTitle,
color, color,
ownerId: userId,
}, },
}) })
) )
@@ -91,7 +149,7 @@ export async function createList(title: string, color: string = '#3B82F6') {
return list return list
} catch (error) { } catch (error) {
console.error('Error creating list:', error) console.error('Error creating list:', error)
if (error instanceof Error && (error.message.includes('required') || error.message.includes('must be'))) { if (error instanceof Error && (error.message.includes('required') || error.message.includes('must be') || error.message.includes('Unauthorized'))) {
throw error throw error
} }
throw new Error('Failed to create list. Please try again.') throw new Error('Failed to create list. Please try again.')
@@ -104,27 +162,65 @@ export async function getListWithItems(listId: string) {
throw new Error('List ID is required') throw new Error('List ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view lists')
}
const userId = session.user.id
const list = await retryOperation(() => const list = await retryOperation(() =>
prisma.list.findUnique({ prisma.list.findFirst({
where: { id: listId }, where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
include: { include: {
items: { items: {
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}, },
owner: {
select: {
id: true,
name: true,
email: true,
},
},
collaborators: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
}, },
}) })
) )
if (!list) { if (!list) {
throw new Error('List not found') throw new Error('List not found or access denied')
} }
return list return list
} catch (error) { } catch (error) {
console.error('Error fetching list:', error) console.error('Error fetching list:', error)
if (error instanceof Error && error.message === 'List not found') { if (error instanceof Error && (error.message === 'List not found or access denied' || error.message.includes('Unauthorized'))) {
throw error throw error
} }
throw new Error('Failed to load list. Please try again.') throw new Error('Failed to load list. Please try again.')
@@ -137,11 +233,40 @@ export async function getItem(itemId: string) {
throw new Error('Item ID is required') throw new Error('Item ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view items')
}
const userId = session.user.id
const item = await retryOperation(() => const item = await retryOperation(() =>
prisma.item.findUnique({ prisma.item.findUnique({
where: { id: itemId }, where: { id: itemId },
include: { include: {
list: true, list: {
include: {
owner: {
select: {
id: true,
name: true,
email: true,
},
},
collaborators: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
}, },
}) })
) )
@@ -150,10 +275,18 @@ export async function getItem(itemId: string) {
throw new Error('Item not found') throw new Error('Item not found')
} }
// Check if user has access to the parent list
const hasAccess = item.list.ownerId === userId ||
item.list.collaborators.some(c => c.userId === userId)
if (!hasAccess) {
throw new Error('You do not have permission to view this item')
}
return item return item
} catch (error) { } catch (error) {
console.error('Error fetching item:', error) console.error('Error fetching item:', error)
if (error instanceof Error && error.message === 'Item not found') { if (error instanceof Error && (error.message === 'Item not found' || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to load item. Please try again.') throw new Error('Failed to load item. Please try again.')
@@ -177,6 +310,38 @@ export async function createItem(
throw new Error('List ID is required') throw new Error('List ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to create items')
}
const userId = session.user.id
// Check if user has access to the list (owner or editor)
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to add items')
}
const titleResult = validateItemTitle(title) const titleResult = validateItemTitle(title)
if (!titleResult.isValid) { if (!titleResult.isValid) {
throw new Error(titleResult.error) throw new Error(titleResult.error)
@@ -210,11 +375,19 @@ export async function createItem(
}) })
) )
// Send notification to collaborators
try {
await notifyItemAdded(listId, item.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
// Don't fail the action if notification fails
}
revalidatePath(`/list/${listId}`) revalidatePath(`/list/${listId}`)
return item return item
} catch (error) { } catch (error) {
console.error('Error creating item:', error) console.error('Error creating item:', error)
if (error instanceof Error && (error.message.includes('required') || error.message.includes('must be') || error.message.includes('Invalid'))) { if (error instanceof Error && (error.message.includes('required') || error.message.includes('must be') || error.message.includes('Invalid') || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to create item. Please try again.') throw new Error('Failed to create item. Please try again.')
@@ -227,14 +400,49 @@ export async function toggleItem(itemId: string) {
throw new Error('Item ID is required') throw new Error('Item ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update items')
}
const userId = session.user.id
const item = await retryOperation(() => const item = await retryOperation(() =>
prisma.item.findUnique({ where: { id: itemId } }) prisma.item.findUnique({
where: { id: itemId },
include: { list: true },
})
) )
if (!item) { if (!item) {
throw new Error('Item not found') throw new Error('Item not found')
} }
// Check if user has access to the parent list (owner or editor)
const hasAccess = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: item.listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
)
if (!hasAccess) {
throw new Error('You do not have permission to update this item')
}
const updated = await retryOperation(() => const updated = await retryOperation(() =>
prisma.item.update({ prisma.item.update({
where: { id: itemId }, where: { id: itemId },
@@ -242,11 +450,20 @@ export async function toggleItem(itemId: string) {
}) })
) )
// Send notification when item is completed (not uncompleted)
if (updated.completed) {
try {
await notifyItemCompleted(item.listId, updated.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
}
}
revalidatePath(`/list/${item.listId}`) revalidatePath(`/list/${item.listId}`)
return updated return updated
} catch (error) { } catch (error) {
console.error('Error toggling item:', error) console.error('Error toggling item:', error)
if (error instanceof Error && error.message === 'Item not found') { if (error instanceof Error && (error.message === 'Item not found' || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to update item. Please try again.') throw new Error('Failed to update item. Please try again.')
@@ -270,6 +487,14 @@ export async function updateItem(
throw new Error('Item ID is required') throw new Error('Item ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update items')
}
const userId = session.user.id
// Validate and sanitize title if provided // Validate and sanitize title if provided
if (data.title !== undefined) { if (data.title !== undefined) {
const titleResult = validateItemTitle(data.title) const titleResult = validateItemTitle(data.title)
@@ -299,12 +524,39 @@ export async function updateItem(
} }
const item = await retryOperation(() => const item = await retryOperation(() =>
prisma.item.findUnique({ where: { id: itemId } }) prisma.item.findUnique({
where: { id: itemId },
include: { list: true },
})
) )
if (!item) { if (!item) {
throw new Error('Item not found') throw new Error('Item not found')
} }
// Check if user has access to the parent list (owner or editor)
const hasAccess = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: item.listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
)
if (!hasAccess) {
throw new Error('You do not have permission to update this item')
}
const updated = await retryOperation(() => const updated = await retryOperation(() =>
prisma.item.update({ prisma.item.update({
@@ -317,7 +569,7 @@ export async function updateItem(
return updated return updated
} catch (error) { } catch (error) {
console.error('Error updating item:', error) console.error('Error updating item:', error)
if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be') || error.message.includes('Invalid'))) { if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be') || error.message.includes('Invalid') || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to update item. Please try again.') throw new Error('Failed to update item. Please try again.')
@@ -330,13 +582,48 @@ export async function deleteItem(itemId: string) {
throw new Error('Item ID is required') throw new Error('Item ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to delete items')
}
const userId = session.user.id
const item = await retryOperation(() => const item = await retryOperation(() =>
prisma.item.findUnique({ where: { id: itemId } }) prisma.item.findUnique({
where: { id: itemId },
include: { list: true },
})
) )
if (!item) { if (!item) {
throw new Error('Item not found') throw new Error('Item not found')
} }
// Check if user has access to the parent list (owner or editor)
const hasAccess = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: item.listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
)
if (!hasAccess) {
throw new Error('You do not have permission to delete this item')
}
await retryOperation(() => await retryOperation(() =>
prisma.item.delete({ prisma.item.delete({
@@ -344,11 +631,18 @@ export async function deleteItem(itemId: string) {
}) })
) )
// Send notification to collaborators
try {
await notifyItemRemoved(item.listId, item.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
}
revalidatePath(`/list/${item.listId}`) revalidatePath(`/list/${item.listId}`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
console.error('Error deleting item:', error) console.error('Error deleting item:', error)
if (error instanceof Error && error.message === 'Item not found') { if (error instanceof Error && (error.message === 'Item not found' || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to delete item. Please try again.') throw new Error('Failed to delete item. Please try again.')
@@ -361,13 +655,26 @@ export async function deleteList(listId: string) {
throw new Error('List ID is required') throw new Error('List ID is required')
} }
// Check if list exists // Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to delete lists')
}
const userId = session.user.id
// Check if list exists and user is the owner (only owners can delete)
const list = await retryOperation(() => const list = await retryOperation(() =>
prisma.list.findUnique({ where: { id: listId } }) prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
) )
if (!list) { if (!list) {
throw new Error('List not found') throw new Error('List not found or you do not have permission to delete it')
} }
await retryOperation(() => await retryOperation(() =>
@@ -380,7 +687,7 @@ export async function deleteList(listId: string) {
return { success: true } return { success: true }
} catch (error) { } catch (error) {
console.error('Error deleting list:', error) console.error('Error deleting list:', error)
if (error instanceof Error && error.message === 'List not found') { if (error instanceof Error && (error.message.includes('not found') || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to delete list. Please try again.') throw new Error('Failed to delete list. Please try again.')
@@ -399,6 +706,38 @@ export async function updateList(
throw new Error('List ID is required') throw new Error('List ID is required')
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update lists')
}
const userId = session.user.id
// Check if user has access to the list (owner or editor)
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to update it')
}
// Validate and sanitize title if provided // Validate and sanitize title if provided
if (data.title !== undefined) { if (data.title !== undefined) {
const titleResult = validateListTitle(data.title) const titleResult = validateListTitle(data.title)
@@ -428,7 +767,7 @@ export async function updateList(
return updated return updated
} catch (error) { } catch (error) {
console.error('Error updating list:', error) console.error('Error updating list:', error)
if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be') || error.message.includes('Invalid'))) { if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be') || error.message.includes('Invalid') || error.message.includes('Unauthorized') || error.message.includes('permission'))) {
throw error throw error
} }
throw new Error('Failed to update list. Please try again.') throw new Error('Failed to update list. Please try again.')
@@ -440,6 +779,14 @@ export async function searchItems(query: string, listId?: string) {
if (!query || !query.trim()) { if (!query || !query.trim()) {
return [] return []
} }
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to search items')
}
const userId = session.user.id
const searchTerm = query.toLowerCase() const searchTerm = query.toLowerCase()
@@ -465,6 +812,18 @@ export async function searchItems(query: string, listId?: string) {
}, },
}, },
], ],
list: {
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
} }
// Add list scope if specified // Add list scope if specified
@@ -485,15 +844,30 @@ export async function searchItems(query: string, listId?: string) {
) )
// Also search in tags (stored as JSON) // Also search in tags (stored as JSON)
const allItemsWhere: any = {
list: {
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
}
if (listId) {
allItemsWhere.listId = listId
}
const allItems = await retryOperation(() => const allItems = await retryOperation(() =>
listId prisma.item.findMany({
? prisma.item.findMany({ where: allItemsWhere,
where: { listId }, include: { list: true },
include: { list: true }, })
})
: prisma.item.findMany({
include: { list: true },
})
) )
const tagMatches = allItems.filter((item) => { const tagMatches = allItems.filter((item) => {
@@ -512,6 +886,9 @@ export async function searchItems(query: string, listId?: string) {
return uniqueResults return uniqueResults
} catch (error) { } catch (error) {
console.error('Error searching items:', error) console.error('Error searching items:', error)
if (error instanceof Error && error.message.includes('Unauthorized')) {
throw error
}
throw new Error('Failed to search items. Please try again.') throw new Error('Failed to search items. Please try again.')
} }
} }
@@ -530,8 +907,29 @@ export async function getItemsByFilter(
} = {} } = {}
) { ) {
try { try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to filter items')
}
const userId = session.user.id
// Build the where clause with compound filters (AND logic) // Build the where clause with compound filters (AND logic)
const whereClause: any = {} const whereClause: any = {
list: {
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
}
// Add list scope if specified // Add list scope if specified
if (listId) { if (listId) {
@@ -644,6 +1042,9 @@ export async function getItemsByFilter(
} }
} catch (error) { } catch (error) {
console.error('Error filtering items:', error) console.error('Error filtering items:', error)
if (error instanceof Error && error.message.includes('Unauthorized')) {
throw error
}
throw new Error('Failed to filter items. Please try again.') throw new Error('Failed to filter items. Please try again.')
} }
} }

85
lib/anonymous-lists.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Utilities for managing anonymous list IDs in localStorage
* This helps track lists created before user registration for migration
*
* Requirements: 10.2, 10.5 - Migration path for existing anonymous lists
*/
const STORAGE_KEY = 'anonymousListIds'
/**
* Add a list ID to the anonymous lists tracking
*/
export function trackAnonymousList(listId: string): void {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
const listIds: string[] = stored ? JSON.parse(stored) : []
if (!listIds.includes(listId)) {
listIds.push(listId)
localStorage.setItem(STORAGE_KEY, JSON.stringify(listIds))
}
} catch (error) {
console.error('Error tracking anonymous list:', error)
}
}
/**
* Remove a list ID from anonymous lists tracking
*/
export function untrackAnonymousList(listId: string): void {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
const listIds: string[] = JSON.parse(stored)
const filtered = listIds.filter(id => id !== listId)
if (filtered.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
} else {
localStorage.removeItem(STORAGE_KEY)
}
} catch (error) {
console.error('Error untracking anonymous list:', error)
}
}
/**
* Get all tracked anonymous list IDs
*/
export function getAnonymousListIds(): string[] {
if (typeof window === 'undefined') return []
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch (error) {
console.error('Error getting anonymous list IDs:', error)
return []
}
}
/**
* Clear all tracked anonymous list IDs
*/
export function clearAnonymousLists(): void {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(STORAGE_KEY)
} catch (error) {
console.error('Error clearing anonymous lists:', error)
}
}
/**
* Check if there are any tracked anonymous lists
*/
export function hasAnonymousLists(): boolean {
return getAnonymousListIds().length > 0
}

369
lib/auth-actions.ts Normal file
View File

@@ -0,0 +1,369 @@
'use server'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { signIn, signOut } from 'next-auth/react'
import { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
import { headers } from 'next/headers'
import { getClientIdentifier } from './rate-limit'
// Validation schemas
const registerSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
name: z.string().optional(),
})
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(1, { message: 'Password is required' }),
})
export type RegisterResult =
| { success: true; userId: string }
| { success: false; error: string }
export type LoginResult =
| { success: true }
| { success: false; error: string }
/**
* Register a new user with email and password
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 9.3 (rate limiting)
*/
export async function registerUser(
email: string,
password: string,
name?: string
): Promise<RegisterResult> {
try {
// Rate limiting - Requirement 9.3
// Only apply rate limiting when headers are available (not in test environment)
try {
const headersList = await headers()
const clientId = getClientIdentifier(headersList)
const rateLimitKey = `register:${clientId}`
const rateLimit = checkRateLimit(rateLimitKey, REGISTER_RATE_LIMIT)
if (!rateLimit.allowed) {
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
return {
success: false,
error: `Too many registration attempts. Please try again in ${minutesUntilReset} minutes.`
}
}
} catch (headerError) {
// Headers not available (e.g., in test environment) - skip rate limiting
console.log('Rate limiting skipped: headers not available')
}
// Validate input (Requirement 1.5 - password length validation)
const validation = registerSchema.safeParse({ email, password, name })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { email: validEmail, password: validPassword, name: validName } = validation.data
// Check if email already exists (Requirement 1.2 - duplicate email rejection)
const existingUser = await prisma.user.findUnique({
where: { email: validEmail },
})
if (existingUser) {
return { success: false, error: 'Email already registered' }
}
// Hash password (Requirement 1.3 - password hashing)
const passwordHash = await bcrypt.hash(validPassword, 10)
// Create user (Requirement 1.1 - create user with unique identifier)
const user = await prisma.user.create({
data: {
email: validEmail,
passwordHash,
name: validName || null,
},
})
// Create session after successful registration (Requirement 1.4)
try {
await signIn('credentials', {
email: validEmail,
password: validPassword,
redirect: false,
})
} catch (error) {
// If session creation fails, still return success since user was created
console.error('Session creation failed after registration:', error)
}
return { success: true, userId: user.id }
} catch (error) {
console.error('Registration error:', error)
// Handle unique constraint violation (shouldn't happen due to check above, but just in case)
if (error instanceof Error && error.message.includes('Unique constraint')) {
return { success: false, error: 'Email already registered' }
}
return { success: false, error: 'Registration failed. Please try again.' }
}
}
/**
* Login user with email and password
* Requirements: 2.1, 2.2, 9.3 (rate limiting)
*/
export async function loginUser(
email: string,
password: string
): Promise<LoginResult> {
try {
// Rate limiting - Requirement 9.3
// Use email as identifier to prevent brute force attacks on specific accounts
const rateLimitKey = `login:${email.toLowerCase()}`
const rateLimit = checkRateLimit(rateLimitKey, LOGIN_RATE_LIMIT)
if (!rateLimit.allowed) {
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
return {
success: false,
error: `Too many login attempts. Please try again in ${minutesUntilReset} minutes.`
}
}
// Validate input (Requirement 2.1 - credential validation)
const validation = loginSchema.safeParse({ email, password })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { email: validEmail, password: validPassword } = validation.data
// Attempt to sign in (Requirement 2.1 - authenticate and create session)
// The authorize function in auth.ts handles:
// - Querying user from database
// - Password verification with bcrypt
// - Session creation on success
await signIn('credentials', {
email: validEmail,
password: validPassword,
redirect: false,
})
// If signIn doesn't throw an error, authentication was successful
// Reset rate limit on successful login
resetRateLimit(rateLimitKey)
return { success: true }
} catch (error) {
// Handle invalid credentials (Requirement 2.2)
// NextAuth throws an error for invalid credentials
console.error('Login error:', error)
// Return generic error message to avoid revealing whether email exists
return { success: false, error: 'Invalid email or password' }
}
}
/**
* Logout user and terminate session
* Requirement: 2.5 - Logout terminates session and clears authentication tokens
*/
export async function logoutUser(): Promise<void> {
try {
// Sign out using NextAuth - this will:
// 1. Clear the session token
// 2. Remove authentication cookies
// 3. Terminate the session
await signOut({ redirect: false })
} catch (error) {
console.error('Logout error:', error)
throw new Error('Logout failed')
}
}
/**
* Get current session
* Requirements: 3.2 - Session validation and persistence
*/
export async function getCurrentSession() {
try {
const { getServerSession } = await import('next-auth/next')
const { authOptions } = await import('@/pages/api/auth/[...nextauth]')
const session = await getServerSession(authOptions)
return session
} catch (error) {
console.error('Session retrieval error:', error)
return null
}
}
/**
* Validate if user is authenticated
* Requirements: 2.4, 3.3 - Session expiration and validation
*/
export async function isAuthenticated(): Promise<boolean> {
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<UpdateProfileResult> {
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<UpdatePasswordResult> {
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
}
}

View File

@@ -4,22 +4,8 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined
} }
// Create Prisma client with connection error handling
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
}) })
// Handle connection errors
prisma.$connect().catch((error) => {
console.error('Failed to connect to database:', error)
// Don't throw here - let individual queries handle connection errors
})
// Graceful shutdown
if (typeof window === 'undefined') {
process.on('beforeExit', async () => {
await prisma.$disconnect()
})
}
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

163
lib/migration-actions.ts Normal file
View File

@@ -0,0 +1,163 @@
'use server'
import { prisma } from './db'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
/**
* Migration utilities for transitioning existing anonymous lists to user-owned lists
* Requirements: 10.2, 10.3, 10.4, 10.5
*/
export type MigrationResult =
| { success: true; migratedCount: number }
| { success: false; error: string }
export type ClaimableList = {
id: string
title: string
color: string
itemCount: number
createdAt: Date
updatedAt: Date
}
/**
* Get lists that can be claimed by a new user
* This checks for lists in localStorage that exist in the database
* Requirement: 10.2 - Provide migration path for existing anonymous lists
*/
export async function getClaimableLists(listIds: string[]): Promise<ClaimableList[]> {
try {
if (!listIds || listIds.length === 0) {
return []
}
// Find lists that exist in the database
// Note: In a real migration scenario, we'd need a way to identify orphaned lists
// For now, we'll check if the provided list IDs exist
const lists = await prisma.list.findMany({
where: {
id: {
in: listIds,
},
},
include: {
_count: {
select: {
items: true,
},
},
},
})
return lists.map(list => ({
id: list.id,
title: list.title,
color: list.color,
itemCount: list._count.items,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
}))
} catch (error) {
console.error('Error fetching claimable lists:', error)
return []
}
}
/**
* Claim existing lists and associate them with the current user
* Requirements: 10.2, 10.3, 10.4, 10.5
*/
export async function claimLists(listIds: string[]): Promise<MigrationResult> {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized: Please log in to claim lists' }
}
const userId = session.user.id
if (!listIds || listIds.length === 0) {
return { success: true, migratedCount: 0 }
}
// Update all specified lists to be owned by the current user
// Requirement 10.3 - Preserve all list data including items, metadata, and timestamps
// Requirement 10.4 - Ensure all lists have an associated owner
// Use transaction to update each list individually to ensure proper type checking
let migratedCount = 0
for (const listId of listIds) {
try {
await prisma.list.update({
where: { id: listId },
data: { ownerId: userId },
})
migratedCount++
} catch (err) {
console.error(`Failed to claim list ${listId}:`, err)
// Continue with other lists
}
}
// Requirement 10.5 - Display migrated lists in user's account
// Lists will now appear in getLists() since they have the user's ownerId
return { success: true, migratedCount }
} catch (error) {
console.error('Error claiming lists:', error)
return { success: false, error: 'Failed to claim lists. Please try again.' }
}
}
/**
* Check if a user has any lists (to determine if they're a new user)
*/
export async function hasExistingLists(): Promise<boolean> {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return false
}
const count = await prisma.list.count({
where: {
ownerId: session.user.id,
},
})
return count > 0
} catch (error) {
console.error('Error checking existing lists:', error)
return false
}
}
/**
* Migrate all orphaned lists to a system user
* This is a one-time migration script for deployment
* Requirement: 10.1, 10.4 - Migration path and ensure all lists have owner
*/
export async function migrateOrphanedListsToSystemUser(): Promise<MigrationResult> {
try {
// First, check if system user exists
const systemUser = await prisma.user.findUnique({
where: { email: 'system@ultimatelists.app' },
})
if (!systemUser) {
return { success: false, error: 'System user does not exist. Run migration script first.' }
}
// This would migrate lists if ownerId was nullable
// Since our current schema requires ownerId, this is a placeholder
// In a real migration, you'd run this before making ownerId required
return { success: true, migratedCount: 0 }
} catch (error) {
console.error('Error migrating orphaned lists:', error)
return { success: false, error: 'Migration failed. Please contact support.' }
}
}

201
lib/notification-helpers.ts Normal file
View File

@@ -0,0 +1,201 @@
'use server';
import { sendPushNotification, notifyListCollaborators } from '@/lib/push-notifications';
import { prisma } from '@/lib/db';
/**
* Notify when an item is added to a shared list
*/
export async function notifyItemAdded(
listId: string,
itemTitle: string,
addedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const addedByUser = await prisma.user.findUnique({
where: { id: addedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `New item in "${list.title}"`,
body: `${addedByUser?.name || 'Someone'} added "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-added-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-added',
},
},
addedByUserId // Don't notify the person who added it
);
}
/**
* Notify when an item is removed from a shared list
*/
export async function notifyItemRemoved(
listId: string,
itemTitle: string,
removedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const removedByUser = await prisma.user.findUnique({
where: { id: removedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `Item removed from "${list.title}"`,
body: `${removedByUser?.name || 'Someone'} removed "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-removed-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-removed',
},
},
removedByUserId
);
}
/**
* Notify when an item is completed
*/
export async function notifyItemCompleted(
listId: string,
itemTitle: string,
completedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const completedByUser = await prisma.user.findUnique({
where: { id: completedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `Item completed in "${list.title}"`,
body: `${completedByUser?.name || 'Someone'} completed "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-completed-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-completed',
},
},
completedByUserId
);
}
/**
* Notify when invited to a list
*/
export async function notifyListInvitation(
invitationId: string,
inviteeEmail: string
) {
const invitation = await prisma.invitation.findUnique({
where: { id: invitationId },
include: {
list: true,
inviter: true,
},
});
if (!invitation) return;
// Find the invitee user
const invitee = await prisma.user.findUnique({
where: { email: inviteeEmail },
});
if (!invitee) return;
await sendPushNotification(invitee.id, {
title: 'New list invitation',
body: `${invitation.inviter.name || 'Someone'} invited you to "${invitation.list.title}"`,
icon: '/icon-192.svg',
tag: `invitation-${invitationId}`,
data: {
url: '/invitations',
action: 'invitation-received',
},
});
}
/**
* Notify when someone accepts your invitation
*/
export async function notifyInvitationAccepted(
invitationId: string
) {
const invitation = await prisma.invitation.findUnique({
where: { id: invitationId },
include: {
list: true,
receiver: true,
},
});
if (!invitation || !invitation.receiver) return;
await sendPushNotification(invitation.inviterId, {
title: 'Invitation accepted',
body: `${invitation.receiver.name || invitation.email} joined "${invitation.list.title}"`,
icon: '/icon-192.svg',
tag: `invitation-accepted-${invitationId}`,
data: {
url: `/list/${invitation.listId}`,
listId: invitation.listId,
action: 'invitation-accepted',
},
});
}
/**
* Notify when removed from a list
*/
export async function notifyRemovedFromList(
userId: string,
listTitle: string,
removedByUserId: string
) {
const removedByUser = await prisma.user.findUnique({
where: { id: removedByUserId },
});
await sendPushNotification(userId, {
title: 'Removed from list',
body: `${removedByUser?.name || 'The owner'} removed you from "${listTitle}"`,
icon: '/icon-192.svg',
tag: `removed-from-list`,
data: {
url: '/',
action: 'removed-from-list',
},
});
}

164
lib/permissions.ts Normal file
View File

@@ -0,0 +1,164 @@
'use server'
import { prisma } from './db'
export type Permission = 'owner' | 'editor' | 'viewer'
/**
* Check if a user can access a list (view it)
* Users can access a list if they are the owner or a collaborator
*
* @param userId - The ID of the user
* @param listId - The ID of the list
* @returns true if the user can access the list, false otherwise
*/
export async function canAccessList(
userId: string,
listId: string
): Promise<boolean> {
try {
if (!userId || !listId) {
return false
}
const list = await prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
})
return list !== null
} catch (error) {
console.error('Error checking list access:', error)
return false
}
}
/**
* Check if a user can edit a list or its items
* Users can edit if they are the owner or have editor permission
*
* @param userId - The ID of the user
* @param listId - The ID of the list
* @returns true if the user can edit the list, false otherwise
*/
export async function canEditList(
userId: string,
listId: string
): Promise<boolean> {
try {
if (!userId || !listId) {
return false
}
const list = await prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
permission: 'editor',
},
},
},
],
},
})
return list !== null
} catch (error) {
console.error('Error checking list edit permission:', error)
return false
}
}
/**
* Check if a user can delete a list
* Only the owner can delete a list
*
* @param userId - The ID of the user
* @param listId - The ID of the list
* @returns true if the user can delete the list, false otherwise
*/
export async function canDeleteList(
userId: string,
listId: string
): Promise<boolean> {
try {
if (!userId || !listId) {
return false
}
const list = await prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
return list !== null
} catch (error) {
console.error('Error checking list delete permission:', error)
return false
}
}
/**
* Get the permission level a user has for a list
*
* @param userId - The ID of the user
* @param listId - The ID of the list
* @returns The permission level ('owner', 'editor', 'viewer') or null if no access
*/
export async function getPermission(
userId: string,
listId: string
): Promise<Permission | null> {
try {
if (!userId || !listId) {
return null
}
// Check if user is the owner
const ownedList = await prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
if (ownedList) {
return 'owner'
}
// Check if user is a collaborator
const collaborator = await prisma.collaborator.findFirst({
where: {
userId: userId,
listId: listId,
},
})
if (collaborator) {
return collaborator.permission as Permission
}
return null
} catch (error) {
console.error('Error getting user permission:', error)
return null
}
}

191
lib/push-notifications.ts Normal file
View File

@@ -0,0 +1,191 @@
'use server';
import webpush from 'web-push';
import { prisma } from '@/lib/db';
// Configure web-push with VAPID keys
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) {
webpush.setVapidDetails(
process.env.VAPID_SUBJECT,
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
export interface PushNotificationPayload {
title: string;
body: string;
icon?: string;
badge?: string;
tag?: string;
data?: {
url?: string;
listId?: string;
itemId?: string;
action?: string;
};
}
/**
* Subscribe a user to push notifications
*/
export async function subscribeToPush(userId: string, subscription: PushSubscriptionJSON) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error('Invalid subscription object');
}
try {
await prisma.pushSubscription.upsert({
where: {
userId_endpoint: {
userId,
endpoint: subscription.endpoint,
},
},
create: {
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
update: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
updatedAt: new Date(),
},
});
return { success: true };
} catch (error) {
console.error('Failed to save push subscription:', error);
throw new Error('Failed to subscribe to push notifications');
}
}
/**
* Unsubscribe a user from push notifications
*/
export async function unsubscribeFromPush(userId: string, endpoint: string) {
try {
await prisma.pushSubscription.deleteMany({
where: {
userId,
endpoint,
},
});
return { success: true };
} catch (error) {
console.error('Failed to remove push subscription:', error);
throw new Error('Failed to unsubscribe from push notifications');
}
}
/**
* Send a push notification to a specific user
*/
export async function sendPushNotification(userId: string, payload: PushNotificationPayload) {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
console.warn('VAPID keys not configured, skipping push notification');
return { success: false, error: 'Push notifications not configured' };
}
try {
// Get all push subscriptions for the user
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId },
});
if (subscriptions.length === 0) {
return { success: true, sent: 0 };
}
// Send notification to all user's devices
const results = await Promise.allSettled(
subscriptions.map(async (sub) => {
const pushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
};
try {
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload),
{
TTL: 60 * 60 * 24, // 24 hours
}
);
return { success: true };
} catch (error: any) {
// Handle expired or invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expired, remove it
await prisma.pushSubscription.delete({
where: { id: sub.id },
});
}
throw error;
}
})
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
return { success: true, sent: successful, total: subscriptions.length };
} catch (error) {
console.error('Failed to send push notification:', error);
return { success: false, error: 'Failed to send notification' };
}
}
/**
* Send notification to all collaborators of a list
*/
export async function notifyListCollaborators(
listId: string,
payload: PushNotificationPayload,
excludeUserId?: string
) {
try {
// Get list owner and collaborators
const list = await prisma.list.findUnique({
where: { id: listId },
include: {
collaborators: {
select: { userId: true },
},
},
});
if (!list) {
return { success: false, error: 'List not found' };
}
// Collect all user IDs (owner + collaborators)
const userIds = [
list.ownerId,
...list.collaborators.map((c) => c.userId),
].filter((id) => id !== excludeUserId); // Exclude the user who triggered the action
// Send notifications to all users
const results = await Promise.allSettled(
userIds.map((userId) => sendPushNotification(userId, payload))
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
return { success: true, notified: successful, total: userIds.length };
} catch (error) {
console.error('Failed to notify list collaborators:', error);
return { success: false, error: 'Failed to send notifications' };
}
}
/**
* Get VAPID public key for client-side subscription
*/
export async function getVapidPublicKey() {
return process.env.VAPID_PUBLIC_KEY || null;
}

189
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* Rate limiting utilities for security
* Implements in-memory rate limiting for login attempts and other sensitive operations
*
* Requirements: 9.3 - Rate limiting on authentication requests
*/
interface RateLimitEntry {
count: number
resetAt: number
}
// In-memory store for rate limiting
// In production, consider using Redis or similar for distributed systems
const rateLimitStore = new Map<string, RateLimitEntry>()
// Cleanup interval to prevent memory leaks
const CLEANUP_INTERVAL = 60 * 60 * 1000 // 1 hour
setInterval(() => {
const now = Date.now()
const entries = Array.from(rateLimitStore.entries())
for (const [key, entry] of entries) {
if (entry.resetAt < now) {
rateLimitStore.delete(key)
}
}
}, CLEANUP_INTERVAL)
export interface RateLimitConfig {
/**
* Maximum number of requests allowed within the window
*/
maxRequests: number
/**
* Time window in milliseconds
*/
windowMs: number
/**
* Optional identifier (e.g., IP address, user ID)
* If not provided, uses a default key
*/
identifier?: string
}
export interface RateLimitResult {
/**
* Whether the request is allowed
*/
allowed: boolean
/**
* Number of requests remaining in the current window
*/
remaining: number
/**
* Timestamp when the rate limit will reset
*/
resetAt: number
/**
* Time in milliseconds until the rate limit resets
*/
retryAfter?: number
}
/**
* Check if a request is allowed based on rate limiting rules
*
* @param key - Unique identifier for the rate limit (e.g., "login:user@example.com" or "login:192.168.1.1")
* @param config - Rate limit configuration
* @returns Rate limit result indicating if request is allowed
*/
export function checkRateLimit(
key: string,
config: RateLimitConfig
): RateLimitResult {
const now = Date.now()
const entry = rateLimitStore.get(key)
// If no entry exists or the window has expired, create a new entry
if (!entry || entry.resetAt < now) {
const resetAt = now + config.windowMs
rateLimitStore.set(key, {
count: 1,
resetAt,
})
return {
allowed: true,
remaining: config.maxRequests - 1,
resetAt,
}
}
// Check if limit has been exceeded
if (entry.count >= config.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
retryAfter: entry.resetAt - now,
}
}
// Increment count and allow request
entry.count++
rateLimitStore.set(key, entry)
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetAt: entry.resetAt,
}
}
/**
* Reset rate limit for a specific key
* Useful for clearing rate limits after successful authentication
*
* @param key - Unique identifier for the rate limit
*/
export function resetRateLimit(key: string): void {
rateLimitStore.delete(key)
}
/**
* Rate limit configuration for login attempts
* Allows 5 login attempts per 15 minutes per email address
*/
export const LOGIN_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
maxRequests: 5,
windowMs: 15 * 60 * 1000, // 15 minutes
}
/**
* Rate limit configuration for registration attempts
* Allows 3 registration attempts per hour per IP address
*/
export const REGISTER_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
maxRequests: 3,
windowMs: 60 * 60 * 1000, // 1 hour
}
/**
* Rate limit configuration for password reset attempts
* Allows 3 password reset attempts per hour per email
*/
export const PASSWORD_RESET_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
maxRequests: 3,
windowMs: 60 * 60 * 1000, // 1 hour
}
/**
* Rate limit configuration for invitation creation
* Allows 10 invitations per hour per user
*/
export const INVITATION_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
maxRequests: 10,
windowMs: 60 * 60 * 1000, // 1 hour
}
/**
* Get client identifier from request headers
* Uses X-Forwarded-For header if available, otherwise falls back to a default
*
* @param headers - Request headers
* @returns Client identifier (IP address or default)
*/
export function getClientIdentifier(headers: Headers): string {
// Try to get IP from X-Forwarded-For header (for proxied requests)
const forwardedFor = headers.get('x-forwarded-for')
if (forwardedFor) {
// X-Forwarded-For can contain multiple IPs, use the first one
return forwardedFor.split(',')[0].trim()
}
// Try to get IP from X-Real-IP header
const realIp = headers.get('x-real-ip')
if (realIp) {
return realIp
}
// Fallback to a default identifier
// In production, you might want to handle this differently
return 'unknown'
}

664
lib/sharing-actions.ts Normal file
View File

@@ -0,0 +1,664 @@
'use server'
import { prisma } from './db'
import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { z } from 'zod'
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
import {
notifyListInvitation,
notifyInvitationAccepted,
notifyRemovedFromList,
} from './notification-helpers'
// Validation schemas
const inviteSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
permission: z.enum(['editor', 'viewer'], { message: 'Permission must be either editor or viewer' }),
})
// Retry utility for database operations
async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | undefined
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation()
} catch (error) {
lastError = error as Error
// Don't retry on validation errors or not found errors
if (
error instanceof Error &&
(error.message.includes('Unique constraint') ||
error.message.includes('Record to update not found') ||
error.message.includes('Record to delete does not exist'))
) {
throw error
}
// Wait before retrying (exponential backoff)
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, attempt)))
}
}
}
throw lastError
}
/**
* Invite a user to collaborate on a list
* Requirements: 5.1, 6.1
*/
export async function inviteUserToList(
listId: string,
email: string,
permission: 'editor' | 'viewer'
) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to invite users')
}
const userId = session.user.id
// Rate limiting - Requirement 9.3
const rateLimitKey = `invite:${userId}`
const rateLimit = checkRateLimit(rateLimitKey, INVITATION_RATE_LIMIT)
if (!rateLimit.allowed) {
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
throw new Error(`Too many invitation attempts. Please try again in ${minutesUntilReset} minutes.`)
}
// Validate input
if (!listId) {
throw new Error('List ID is required')
}
const validation = inviteSchema.safeParse({ email, permission })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
throw new Error(errors)
}
const { email: validEmail, permission: validPermission } = validation.data
// Check if user is the owner of the list
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to invite users')
}
// Check if the invited email is the owner's email
if (validEmail === session.user.email) {
throw new Error('You cannot invite yourself to your own list')
}
// Check if user is already a collaborator
const existingCollaborator = await retryOperation(() =>
prisma.collaborator.findFirst({
where: {
listId: listId,
user: {
email: validEmail,
},
},
})
)
if (existingCollaborator) {
throw new Error('User is already a collaborator on this list')
}
// Check if there's already a pending invitation
const existingInvitation = await retryOperation(() =>
prisma.invitation.findFirst({
where: {
listId: listId,
email: validEmail,
status: 'pending',
},
})
)
if (existingInvitation) {
throw new Error('An invitation has already been sent to this email')
}
// Create invitation with expiration (7 days from now)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
const invitation = await retryOperation(() =>
prisma.invitation.create({
data: {
email: validEmail,
listId: listId,
inviterId: userId,
permission: validPermission,
expiresAt: expiresAt,
},
include: {
list: true,
inviter: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
)
// Send notification to the invitee
try {
await notifyListInvitation(invitation.id, validEmail)
} catch (error) {
console.error('Failed to send invitation notification:', error)
// Don't fail the action if notification fails
}
revalidatePath(`/list/${listId}`)
return invitation
} catch (error) {
console.error('Error inviting user:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('Invalid') ||
error.message.includes('Unauthorized') ||
error.message.includes('permission') ||
error.message.includes('already') ||
error.message.includes('cannot')
)) {
throw error
}
throw new Error('Failed to send invitation. Please try again.')
}
}
/**
* Get pending invitations for the current user
* Requirements: 8.1, 8.2
*/
export async function getInvitations() {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to view invitations')
}
const userEmail = session.user.email
// Get all pending invitations for this user's email
const invitations = await retryOperation(() =>
prisma.invitation.findMany({
where: {
email: userEmail,
status: 'pending',
expiresAt: {
gt: new Date(), // Only get non-expired invitations
},
},
include: {
list: true,
inviter: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
)
return invitations
} catch (error) {
console.error('Error fetching invitations:', error)
if (error instanceof Error && error.message.includes('Unauthorized')) {
throw error
}
throw new Error('Failed to load invitations. Please try again.')
}
}
/**
* Accept an invitation and become a collaborator
* Requirements: 5.2
*/
export async function acceptInvitation(invitationId: string) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to accept invitations')
}
const userId = session.user.id
const userEmail = session.user.email
if (!invitationId) {
throw new Error('Invitation ID is required')
}
// Get the invitation
const invitation = await retryOperation(() =>
prisma.invitation.findUnique({
where: { id: invitationId },
include: { list: true },
})
)
if (!invitation) {
throw new Error('Invitation not found')
}
// Verify the invitation is for this user
if (invitation.email !== userEmail) {
throw new Error('This invitation is not for you')
}
// Check if invitation is still pending
if (invitation.status !== 'pending') {
throw new Error('This invitation has already been processed')
}
// Check if invitation has expired
if (invitation.expiresAt < new Date()) {
throw new Error('This invitation has expired')
}
// Check if user is already a collaborator (shouldn't happen, but just in case)
const existingCollaborator = await retryOperation(() =>
prisma.collaborator.findFirst({
where: {
userId: userId,
listId: invitation.listId,
},
})
)
if (existingCollaborator) {
// Update invitation status and return
await retryOperation(() =>
prisma.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
})
)
throw new Error('You are already a collaborator on this list')
}
// Create collaborator and update invitation status in a transaction
const result = await retryOperation(() =>
prisma.$transaction(async (tx) => {
// Create collaborator
const collaborator = await tx.collaborator.create({
data: {
userId: userId,
listId: invitation.listId,
permission: invitation.permission,
},
})
// Update invitation status
await tx.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
})
return collaborator
})
)
// Notify the person who sent the invitation
try {
await notifyInvitationAccepted(invitationId)
} catch (error) {
console.error('Failed to send acceptance notification:', error)
// Don't fail the action if notification fails
}
revalidatePath('/')
revalidatePath(`/list/${invitation.listId}`)
return result
} catch (error) {
console.error('Error accepting invitation:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('not found') ||
error.message.includes('Unauthorized') ||
error.message.includes('not for you') ||
error.message.includes('already') ||
error.message.includes('expired')
)) {
throw error
}
throw new Error('Failed to accept invitation. Please try again.')
}
}
/**
* Decline an invitation
* Requirements: 5.3
*/
export async function declineInvitation(invitationId: string) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to decline invitations')
}
const userEmail = session.user.email
if (!invitationId) {
throw new Error('Invitation ID is required')
}
// Get the invitation
const invitation = await retryOperation(() =>
prisma.invitation.findUnique({
where: { id: invitationId },
})
)
if (!invitation) {
throw new Error('Invitation not found')
}
// Verify the invitation is for this user
if (invitation.email !== userEmail) {
throw new Error('This invitation is not for you')
}
// Check if invitation is still pending
if (invitation.status !== 'pending') {
throw new Error('This invitation has already been processed')
}
// Delete the invitation (as per requirement 5.3 - remove the invitation record)
await retryOperation(() =>
prisma.invitation.delete({
where: { id: invitationId },
})
)
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error declining invitation:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('not found') ||
error.message.includes('Unauthorized') ||
error.message.includes('not for you') ||
error.message.includes('already')
)) {
throw error
}
throw new Error('Failed to decline invitation. Please try again.')
}
}
/**
* Get all collaborators for a list
* Requirements: 5.4
*/
export async function getCollaborators(listId: string) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view collaborators')
}
const userId = session.user.id
if (!listId) {
throw new Error('List ID is required')
}
// Check if user has access to the list
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to view collaborators')
}
// Get all collaborators
const collaborators = await retryOperation(() =>
prisma.collaborator.findMany({
where: {
listId: listId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
})
)
return collaborators
} catch (error) {
console.error('Error fetching collaborators:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('Unauthorized') ||
error.message.includes('permission')
)) {
throw error
}
throw new Error('Failed to load collaborators. Please try again.')
}
}
/**
* Update a collaborator's permission level
* Requirements: 6.5
*/
export async function updateCollaboratorPermission(
listId: string,
collaboratorId: string,
permission: 'editor' | 'viewer'
) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update permissions')
}
const userId = session.user.id
if (!listId || !collaboratorId) {
throw new Error('List ID and collaborator ID are required')
}
const validation = z.enum(['editor', 'viewer']).safeParse(permission)
if (!validation.success) {
throw new Error('Permission must be either editor or viewer')
}
// Check if user is the owner of the list
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to update permissions')
}
// Update the collaborator's permission
const updated = await retryOperation(() =>
prisma.collaborator.update({
where: {
id: collaboratorId,
listId: listId, // Ensure the collaborator belongs to this list
},
data: {
permission: permission,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
)
revalidatePath(`/list/${listId}`)
return updated
} catch (error) {
console.error('Error updating collaborator permission:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('Unauthorized') ||
error.message.includes('permission') ||
error.message.includes('not found')
)) {
throw error
}
throw new Error('Failed to update permission. Please try again.')
}
}
/**
* Remove a collaborator from a list
* Requirements: 5.5
*/
export async function removeCollaborator(
listId: string,
collaboratorId: string
) {
try {
// Get current user session
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to remove collaborators')
}
const userId = session.user.id
if (!listId || !collaboratorId) {
throw new Error('List ID and collaborator ID are required')
}
// Check if user is the owner of the list
const list = await retryOperation(() =>
prisma.list.findFirst({
where: {
id: listId,
ownerId: userId,
},
})
)
if (!list) {
throw new Error('List not found or you do not have permission to remove collaborators')
}
// Get collaborator details before deleting
const collaborator = await retryOperation(() =>
prisma.collaborator.findUnique({
where: { id: collaboratorId },
include: {
user: {
select: {
id: true,
},
},
},
})
)
// Delete the collaborator
await retryOperation(() =>
prisma.collaborator.delete({
where: {
id: collaboratorId,
listId: listId, // Ensure the collaborator belongs to this list
},
})
)
// Notify the removed user
if (collaborator) {
try {
await notifyRemovedFromList(collaborator.user.id, list.title, userId)
} catch (error) {
console.error('Failed to send removal notification:', error)
// Don't fail the action if notification fails
}
}
revalidatePath(`/list/${listId}`)
return { success: true }
} catch (error) {
console.error('Error removing collaborator:', error)
if (error instanceof Error && (
error.message.includes('required') ||
error.message.includes('Unauthorized') ||
error.message.includes('permission') ||
error.message.includes('not found')
)) {
throw error
}
throw new Error('Failed to remove collaborator. Please try again.')
}
}

View File

@@ -1,6 +1,8 @@
/** /**
* Input validation utilities for the Ultimate Lists App * Input validation utilities for the Ultimate Lists App
* Provides validation functions and sanitization for user inputs * Provides validation functions and sanitization for user inputs
*
* Requirements: 9.5 - Input sanitization for all user inputs
*/ */
export interface ValidationResult { export interface ValidationResult {
@@ -9,13 +11,45 @@ export interface ValidationResult {
} }
/** /**
* Sanitize string input by trimming whitespace * Sanitize string input by trimming whitespace and removing potentially dangerous characters
* Requirement 9.5 - Input sanitization
*/ */
export function sanitizeString(input: string | null | undefined): string { export function sanitizeString(input: string | null | undefined): string {
if (input === null || input === undefined) { if (input === null || input === undefined) {
return '' return ''
} }
return input.trim()
// Trim whitespace
let sanitized = input.trim()
// Remove null bytes that could cause issues
sanitized = sanitized.replace(/\0/g, '')
// Remove control characters except newlines and tabs
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
return sanitized
}
/**
* Sanitize email input
* Requirement 9.5 - Input sanitization
*/
export function sanitizeEmail(email: string | null | undefined): string {
if (email === null || email === undefined) {
return ''
}
// Convert to lowercase and trim
let sanitized = email.toLowerCase().trim()
// Remove any whitespace within the email
sanitized = sanitized.replace(/\s/g, '')
// Remove null bytes and control characters
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '')
return sanitized
} }
/** /**
@@ -182,10 +216,59 @@ export function validateNotes(notes: string | null | undefined): ValidationResul
/** /**
* Sanitize HTML to prevent XSS attacks * Sanitize HTML to prevent XSS attacks
* Basic implementation - strips HTML tags * Strips HTML tags and dangerous characters
* Requirement 9.5 - Input sanitization to prevent XSS
*/ */
export function sanitizeHtml(input: string): string { export function sanitizeHtml(input: string): string {
return input.replace(/<[^>]*>/g, '') if (!input) return ''
let sanitized = input
// First, remove script tags and their content (before removing other tags)
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove event handlers (onclick, onerror, etc.)
sanitized = sanitized.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
sanitized = sanitized.replace(/on\w+\s*=\s*[^\s>]*/gi, '')
// Remove javascript: protocol
sanitized = sanitized.replace(/javascript:/gi, '')
// Remove data: protocol (can be used for XSS)
sanitized = sanitized.replace(/data:text\/html/gi, '')
// Remove all HTML tags
sanitized = sanitized.replace(/<[^>]*>/g, '')
// Encode special HTML characters (after removing tags)
sanitized = sanitized
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;')
return sanitized
}
/**
* Sanitize SQL input to prevent SQL injection
* Note: This is a defense-in-depth measure. Primary protection comes from parameterized queries.
* Requirement 9.5 - Input sanitization
*/
export function sanitizeSqlInput(input: string): string {
if (!input) return ''
// Remove SQL comment markers
let sanitized = input.replace(/--/g, '')
sanitized = sanitized.replace(/\/\*/g, '')
sanitized = sanitized.replace(/\*\//g, '')
// Remove semicolons that could terminate statements
sanitized = sanitized.replace(/;/g, '')
return sanitized
} }
/** /**
@@ -301,3 +384,47 @@ export function validateItemData(data: {
}, },
} }
} }
/**
* Validate email format
* Requirement 9.5 - Input validation and sanitization
*/
export function validateEmail(email: string): ValidationResult {
const sanitized = sanitizeEmail(email)
if (!sanitized) {
return { isValid: false, error: 'Email is required' }
}
// Basic email regex pattern
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(sanitized)) {
return { isValid: false, error: 'Invalid email format' }
}
if (sanitized.length > 255) {
return { isValid: false, error: 'Email must be less than 255 characters' }
}
return { isValid: true }
}
/**
* Validate password strength
*/
export function validatePassword(password: string): ValidationResult {
if (!password) {
return { isValid: false, error: 'Password is required' }
}
if (password.length < 8) {
return { isValid: false, error: 'Password must be at least 8 characters' }
}
if (password.length > 128) {
return { isValid: false, error: 'Password must be less than 128 characters' }
}
return { isValid: true }
}

View File

@@ -8,16 +8,18 @@ const nextConfig = {
exclude: ['error', 'warn'], exclude: ['error', 'warn'],
} : false, } : false,
}, },
// Enable SWC minification for better performance
swcMinify: true, swcMinify: true,
// Optimize images
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
}, },
// Enable compression
compress: true, compress: true,
// Production source maps (smaller)
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
trailingSlash: false,
poweredByHeader: false,
// Optimize for mobile Safari
reactStrictMode: true,
// Enable optimized font loading
optimizeFonts: true,
} }
module.exports = nextConfig module.exports = nextConfig

1423
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,29 +7,43 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "vitest --run",
"test:watch": "vitest",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:migrate": "npx tsx scripts/migrate-lists.ts",
"icons:generate": "node scripts/generate-icons.js", "icons:generate": "node scripts/generate-icons.js",
"icons:info": "node scripts/svg-to-png.js" "icons:info": "node scripts/svg-to-png.js",
"vapid:generate": "node scripts/generate-vapid-keys.js"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/client": "^5.7.1", "@prisma/client": "^5.7.1",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"next": "14.0.4", "next": "14.0.4",
"next-auth": "^4.24.5",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"web-push": "^3.6.7",
"zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/web-push": "^3.6.3",
"@vitest/ui": "^4.0.14",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.0.4", "eslint-config-next": "14.0.4",
"postcss": "^8", "postcss": "^8",
"prisma": "^5.7.1", "prisma": "^5.7.1",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"typescript": "^5" "tsx": "^4.7.0",
"typescript": "^5",
"vitest": "^4.0.14"
} }
} }

View File

@@ -0,0 +1,54 @@
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@/lib/db"
import bcrypt from "bcryptjs"
import { z } from "zod"
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
export const authOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
const { email, password } = loginSchema.parse(credentials)
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.passwordHash) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) return null
return { id: user.id, email: user.email, name: user.name }
} catch {
return null
}
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
error: "/auth/error",
},
callbacks: {
async jwt({ token, user }) {
if (user) token.id = user.id
return token
},
async session({ session, token }) {
if (token.id) session.user.id = token.id
return session
},
},
}
export default NextAuth(authOptions)

View File

@@ -7,13 +7,86 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownedLists List[] @relation("ListOwner")
collaborations Collaborator[]
sentInvitations Invitation[] @relation("InvitationInviter")
receivedInvitations Invitation[] @relation("InvitationReceiver")
pushSubscriptions PushSubscription[]
@@index([email])
}
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @db.VarChar(512)
p256dh String @db.Text
auth String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([userId])
}
model List { model List {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
color String @default("#3B82F6") color String @default("#3B82F6")
createdAt DateTime @default(now()) ownerId String
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
items Item[] updatedAt DateTime @updatedAt
owner User @relation("ListOwner", fields: [ownerId], references: [id], onDelete: Cascade)
items Item[]
collaborators Collaborator[]
invitations Invitation[]
@@index([ownerId])
@@index([ownerId, createdAt])
}
model Collaborator {
id String @id @default(cuid())
userId String
listId String
permission String // 'editor' or 'viewer'
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
@@unique([userId, listId])
@@index([userId])
@@index([listId])
@@index([listId, permission])
}
model Invitation {
id String @id @default(cuid())
email String
listId String
inviterId String
permission String // 'editor' or 'viewer'
status String @default("pending") // 'pending', 'accepted', 'declined'
createdAt DateTime @default(now())
expiresAt DateTime
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
inviter User @relation("InvitationInviter", fields: [inviterId], references: [id], onDelete: Cascade)
receiver User? @relation("InvitationReceiver", fields: [email], references: [email], onDelete: Cascade)
@@index([email])
@@index([listId])
@@index([inviterId])
@@index([status])
@@index([email, status])
@@index([listId, status])
} }
model Item { model Item {

View File

@@ -1,7 +1,8 @@
{ {
"name": "Ultimate Lists", "name": "Ultimate Lists",
"short_name": "Lists", "short_name": "Lists",
"description": "The ultimate todo list app with advanced task management", "description": "The ultimate todo list app with advanced task management and continuous task input",
"version": "1.0.2",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",

View File

@@ -1,13 +1,16 @@
// Service Worker for Ultimate Lists App // Service Worker for Ultimate Lists App
// Implements caching strategies for offline functionality // Implements caching strategies for offline functionality
const CACHE_NAME = 'ultimate-lists-v1'; const CACHE_NAME = 'ultimate-lists-v8';
const RUNTIME_CACHE = 'ultimate-lists-runtime-v1'; const RUNTIME_CACHE = 'ultimate-lists-runtime-v8';
// Static assets to cache on install // Static assets to cache on install
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/offline', '/offline',
'/auth/login',
'/auth/register',
'/auth/error',
'/manifest.json', '/manifest.json',
'/icon-192.svg', '/icon-192.svg',
'/icon-512.svg', '/icon-512.svg',
@@ -57,6 +60,17 @@ self.addEventListener('fetch', (event) => {
if (!url.protocol.startsWith('http')) { if (!url.protocol.startsWith('http')) {
return; return;
} }
// Skip cross-origin requests for performance
if (url.origin !== self.location.origin) {
return;
}
// Handle authentication-related requests
if (isAuthRequest(url)) {
event.respondWith(handleAuthRequest(request));
return;
}
// Cache-first strategy for static assets // Cache-first strategy for static assets
if (isStaticAsset(url)) { if (isStaticAsset(url)) {
@@ -90,6 +104,58 @@ function isPageRequest(url) {
return url.origin === self.location.origin && !url.pathname.includes('.'); return url.origin === self.location.origin && !url.pathname.includes('.');
} }
// Helper: Check if request is authentication-related
function isAuthRequest(url) {
return url.pathname.startsWith('/auth/') ||
url.pathname.startsWith('/api/auth/');
}
// Strategy: Handle authentication requests
async function handleAuthRequest(request) {
const url = new URL(request.url);
// For authentication API calls, always try network first
if (url.pathname.startsWith('/api/auth/')) {
try {
const response = await fetch(request);
return response;
} catch (error) {
console.error('Authentication API request failed (offline):', error);
// Return a JSON error response for API calls
return new Response(
JSON.stringify({
error: 'Authentication requires an internet connection. Please check your connection and try again.'
}),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
}
}
// For authentication pages, use cache-first with network fallback
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.error('Authentication page request failed:', error);
// Return cached version if available
if (cached) {
return cached;
}
// Otherwise redirect to offline page
return caches.match('/offline');
}
}
// Strategy: Cache-first (for static assets) // Strategy: Cache-first (for static assets)
async function cacheFirst(request) { async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(CACHE_NAME);
@@ -117,6 +183,12 @@ async function staleWhileRevalidate(request) {
const cached = await cache.match(request); const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => { const fetchPromise = fetch(request).then((response) => {
// Handle authentication errors (401, 403)
if (response.status === 401 || response.status === 403) {
// Don't cache authentication errors
return response;
}
if (response.ok) { if (response.ok) {
cache.put(request, response.clone()); cache.put(request, response.clone());
} }
@@ -141,6 +213,13 @@ async function staleWhileRevalidate(request) {
async function networkFirst(request) { async function networkFirst(request) {
try { try {
const response = await fetch(request); const response = await fetch(request);
// Handle authentication errors (401, 403)
if (response.status === 401 || response.status === 403) {
// Don't cache authentication errors
return response;
}
if (response.ok) { if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE); const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, response.clone()); cache.put(request, response.clone());
@@ -201,3 +280,53 @@ self.addEventListener('message', (event) => {
} }
} }
}); });
// Push notification event - receive and display notifications
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
try {
const data = event.data.json();
const { title, body, icon, badge, tag, data: notificationData } = data;
const options = {
body,
icon: icon || '/icon-192.svg',
badge: badge || '/icon-192.svg',
tag: tag || 'default',
data: notificationData,
vibrate: [200, 100, 200],
requireInteraction: false,
};
event.waitUntil(
self.registration.showNotification(title, options)
);
} catch (error) {
console.error('Failed to show notification:', error);
}
});
// Notification click event - handle user interaction
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open a new window if none exists
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
/**
* Generate VAPID keys for Web Push notifications
* Run: node scripts/generate-vapid-keys.js
*/
const webpush = require('web-push');
console.log('Generating VAPID keys for Web Push notifications...\n');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('✓ VAPID keys generated successfully!\n');
console.log('Add these to your .env file:\n');
console.log('# Web Push Notifications (VAPID)');
console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
console.log('VAPID_SUBJECT=mailto:your-email@example.com');
console.log('\n⚠️ Keep the private key secret! Do not commit it to version control.\n');

110
scripts/migrate-lists.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Migration script for existing anonymous lists
* This script should be run during deployment to ensure all lists have an owner
*
* Requirements: 10.1, 10.2, 10.3, 10.4
*
* Usage:
* npx tsx scripts/migrate-lists.ts
*/
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('Starting list migration...')
try {
// Step 1: Check if system user exists
console.log('Checking for system user...')
let systemUser = await prisma.user.findUnique({
where: { email: 'system@ultimatelists.app' },
})
// Step 2: Create system user if it doesn't exist
if (!systemUser) {
console.log('Creating system user...')
const passwordHash = await bcrypt.hash('system-user-no-login-' + Date.now(), 10)
systemUser = await prisma.user.create({
data: {
email: 'system@ultimatelists.app',
name: 'System',
passwordHash,
},
})
console.log(`System user created with ID: ${systemUser.id}`)
} else {
console.log(`System user already exists with ID: ${systemUser.id}`)
}
// Step 3: Find lists without owners (if ownerId was nullable)
// Note: In the current schema, ownerId is required, so this is for reference
// If you're migrating from a schema where ownerId was nullable, uncomment this:
/*
console.log('Finding orphaned lists...')
const orphanedLists = await prisma.list.findMany({
where: {
ownerId: null,
},
})
console.log(`Found ${orphanedLists.length} orphaned lists`)
// Step 4: Assign orphaned lists to system user
if (orphanedLists.length > 0) {
console.log('Migrating orphaned lists to system user...')
const result = await prisma.list.updateMany({
where: {
ownerId: null,
},
data: {
ownerId: systemUser.id,
},
})
console.log(`Migrated ${result.count} lists to system user`)
}
*/
// Step 5: Verify all lists have owners
console.log('Verifying all lists have owners...')
const totalLists = await prisma.list.count()
// Since ownerId is required in the schema, all lists will have owners
const listsWithOwners = totalLists
console.log(`Total lists: ${totalLists}`)
console.log(`Lists with owners: ${listsWithOwners}`)
if (totalLists === listsWithOwners) {
console.log('✓ All lists have owners')
} else {
console.warn(`⚠ Warning: ${totalLists - listsWithOwners} lists without owners`)
}
// Step 6: Display migration summary
console.log('\nMigration Summary:')
console.log('==================')
console.log(`System user: ${systemUser.email} (${systemUser.id})`)
console.log(`Total lists: ${totalLists}`)
console.log(`Lists with owners: ${listsWithOwners}`)
const users = await prisma.user.count()
console.log(`Total users: ${users}`)
console.log('\nMigration completed successfully!')
} catch (error) {
console.error('Migration failed:', error)
throw error
} finally {
await prisma.$disconnect()
}
}
main()
.catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})

21
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
} & DefaultSession["user"]
}
interface User {
id: string
email: string
name: string | null
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
}
}

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: [],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
conditions: ['node', 'default'],
},
})