From 0bb293e1018c4c88aa5638042db1d233d939835d Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 00:48:47 +0000 Subject: [PATCH] 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 --- .kiro/specs/user-auth-and-sharing/tasks.md | 2 +- app/list/[id]/item/[itemId]/edit/page.tsx | 2 + app/list/[id]/item/[itemId]/page.tsx | 2 + app/list/[id]/page.tsx | 2 + app/page.tsx | 2 + lib/actions.ts | 427 +++++++++++++++++++-- 6 files changed, 408 insertions(+), 29 deletions(-) diff --git a/.kiro/specs/user-auth-and-sharing/tasks.md b/.kiro/specs/user-auth-and-sharing/tasks.md index d13e423..83a4925 100644 --- a/.kiro/specs/user-auth-and-sharing/tasks.md +++ b/.kiro/specs/user-auth-and-sharing/tasks.md @@ -96,7 +96,7 @@ - Redirect unauthenticated users to login - _Requirements: 3.3, 3.5_ -- [ ] 8. Update list operations for ownership +- [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 diff --git a/app/list/[id]/item/[itemId]/edit/page.tsx b/app/list/[id]/item/[itemId]/edit/page.tsx index 4cccfc4..ad4a7fe 100644 --- a/app/list/[id]/item/[itemId]/edit/page.tsx +++ b/app/list/[id]/item/[itemId]/edit/page.tsx @@ -4,6 +4,8 @@ import ItemForm from '@/components/ItemForm' import Link from 'next/link' import { ArrowLeft } from 'lucide-react' +export const dynamic = 'force-dynamic' + interface PageProps { params: { id: string diff --git a/app/list/[id]/item/[itemId]/page.tsx b/app/list/[id]/item/[itemId]/page.tsx index bf4787e..498eb55 100644 --- a/app/list/[id]/item/[itemId]/page.tsx +++ b/app/list/[id]/item/[itemId]/page.tsx @@ -6,6 +6,8 @@ import { ArrowLeft, Edit } from 'lucide-react' import DeleteItemButton from '@/components/DeleteItemButton' import ToggleItemButton from '@/components/ToggleItemButton' +export const dynamic = 'force-dynamic' + interface PageProps { params: { id: string diff --git a/app/list/[id]/page.tsx b/app/list/[id]/page.tsx index 8b20fe7..0637101 100644 --- a/app/list/[id]/page.tsx +++ b/app/list/[id]/page.tsx @@ -5,6 +5,8 @@ import { ArrowLeft } from 'lucide-react' import InlineItemCreate from '@/components/InlineItemCreate' import ListDetailView from '@/components/ListDetailView' +export const dynamic = 'force-dynamic' + export default async function ListDetailPage({ params }: { params: { id: string } }) { const list = await getListWithItems(params.id) diff --git a/app/page.tsx b/app/page.tsx index 8acda24..695034d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,6 +4,8 @@ import NewListButton from '@/components/NewListButton' import GlobalSearch from '@/components/GlobalSearch' import EmptyState from '@/components/EmptyState' +export const dynamic = 'force-dynamic' + export default async function Home() { const lists = await getLists() diff --git a/lib/actions.ts b/lib/actions.ts index f15b8f3..93c77f9 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -2,6 +2,7 @@ import { prisma } from './db' import { revalidatePath } from 'next/cache' +import { auth } from '@/auth' import { validateListTitle, validateItemTitle, @@ -47,10 +48,49 @@ async function retryOperation( export async function getLists() { try { + // Get current user session + const session = await auth() + 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(() => prisma.list.findMany({ + where: { + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, include: { items: true, + owner: { + select: { + id: true, + name: true, + email: true, + }, + }, + collaborators: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, }, orderBy: { updatedAt: 'desc', @@ -59,12 +99,23 @@ export async function getLists() { ) } catch (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.') } } export async function createList(title: string, color: string = '#3B82F6') { try { + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to create lists') + } + + const userId = session.user.id + // Validate and sanitize input const titleResult = validateListTitle(title) if (!titleResult.isValid) { @@ -83,6 +134,7 @@ export async function createList(title: string, color: string = '#3B82F6') { data: { title: sanitizedTitle, color, + ownerId: userId, }, }) ) @@ -91,7 +143,7 @@ export async function createList(title: string, color: string = '#3B82F6') { return list } catch (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 new Error('Failed to create list. Please try again.') @@ -104,27 +156,65 @@ export async function getListWithItems(listId: string) { throw new Error('List ID is required') } + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to view lists') + } + + const userId = session.user.id + const list = await retryOperation(() => - prisma.list.findUnique({ - where: { id: listId }, + prisma.list.findFirst({ + where: { + id: listId, + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, include: { items: { orderBy: { createdAt: 'desc', }, }, + owner: { + select: { + id: true, + name: true, + email: true, + }, + }, + collaborators: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, }, }) ) if (!list) { - throw new Error('List not found') + throw new Error('List not found or access denied') } return list } catch (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 new Error('Failed to load list. Please try again.') @@ -137,11 +227,40 @@ export async function getItem(itemId: string) { throw new Error('Item ID is required') } + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to view items') + } + + const userId = session.user.id + const item = await retryOperation(() => prisma.item.findUnique({ where: { id: itemId }, 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 +269,18 @@ export async function getItem(itemId: string) { 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 } catch (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 new Error('Failed to load item. Please try again.') @@ -177,6 +304,38 @@ export async function createItem( throw new Error('List ID is required') } + // Get current user session + const session = await auth() + 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) if (!titleResult.isValid) { throw new Error(titleResult.error) @@ -214,7 +373,7 @@ export async function createItem( return item } catch (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 new Error('Failed to create item. Please try again.') @@ -227,14 +386,49 @@ export async function toggleItem(itemId: string) { throw new Error('Item ID is required') } + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to update items') + } + + const userId = session.user.id + const item = await retryOperation(() => - prisma.item.findUnique({ where: { id: itemId } }) + prisma.item.findUnique({ + where: { id: itemId }, + include: { list: true }, + }) ) if (!item) { 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(() => prisma.item.update({ where: { id: itemId }, @@ -246,7 +440,7 @@ export async function toggleItem(itemId: string) { return updated } catch (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 new Error('Failed to update item. Please try again.') @@ -270,6 +464,14 @@ export async function updateItem( throw new Error('Item ID is required') } + // Get current user session + const session = await auth() + 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 if (data.title !== undefined) { const titleResult = validateItemTitle(data.title) @@ -299,12 +501,39 @@ export async function updateItem( } const item = await retryOperation(() => - prisma.item.findUnique({ where: { id: itemId } }) + prisma.item.findUnique({ + where: { id: itemId }, + include: { list: true }, + }) ) if (!item) { 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(() => prisma.item.update({ @@ -317,7 +546,7 @@ export async function updateItem( return updated } catch (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 new Error('Failed to update item. Please try again.') @@ -330,13 +559,48 @@ export async function deleteItem(itemId: string) { throw new Error('Item ID is required') } + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to delete items') + } + + const userId = session.user.id + const item = await retryOperation(() => - prisma.item.findUnique({ where: { id: itemId } }) + prisma.item.findUnique({ + where: { id: itemId }, + include: { list: true }, + }) ) if (!item) { 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(() => prisma.item.delete({ @@ -348,7 +612,7 @@ export async function deleteItem(itemId: string) { return { success: true } } catch (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 new Error('Failed to delete item. Please try again.') @@ -361,13 +625,26 @@ export async function deleteList(listId: string) { throw new Error('List ID is required') } - // Check if list exists + // Get current user session + const session = await auth() + 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(() => - prisma.list.findUnique({ where: { id: listId } }) + prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) ) 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(() => @@ -380,7 +657,7 @@ export async function deleteList(listId: string) { return { success: true } } catch (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 new Error('Failed to delete list. Please try again.') @@ -399,6 +676,38 @@ export async function updateList( throw new Error('List ID is required') } + // Get current user session + const session = await auth() + 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 if (data.title !== undefined) { const titleResult = validateListTitle(data.title) @@ -428,7 +737,7 @@ export async function updateList( return updated } catch (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 new Error('Failed to update list. Please try again.') @@ -440,6 +749,14 @@ export async function searchItems(query: string, listId?: string) { if (!query || !query.trim()) { return [] } + + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to search items') + } + + const userId = session.user.id const searchTerm = query.toLowerCase() @@ -465,6 +782,18 @@ export async function searchItems(query: string, listId?: string) { }, }, ], + list: { + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, } // Add list scope if specified @@ -485,15 +814,30 @@ export async function searchItems(query: string, listId?: string) { ) // 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(() => - listId - ? prisma.item.findMany({ - where: { listId }, - include: { list: true }, - }) - : prisma.item.findMany({ - include: { list: true }, - }) + prisma.item.findMany({ + where: allItemsWhere, + include: { list: true }, + }) ) const tagMatches = allItems.filter((item) => { @@ -512,6 +856,9 @@ export async function searchItems(query: string, listId?: string) { return uniqueResults } catch (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.') } } @@ -530,8 +877,29 @@ export async function getItemsByFilter( } = {} ) { try { + // Get current user session + const session = await auth() + 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) - const whereClause: any = {} + const whereClause: any = { + list: { + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, + } // Add list scope if specified if (listId) { @@ -644,6 +1012,9 @@ export async function getItemsByFilter( } } catch (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.') } } \ No newline at end of file