diff --git a/app/api/boards/[id]/notes/route.ts b/app/api/boards/[id]/notes/route.ts index 22c161a0f..8caa95854 100644 --- a/app/api/boards/[id]/notes/route.ts +++ b/app/api/boards/[id]/notes/route.ts @@ -23,6 +23,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: true, isPublic: true, organizationId: true, + createdBy: true, notes: { where: { deletedAt: null, // Only include non-deleted notes @@ -63,18 +64,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const user = await db.user.findUnique({ - where: { id: session.user.id }, - select: { - organizationId: true, + const userInOrg = await db.user.findFirst({ + where: { + id: session.user.id, + organizationId: board.organizationId, }, }); - if (!user?.organizationId) { - return NextResponse.json({ error: "No organization found" }, { status: 403 }); + if (!userInOrg) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); } - if (board.organizationId !== user.organizationId) { + const boardShare = await db.boardShare.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!boardShare && board.createdBy !== session.user.id) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } @@ -115,9 +123,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const { color, checklistItems } = validatedBody; - // Verify user has access to this board (same organization) + // Verify user has access to this board (either through organization membership or explicit board share) const user = await db.user.findUnique({ - where: { id: session.user.id }, + where: { + id: session.user.id, + OR: [ + { organizationId: { not: null } }, + { + boardShares: { + some: { + boardId: boardId, + }, + }, + }, + ], + }, select: { organizationId: true, organization: { @@ -130,8 +150,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }, }); - if (!user?.organizationId) { - return NextResponse.json({ error: "No organization found" }, { status: 403 }); + if (!user) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); } const board = await db.board.findUnique({ @@ -140,6 +160,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: true, name: true, organizationId: true, + createdBy: true, sendSlackUpdates: true, }, }); @@ -148,7 +169,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Board not found" }, { status: 404 }); } - if (board.organizationId !== user.organizationId) { + const userInOrg = await db.user.findFirst({ + where: { + id: session.user.id, + organizationId: board.organizationId, + }, + }); + + if (!userInOrg) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + const boardShare = await db.boardShare.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!boardShare && board.createdBy !== session.user.id) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } diff --git a/app/api/boards/[id]/route.ts b/app/api/boards/[id]/route.ts index 304a6c8f5..b1912cca5 100644 --- a/app/api/boards/[id]/route.ts +++ b/app/api/boards/[id]/route.ts @@ -35,7 +35,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Check if user is member of the organization + // Check if user is in the organization (required for board sharing) const userInOrg = await db.user.findFirst({ where: { id: session.user.id, @@ -47,6 +47,17 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + const boardShare = await db.boardShare.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!boardShare && board.createdBy !== session.user.id) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + // Return board data without sensitive organization member details const { organization, ...boardData } = board; @@ -105,15 +116,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Board not found" }, { status: 404 }); } - // Check if user is member of the organization and get admin status + // Check if user is in the organization and has admin status (for editing/deleting boards) + // OR if user is the board creator const currentUser = await db.user.findFirst({ where: { id: session.user.id, - organizationId: board.organizationId, + OR: [ + { organizationId: board.organizationId }, + { id: board.createdBy }, // Allow board creator to edit their own board + ], }, select: { id: true, isAdmin: true, + organizationId: true, }, }); @@ -121,11 +137,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + // For admin checks, user must be organization admin OR board creator + const isOrgAdmin = currentUser.isAdmin || board.createdBy === session.user.id; + // For name/description/isPublic updates, check if user can edit this board (board creator or admin) if ( (name !== undefined || description !== undefined || isPublic !== undefined) && board.createdBy !== session.user.id && - !currentUser.isAdmin + !isOrgAdmin ) { return NextResponse.json( { error: "Only the board creator or admin can edit this board" }, @@ -196,15 +215,20 @@ export async function DELETE( return NextResponse.json({ error: "Board not found" }, { status: 404 }); } - // Check if user is member of the organization and get admin status + // Check if user is in the organization and has admin status (for editing/deleting boards) + // OR if user is the board creator const currentUser = await db.user.findFirst({ where: { id: session.user.id, - organizationId: board.organizationId, + OR: [ + { organizationId: board.organizationId }, + { id: board.createdBy }, // Allow board creator to edit their own board + ], }, select: { id: true, isAdmin: true, + organizationId: true, }, }); @@ -212,8 +236,11 @@ export async function DELETE( return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + // For admin checks, user must be organization admin OR board creator + const isOrgAdmin = currentUser.isAdmin || board.createdBy === session.user.id; + // Check if user can delete this board (board creator or admin) - if (board.createdBy !== session.user.id && !currentUser.isAdmin) { + if (board.createdBy !== session.user.id && !isOrgAdmin) { return NextResponse.json( { error: "Only the board creator or admin can delete this board" }, { status: 403 } diff --git a/app/api/boards/[id]/share/route.ts b/app/api/boards/[id]/share/route.ts new file mode 100644 index 000000000..a1b1550a8 --- /dev/null +++ b/app/api/boards/[id]/share/route.ts @@ -0,0 +1,236 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +// Helper function to update organization-level sharing status when board sharing changes +async function updateOrganizationSharingStatus(organizationId: string) { + try { + // Get all boards in the organization + const boards = await db.board.findMany({ + where: { organizationId }, + select: { id: true }, + }); + + if (boards.length === 0) return; + + // Get all users in the organization + const users = await db.user.findMany({ + where: { organizationId }, + select: { id: true }, + }); + + // Get current board shares for all boards in the organization + const allBoardShares = await db.boardShare.findMany({ + where: { + boardId: { in: boards.map((b) => b.id) }, + }, + select: { + boardId: true, + userId: true, + }, + }); + + // Group shares by user + const sharesByUser = new Map>(); + allBoardShares.forEach((share) => { + if (!sharesByUser.has(share.userId)) { + sharesByUser.set(share.userId, new Set()); + } + sharesByUser.get(share.userId)!.add(share.boardId); + }); + + // For each user, check if they should have "share all boards" status + for (const user of users) { + const sharedBoardIds = sharesByUser.get(user.id) || new Set(); + const shouldShareAllBoards = boards.length > 0 && sharedBoardIds.size === boards.length; + + // If user should share all boards but doesn't have shares for all boards, add them + if (shouldShareAllBoards) { + const missingBoardIds = boards.map((b) => b.id).filter((id) => !sharedBoardIds.has(id)); + + if (missingBoardIds.length > 0) { + await db.boardShare.createMany({ + data: missingBoardIds.map((boardId) => ({ + boardId, + userId: user.id, + })), + }); + } + } + } + } catch (error) { + console.error("Error updating organization sharing status:", error); + } +} + +// Get sharing status for a board +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const boardId = (await params).id; + + // Check if board exists and user has access + const board = await db.board.findUnique({ + where: { id: boardId }, + include: { organization: true }, + }); + + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + // Check if user is member of the organization and get admin status + const currentUser = await db.user.findFirst({ + where: { + id: session.user.id, + organizationId: board.organizationId, + }, + select: { + id: true, + isAdmin: true, + }, + }); + + if (!currentUser) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + // Get all organization members + const organizationMembers = await db.user.findMany({ + where: { organizationId: board.organizationId }, + select: { + id: true, + name: true, + email: true, + isAdmin: true, + }, + orderBy: { name: "asc" }, + }); + + // Get current board shares for this board + const boardShares = await db.boardShare.findMany({ + where: { boardId }, + select: { userId: true }, + }); + + const sharedUserIds = new Set(boardShares.map((share) => share.userId)); + + // Combine member data with sharing status + const membersWithSharing = organizationMembers.map((member) => ({ + id: member.id, + name: member.name, + email: member.email, + isAdmin: member.isAdmin, + isShared: sharedUserIds.has(member.id), + })); + + return NextResponse.json({ + members: membersWithSharing, + boardCreator: board.createdBy, + }); + } catch (error) { + console.error("Error fetching board sharing:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// Update sharing for specific users on this board +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const boardId = (await params).id; + const body = await request.json(); + + const schema = z.object({ + userIds: z.array(z.string()), + }); + + const validatedBody = schema.parse(body); + const { userIds } = validatedBody; + + // Check if board exists and user has access + const board = await db.board.findUnique({ + where: { id: boardId }, + include: { organization: true }, + }); + + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + // Check if user is member of the organization and get admin status + const currentUser = await db.user.findFirst({ + where: { + id: session.user.id, + organizationId: board.organizationId, + }, + select: { + id: true, + isAdmin: true, + }, + }); + + if (!currentUser) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + // Only admins can update board sharing + if (!currentUser.isAdmin) { + return NextResponse.json({ error: "Only admins can update board sharing" }, { status: 403 }); + } + + // Validate that all userIds are in the same organization + const validUsers = await db.user.findMany({ + where: { + id: { in: userIds }, + organizationId: board.organizationId, + }, + select: { id: true }, + }); + + if (validUsers.length !== userIds.length) { + return NextResponse.json( + { error: "Some users are not in the organization" }, + { status: 400 } + ); + } + + // Update board shares - delete existing and create new ones + await db.$transaction([ + db.boardShare.deleteMany({ where: { boardId } }), + ...userIds.map((userId) => + db.boardShare.create({ + data: { + boardId, + userId, + }, + }) + ), + ]); + + // Check if this affects any user's "share all boards" status and update organization settings + await updateOrganizationSharingStatus(board.organizationId); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400 } + ); + } + console.error("Error updating board sharing:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/boards/route.ts b/app/api/boards/route.ts index 0423e7f34..ecc04385c 100644 --- a/app/api/boards/route.ts +++ b/app/api/boards/route.ts @@ -23,9 +23,21 @@ export async function GET() { return NextResponse.json({ error: "No organization found" }, { status: 404 }); } - // Get all boards for the organization + // Get boards the user has access to (public boards, explicitly shared boards, or boards created by the user) const boards = await db.board.findMany({ - where: { organizationId: user.organizationId }, + where: { + OR: [ + { isPublic: true }, + { + shares: { + some: { + userId: session.user.id, + }, + }, + }, + { createdBy: session.user.id }, // Boards created by the user + ], + }, select: { id: true, name: true, @@ -34,6 +46,12 @@ export async function GET() { createdBy: true, createdAt: true, updatedAt: true, + organization: { + select: { + id: true, + name: true, + }, + }, _count: { select: { notes: { @@ -152,6 +170,14 @@ export async function POST(request: NextRequest) { }, }); + // Automatically create a board sharing record for the creator + await db.boardShare.create({ + data: { + boardId: board.id, + userId: session.user.id, + }, + }); + return NextResponse.json({ board }, { status: 201 }); } catch (error) { console.error("Error creating board:", error); diff --git a/app/api/organization/share/route.ts b/app/api/organization/share/route.ts new file mode 100644 index 000000000..797ded510 --- /dev/null +++ b/app/api/organization/share/route.ts @@ -0,0 +1,249 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +// Helper function to update board-level sharing status when organization sharing changes +async function updateBoardSharingStatus(orgId: string) { + try { + // Get all boards in the organization + const boards = await db.board.findMany({ + where: { organizationId: orgId }, + select: { id: true }, + }); + + if (boards.length === 0) return; + } catch (error) { + console.error("Error updating board sharing status:", error); + } +} + +// Get sharing status for all organization members across all boards +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get user with organization + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { + organizationId: true, + isAdmin: true, + }, + }); + + if (!user?.organizationId) { + return NextResponse.json({ error: "No organization found" }, { status: 404 }); + } + + // Only admins can view organization sharing + if (!user.isAdmin) { + return NextResponse.json( + { error: "Only admins can view organization sharing" }, + { status: 403 } + ); + } + + // Get all organization members + const organizationMembers = await db.user.findMany({ + where: { organizationId: user.organizationId }, + select: { + id: true, + name: true, + email: true, + isAdmin: true, + }, + orderBy: { name: "asc" }, + }); + + // Get all boards in the organization + const boards = await db.board.findMany({ + where: { organizationId: user.organizationId }, + select: { + id: true, + name: true, + }, + orderBy: { name: "asc" }, + }); + + // Get all board shares for this organization + const boardShares = await db.boardShare.findMany({ + where: { + board: { + organizationId: user.organizationId, + }, + }, + select: { + boardId: true, + userId: true, + }, + }); + + // Create a map of board shares by user + const sharesByUser = new Map>(); + boardShares.forEach((share) => { + if (!sharesByUser.has(share.userId)) { + sharesByUser.set(share.userId, new Set()); + } + sharesByUser.get(share.userId)!.add(share.boardId); + }); + + // For each member, determine if they have all boards shared + const membersWithSharingStatus = organizationMembers.map((member) => { + const sharedBoardIds = sharesByUser.get(member.id) || new Set(); + const allBoardsShared = boards.length > 0 && sharedBoardIds.size === boards.length; + + return { + id: member.id, + name: member.name, + email: member.email, + isAdmin: member.isAdmin, + shareAllBoards: allBoardsShared, + sharedBoardIds: Array.from(sharedBoardIds), + totalBoards: boards.length, + }; + }); + + return NextResponse.json({ + members: membersWithSharingStatus, + boards: boards.map((board) => ({ id: board.id, name: board.name })), + }); + } catch (error) { + console.error("Error fetching organization sharing:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// Update sharing for specific users across all boards +export async function PUT(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + + const schema = z.object({ + userSharing: z.array( + z.object({ + userId: z.string(), + shareAllBoards: z.boolean(), + sharedBoardIds: z.array(z.string()).optional(), + }) + ), + }); + + const validatedBody = schema.parse(body); + const { userSharing } = validatedBody; + + // Get user with organization + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { + organizationId: true, + isAdmin: true, + }, + }); + + if (!user?.organizationId) { + return NextResponse.json({ error: "No organization found" }, { status: 404 }); + } + + const organizationId = user.organizationId; + + // Only admins can update organization sharing + if (!user.isAdmin) { + return NextResponse.json( + { error: "Only admins can update organization sharing" }, + { status: 403 } + ); + } + + // Get all boards in the organization + const boards = await db.board.findMany({ + where: { organizationId }, + select: { id: true }, + }); + + const boardIds = boards.map((board) => board.id); + + // Validate that all userIds are in the same organization + const validUsers = await db.user.findMany({ + where: { + id: { in: userSharing.map((us) => us.userId) }, + organizationId, + }, + select: { id: true }, + }); + + if (validUsers.length !== userSharing.length) { + return NextResponse.json( + { error: "Some users are not in the organization" }, + { status: 400 } + ); + } + + // Update sharing for each user + for (const userShare of userSharing) { + if (userShare.shareAllBoards) { + // Share all boards with this user + await db.boardShare.deleteMany({ + where: { + boardId: { in: boardIds }, + userId: userShare.userId, + }, + }); + + await db.boardShare.createMany({ + data: boardIds.map((boardId) => ({ + boardId, + userId: userShare.userId, + })), + }); + } else if (userShare.sharedBoardIds && userShare.sharedBoardIds.length > 0) { + // Share specific boards with this user + await db.boardShare.deleteMany({ + where: { + boardId: { in: boardIds }, + userId: userShare.userId, + }, + }); + + await db.boardShare.createMany({ + data: userShare.sharedBoardIds.map((boardId) => ({ + boardId, + userId: userShare.userId, + })), + }); + } else { + // Share no boards with this user + await db.boardShare.deleteMany({ + where: { + boardId: { in: boardIds }, + userId: userShare.userId, + }, + }); + } + } + + // Check if this affects any user's board-specific sharing and update accordingly + await updateBoardSharingStatus(organizationId); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400 } + ); + } + console.error("Error updating organization sharing:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/boards/[id]/page.tsx b/app/boards/[id]/page.tsx index 6a637d532..07ea7b4a3 100644 --- a/app/boards/[id]/page.tsx +++ b/app/boards/[id]/page.tsx @@ -16,6 +16,7 @@ import { XIcon, StickyNote, Plus, + Users, } from "lucide-react"; import Link from "next/link"; import { BetaBadge } from "@/components/ui/beta-badge"; @@ -101,6 +102,13 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> }); const [copiedPublicUrl, setCopiedPublicUrl] = useState(false); const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(false); + const [boardSharing, setBoardSharing] = useState< + { id: string; name: string; email: string; isShared: boolean }[] + >([]); + const [boardCreator, setBoardCreator] = useState(null); + const [memberSearchTerm, setMemberSearchTerm] = useState(""); + const [updatingSharing, setUpdatingSharing] = useState(null); + const [boardSharingDialog, setBoardSharingDialog] = useState(false); const boardRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); @@ -212,6 +220,12 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> // eslint-disable-next-line react-hooks/exhaustive-deps }, [boardId]); + useEffect(() => { + if (boardSettingsDialog && boardId && boardId !== "all-notes" && boardId !== "archive") { + fetchBoardSharing(); + } + }, [boardSettingsDialog, boardId]); + useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -640,6 +654,79 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> } }; + const fetchBoardSharing = async () => { + if (!boardId || boardId === "all-notes" || boardId === "archive") return; + + try { + const response = await fetch(`/api/boards/${boardId}/share`); + if (response.ok) { + const data = await response.json(); + setBoardSharing(data.members || []); + setBoardCreator(data.boardCreator || null); + } + } catch (error) { + console.error("Error fetching board sharing:", error); + } + }; + + const handleToggleMemberSharing = async (memberId: string, isShared: boolean) => { + if (!boardId || boardId === "all-notes" || boardId === "archive") return; + + setUpdatingSharing(memberId); + try { + const currentSharedIds = boardSharing + .filter((member) => member.isShared) + .map((member) => member.id); + + const newSharedIds = isShared + ? [...currentSharedIds, memberId] + : currentSharedIds.filter((id) => id !== memberId); + + // Use individual update function that doesn't close the dialog + await handleUpdateBoardSharingIndividual(newSharedIds); + } catch (error) { + console.error("Error toggling member sharing:", error); + } finally { + setUpdatingSharing(null); + } + }; + + const handleUpdateBoardSharingIndividual = async (userIds: string[]) => { + if (!boardId || boardId === "all-notes" || boardId === "archive") return; + + setUpdatingSharing("bulk"); + try { + const response = await fetch(`/api/boards/${boardId}/share`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userIds }), + }); + + if (response.ok) { + await fetchBoardSharing(); + // Don't close dialog for individual toggles + } else { + const errorData = await response.json(); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: errorData.error || "Failed to update board sharing", + }); + } + } catch (error) { + console.error("Error updating board sharing:", error); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: "Failed to update board sharing", + }); + } finally { + setUpdatingSharing(null); + } + }; + const handleDeleteBoard = async () => { try { const response = await fetch(`/api/boards/${boardId}`, { @@ -1196,6 +1283,56 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }>

+ {/* Board Sharing Section */} +
+
+
+ + +
+

+ Control which team members can access this board +

+
+ + {/* Quick preview of shared members */} + {boardSharing.length > 0 && ( +
+

+ Currently shared with: +

+
+ {boardSharing + .filter((member) => member.isShared) + .slice(0, 3) + .map((member) => ( + + {member.name || member.email} + + ))} + {boardSharing.filter((member) => member.isShared).length > 3 && ( + + +{boardSharing.filter((member) => member.isShared).length - 3} more + + )} +
+
+ )} +
+ + + + + {/* Delete Confirmation Dialog */} diff --git a/app/settings/organization/page.tsx b/app/settings/organization/page.tsx index 4e6aa3c21..c7785c008 100644 --- a/app/settings/organization/page.tsx +++ b/app/settings/organization/page.tsx @@ -9,6 +9,9 @@ import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { format } from "date-fns"; import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Board } from "@/components/note"; import { Trash2, @@ -22,6 +25,7 @@ import { Users, ExternalLink, Calendar as CalendarIconLucide, + Eye, } from "lucide-react"; import { Loader } from "@/components/ui/loader"; import { @@ -34,6 +38,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useUser } from "@/app/contexts/UserContext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -63,6 +74,16 @@ interface SelfServeInvite { }; } +interface MemberWithSharing { + id: string; + name: string | null; + email: string; + isAdmin: boolean; + shareAllBoards: boolean; + sharedBoardIds: string[]; + totalBoards: number; +} + export default function OrganizationSettingsPage() { const { user, loading, refreshUser } = useUser(); const router = useRouter(); @@ -101,6 +122,15 @@ export default function OrganizationSettingsPage() { }>({ open: false, title: "", description: "", variant: "error" }); const [creating, setCreating] = useState(false); const [copiedInviteToken, setCopiedInviteToken] = useState(null); + const [membersWithSharing, setMembersWithSharing] = useState([]); + const [boards, setBoards] = useState([]); + const [updatingSharing, setUpdatingSharing] = useState(null); + const [boardDialog, setBoardDialog] = useState<{ + open: boolean; + memberId: string; + memberName: string; + sharedBoardIds: string[]; + }>({ open: false, memberId: "", memberName: "", sharedBoardIds: [] }); useEffect(() => { if (user?.organization) { @@ -123,6 +153,7 @@ export default function OrganizationSettingsPage() { if (user?.organization) { fetchInvites(); fetchSelfServeInvites(); + fetchBoardSharing(); } }, [user?.organization]); @@ -150,6 +181,111 @@ export default function OrganizationSettingsPage() { } }; + const fetchBoardSharing = async () => { + try { + const response = await fetch("/api/organization/share"); + if (response.ok) { + const data = await response.json(); + setMembersWithSharing(data.members || []); + setBoards(data.boards || []); + } + } catch (error) { + console.error("Error fetching board sharing:", error); + } finally { + } + }; + + const handleToggleShareAllBoards = async (memberId: string, shareAllBoards: boolean) => { + setUpdatingSharing(memberId); + try { + const response = await fetch("/api/organization/share", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userSharing: [ + { + userId: memberId, + shareAllBoards, + }, + ], + }), + }); + + if (response.ok) { + await fetchBoardSharing(); + } else { + const errorData = await response.json(); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: errorData.error || "Failed to update board sharing", + }); + } + } catch (error) { + console.error("Error updating board sharing:", error); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: "Failed to update board sharing", + }); + } finally { + setUpdatingSharing(null); + } + }; + + const handleUpdateBoardSharing = async (memberId: string, sharedBoardIds: string[]) => { + setUpdatingSharing(memberId); + try { + const response = await fetch("/api/organization/share", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userSharing: [ + { + userId: memberId, + shareAllBoards: sharedBoardIds.length === boards.length, + sharedBoardIds, + }, + ], + }), + }); + + if (response.ok) { + await fetchBoardSharing(); + setBoardDialog({ open: false, memberId: "", memberName: "", sharedBoardIds: [] }); + } else { + const errorData = await response.json(); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: errorData.error || "Failed to update board sharing", + }); + } + } catch (error) { + console.error("Error updating board sharing:", error); + setErrorDialog({ + open: true, + title: "Failed to update board sharing", + description: "Failed to update board sharing", + }); + } finally { + setUpdatingSharing(null); + } + }; + + const openBoardDialog = (memberId: string, memberName: string, sharedBoardIds: string[]) => { + setBoardDialog({ + open: true, + memberId, + memberName, + sharedBoardIds, + }); + }; + const handleSaveOrgName = async () => { setSavingOrg(true); try { @@ -600,73 +736,119 @@ export default function OrganizationSettingsPage() {
- {user?.organization?.members?.map((member) => ( -
-
- - - - {member.name - ? member.name.charAt(0).toUpperCase() - : member.email.charAt(0).toUpperCase()} - - -
-
-

- {member.name || "Unnamed User"} -

- {member.isAdmin && ( - - - Admin - + {user?.organization?.members?.map((member) => { + const memberSharing = membersWithSharing.find((m) => m.id === member.id); + return ( +
+
+ + + + {member.name + ? member.name.charAt(0).toUpperCase() + : member.email.charAt(0).toUpperCase()} + + +
+
+

+ {member.name || "Unnamed User"} +

+ {member.isAdmin && ( + + + Admin + + )} +
+

{member.email}

+ {memberSharing && ( +
+ + {memberSharing.shareAllBoards + ? `All ${memberSharing.totalBoards} boards shared` + : `${memberSharing.sharedBoardIds.length} of ${memberSharing.totalBoards} boards shared`} + +
)}
-

{member.email}

+
+
+ {/* Board sharing toggle - only for admins and not for yourself */} + {user?.isAdmin && member.id !== user.id && ( +
+ + handleToggleShareAllBoards(member.id, checked) + } + disabled={updatingSharing === member.id} + className="data-[state=checked]:bg-green-600" + /> + + Share all boards + + +
+ )} + + {/* Admin toggle - only for admins and not for yourself */} + {user?.isAdmin && member.id !== user.id && ( + <> + + + + )}
-
- {/* Only show admin toggle to current admins and not for yourself */} - {user?.isAdmin && member.id !== user.id && ( - - )} - {user?.isAdmin && member.id !== user.id && ( - - )} -
-
- ))} + ); + })}
@@ -1009,6 +1191,80 @@ export default function OrganizationSettingsPage() { + {/* Board Sharing Dialog */} + + setBoardDialog({ open, memberId: "", memberName: "", sharedBoardIds: [] }) + } + > + + + + Share boards with {boardDialog.memberName} + + + Select which boards to share with this team member. They will only be able to see and + edit the selected boards. + + +
+ {boards.map((board) => { + const isShared = boardDialog.sharedBoardIds.includes(board.id); + return ( +
+ { + const newSharedBoardIds = checked + ? [...boardDialog.sharedBoardIds, board.id] + : boardDialog.sharedBoardIds.filter((id) => id !== board.id); + setBoardDialog((prev) => ({ ...prev, sharedBoardIds: newSharedBoardIds })); + }} + className="data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600" + /> + +
+ ); + })} +
+
+
+ {boardDialog.sharedBoardIds.length} of {boards.length} boards selected +
+
+ + +
+
+
+
+ diff --git a/prisma/migrations/20251005180754_add_board_shares/migration.sql b/prisma/migrations/20251005180754_add_board_shares/migration.sql new file mode 100644 index 000000000..ddf3e97f5 --- /dev/null +++ b/prisma/migrations/20251005180754_add_board_shares/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "board_shares" ( + "id" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "board_shares_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "board_shares_boardId_idx" ON "board_shares"("boardId"); + +-- CreateIndex +CREATE INDEX "board_shares_userId_idx" ON "board_shares"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "board_shares_boardId_userId_key" ON "board_shares"("boardId", "userId"); + +-- AddForeignKey +ALTER TABLE "board_shares" ADD CONSTRAINT "board_shares_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "boards"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "board_shares" ADD CONSTRAINT "board_shares_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b75393ca6..ba88aa974 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,7 @@ model User { invitedOrganizations OrganizationInvite[] createdSelfServeInvites OrganizationSelfServeInvite[] notes Note[] + boardShares BoardShare[] @@index([organizationId], name: "idx_user_org") @@map("users") @@ -94,6 +95,7 @@ model Board { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt notes Note[] + shares BoardShare[] // Performance indexes @@index([organizationId, createdAt], name: "idx_board_org_created") @@ -137,6 +139,20 @@ model ChecklistItem { @@index([noteId, order]) } +model BoardShare { + id String @id @default(cuid()) + boardId String + userId String + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([boardId, userId]) + @@map("board_shares") + @@index([boardId]) + @@index([userId]) +} + model OrganizationInvite { id String @id @default(cuid()) email String diff --git a/tests/e2e/board-sharing.spec.ts b/tests/e2e/board-sharing.spec.ts new file mode 100644 index 000000000..a9c2ffc07 --- /dev/null +++ b/tests/e2e/board-sharing.spec.ts @@ -0,0 +1,366 @@ +import { test, expect } from "../fixtures/test-helpers"; + +test.describe("Board Sharing", () => { + test.beforeEach(async ({ testPrisma, testContext }) => { + // Make the test user an admin + await testPrisma.user.update({ + where: { id: testContext.userId }, + data: { isAdmin: true }, + }); + + // Create a second user for sharing tests + const secondUserId = `usr2_${testContext.testId}`; + const secondUserEmail = `test2-${testContext.testId}@example.com`; + + await testPrisma.user.create({ + data: { + id: secondUserId, + email: secondUserEmail, + name: `Test User 2 ${testContext.testId}`, + organizationId: testContext.organizationId, + isAdmin: false, + }, + }); + }); + + test.describe("Board Creation & Creator Access", () => { + test("admin creates board with auto-access", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + await authenticatedPage.goto("/dashboard"); + + const boardName = testContext.getBoardName("Shared Test Board"); + await authenticatedPage.click('button:has-text("Add Board")'); + await authenticatedPage.fill('input[placeholder*="board name"]', boardName); + await authenticatedPage.fill( + 'textarea[placeholder*="board description"]', + "Test board for sharing" + ); + + const createResponse = authenticatedPage.waitForResponse( + (resp) => resp.url().includes("/api/boards") && resp.status() === 201 + ); + + await authenticatedPage.click('button:has-text("Create board")'); + await createResponse; + + // Verify board in dashboard + await expect( + authenticatedPage.locator(`[data-slot="card-title"]:has-text("${boardName}")`) + ).toBeVisible(); + + // Verify board in database + const board = await testPrisma.board.findFirst({ + where: { + name: boardName, + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + expect(board).toBeTruthy(); + + // Verify creator auto-shared + const boardShare = await testPrisma.boardShare.findFirst({ + where: { + boardId: board!.id, + userId: testContext.userId, + }, + }); + expect(boardShare).toBeTruthy(); + }); + + test("creator can access own board", async ({ authenticatedPage, testContext, testPrisma }) => { + // Create board + const boardName = testContext.getBoardName("Creator Access Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for creator access", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + // Navigate to board + await authenticatedPage.goto(`/boards/${board.id}`); + + // Should load successfully + await expect(authenticatedPage.locator("text=Board not found")).not.toBeVisible(); + await expect( + authenticatedPage + .locator(`[data-testid="board-dropdown-trigger"]`) + .filter({ hasText: boardName }) + ).toBeVisible(); + }); + }); + + test.describe("Board Sharing Dialog", () => { + test("sharing dialog shows creator identification", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + // Create board + const boardName = testContext.getBoardName("Sharing Dialog Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for sharing dialog", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + await authenticatedPage.goto(`/boards/${board.id}`); + await authenticatedPage.click('[aria-label="Board settings"]'); + await authenticatedPage.click('button:has-text("Manage Sharing")'); + + // Dialog opens + await expect( + authenticatedPage.locator('[data-slot="dialog-title"]').filter({ hasText: "Share" }) + ).toBeVisible(); + + // Creator identified with badge and auto-access + await expect(authenticatedPage.locator("text=Creator")).toBeVisible(); + await expect(authenticatedPage.locator("text=Auto access")).toBeVisible(); + }); + + test("sharing dialog shows all members", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + // Create board + const boardName = testContext.getBoardName("Members Dialog Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for members dialog", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + await authenticatedPage.goto(`/boards/${board.id}`); + await authenticatedPage.click('[aria-label="Board settings"]'); + await authenticatedPage.click('button:has-text("Manage Sharing")'); + + // Shows all org members + await expect(authenticatedPage.locator("text=Test User").first()).toBeVisible(); // Admin + await expect(authenticatedPage.locator("text=Test User 2")).toBeVisible(); // Regular user + }); + + test("dialog persists during member toggles", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + // Create board + const boardName = testContext.getBoardName("Toggle Dialog Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for toggle dialog", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + await authenticatedPage.goto(`/boards/${board.id}`); + await authenticatedPage.click('[aria-label="Board settings"]'); + await authenticatedPage.click('button:has-text("Manage Sharing")'); + + // Toggle member sharing + const toggleSwitch = authenticatedPage.locator('button[role="switch"]').nth(1); // Second member + await toggleSwitch.click(); + + // Dialog stays open + await expect( + authenticatedPage.locator('[data-slot="dialog-title"]').filter({ hasText: "Share" }) + ).toBeVisible(); + + // Can toggle again + await toggleSwitch.click(); + await expect( + authenticatedPage.locator('[data-slot="dialog-title"]').filter({ hasText: "Share" }) + ).toBeVisible(); + }); + + test("dialog closes on Done button", async ({ authenticatedPage, testContext, testPrisma }) => { + // Create board + const boardName = testContext.getBoardName("Done Button Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for done button", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + await authenticatedPage.goto(`/boards/${board.id}`); + await authenticatedPage.click('[aria-label="Board settings"]'); + await authenticatedPage.click('button:has-text("Manage Sharing")'); + + // Dialog opens + await expect( + authenticatedPage.locator('[data-slot="dialog-title"]').filter({ hasText: "Share" }) + ).toBeVisible(); + + // Click Done + await authenticatedPage.click('[data-slot="dialog-content"] button:has-text("Done")'); + + // Dialog closes + await expect( + authenticatedPage.locator('[data-slot="dialog-title"]').filter({ hasText: "Share" }) + ).not.toBeVisible(); + }); + }); + + test.describe("Access Control", () => { + test("shared user can access board", async ({ authenticatedPage, testContext, testPrisma }) => { + // Create board + const boardName = testContext.getBoardName("Shared Access Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for shared access", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + // Share with second user + await testPrisma.boardShare.create({ + data: { + boardId: board.id, + userId: `usr2_${testContext.testId}`, + }, + }); + + // Verify sharing record exists + const boardShare = await testPrisma.boardShare.findFirst({ + where: { + boardId: board.id, + userId: `usr2_${testContext.testId}`, + }, + }); + expect(boardShare).toBeTruthy(); + }); + + test("creator has access without explicit sharing", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + // Create board without sharing + const boardName = testContext.getBoardName("Creator Access Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for creator access without explicit sharing", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + // Creator can access own board + await authenticatedPage.goto(`/boards/${board.id}`); + await expect(authenticatedPage.locator("text=Board not found")).not.toBeVisible(); + await expect( + authenticatedPage + .locator(`[data-testid="board-dropdown-trigger"]`) + .filter({ hasText: boardName }) + ).toBeVisible(); + }); + }); + + test.describe("Organization Integration", () => { + test("org sharing API works", async ({ authenticatedPage, testContext, testPrisma }) => { + // Create board + const boardName = testContext.getBoardName("Org Sync Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for org sync", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + await authenticatedPage.goto("/settings/organization"); + + // Verify org sharing API + const orgShareResponse = await authenticatedPage.request.get("/api/organization/share"); + expect(orgShareResponse.ok()).toBeTruthy(); + + const shareData = await orgShareResponse.json(); + expect(shareData.members).toBeDefined(); + expect(shareData.boards).toBeDefined(); + }); + }); + + test.describe("Edge Cases", () => { + test("public boards are accessible", async ({ authenticatedPage, testContext, testPrisma }) => { + // Create public board + const boardName = testContext.getBoardName("Public Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test public board", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + isPublic: true, + }, + }); + + // Public boards accessible + await authenticatedPage.goto(`/boards/${board.id}`); + await expect( + authenticatedPage + .locator(`[data-testid="board-dropdown-trigger"]`) + .filter({ hasText: boardName }) + ).toBeVisible(); + }); + + test("board deletion removes sharing", async ({ + authenticatedPage, + testContext, + testPrisma, + }) => { + // Create board with sharing + const boardName = testContext.getBoardName("Deletion Test Board"); + const board = await testPrisma.board.create({ + data: { + name: boardName, + description: "Test board for deletion", + createdBy: testContext.userId, + organizationId: testContext.organizationId, + }, + }); + + // Share with second user + await testPrisma.boardShare.create({ + data: { + boardId: board.id, + userId: `usr2_${testContext.testId}`, + }, + }); + + // Delete board + await testPrisma.board.delete({ + where: { id: board.id }, + }); + + // Sharing record deleted (cascade) + const boardShare = await testPrisma.boardShare.findFirst({ + where: { + boardId: board.id, + }, + }); + expect(boardShare).toBeNull(); + }); + }); +});