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
This commit is contained in:
2025-12-02 00:48:47 +00:00
parent 2ebda95207
commit 0bb293e101
6 changed files with 408 additions and 29 deletions

View File

@@ -96,7 +96,7 @@
- Redirect unauthenticated users to login - Redirect unauthenticated users to login
- _Requirements: 3.3, 3.5_ - _Requirements: 3.3, 3.5_
- [ ] 8. Update list operations for ownership - [x] 8. Update list operations for ownership
- Modify createList to assign ownerId - Modify createList to assign ownerId
- Update getLists to filter by user access - Update getLists to filter by user access
- Add ownership checks to updateList and deleteList - Add ownership checks to updateList and deleteList

View File

@@ -4,6 +4,8 @@ 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

View File

@@ -6,6 +6,8 @@ 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

View File

@@ -5,6 +5,8 @@ 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 default async function ListDetailPage({ params }: { params: { id: string } }) { export default async function ListDetailPage({ params }: { params: { id: string } }) {
const list = await getListWithItems(params.id) const list = await getListWithItems(params.id)

View File

@@ -4,6 +4,8 @@ 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'
export const dynamic = 'force-dynamic'
export default async function Home() { export default async function Home() {
const lists = await getLists() const lists = await getLists()

View File

@@ -2,6 +2,7 @@
import { prisma } from './db' import { prisma } from './db'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
import { import {
validateListTitle, validateListTitle,
validateItemTitle, validateItemTitle,
@@ -47,10 +48,49 @@ async function retryOperation<T>(
export async function getLists() { export async function getLists() {
try { 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(() => 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 +99,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 auth()
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 +134,7 @@ export async function createList(title: string, color: string = '#3B82F6') {
data: { data: {
title: sanitizedTitle, title: sanitizedTitle,
color, color,
ownerId: userId,
}, },
}) })
) )
@@ -91,7 +143,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 +156,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 auth()
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 +227,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 auth()
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 +269,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 +304,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 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) const titleResult = validateItemTitle(title)
if (!titleResult.isValid) { if (!titleResult.isValid) {
throw new Error(titleResult.error) throw new Error(titleResult.error)
@@ -214,7 +373,7 @@ export async function createItem(
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 +386,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 auth()
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 },
@@ -246,7 +440,7 @@ export async function toggleItem(itemId: string) {
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 +464,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 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 // 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 +501,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 +546,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 +559,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 auth()
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({
@@ -348,7 +612,7 @@ export async function deleteItem(itemId: string) {
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 +625,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 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(() => 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 +657,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 +676,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 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 // 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 +737,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 +749,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 auth()
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 +782,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 +814,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 +856,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 +877,29 @@ export async function getItemsByFilter(
} = {} } = {}
) { ) {
try { 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) // 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 +1012,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.')
} }
} }