diff --git a/app/api/boards/[id]/members/route.ts b/app/api/boards/[id]/members/route.ts new file mode 100644 index 000000000..01d4dc4a0 --- /dev/null +++ b/app/api/boards/[id]/members/route.ts @@ -0,0 +1,270 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const memberSchema = z.object({ + email: z.string().email().transform((email) => email.trim().toLowerCase()), +}); + +// Get all members of 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 has board access (is member or org admin) + 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 }); + } + + const isBoardMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!isBoardMember && !currentUser.isAdmin) { + return NextResponse.json({ error: "Only board members or org admins can view members" }, { status: 403 }); + } + + // Get all board members with user details + const members = await db.boardMember.findMany({ + where: { boardId }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ members }); + } catch (error) { + console.error("Error fetching board members:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// Add a member to a board +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const boardId = (await params).id; + + let validatedBody; + try { + validatedBody = memberSchema.parse(body); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400 } + ); + } + throw error; + } + + const { email } = validatedBody; + + // Check if board exists + 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 }); + } + + // Check if user has board access (is member or org admin) + const isBoardMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!isBoardMember && !currentUser.isAdmin) { + return NextResponse.json({ error: "Only board members or org admins can add members" }, { status: 403 }); + } + + // Find user to add + const userToAdd = await db.user.findUnique({ + where: { email }, + }); + + if (!userToAdd) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Check if user is in the same organization + if (userToAdd.organizationId !== board.organizationId) { + return NextResponse.json({ error: "User must be in the same organization" }, { status: 403 }); + } + + // Check if user is already a member + const existingMember = await db.boardMember.findFirst({ + where: { + boardId, + userId: userToAdd.id, + }, + }); + + if (existingMember) { + return NextResponse.json({ error: "User is already a member of this board" }, { status: 400 }); + } + + // Add user to board + const newMember = await db.boardMember.create({ + data: { + userId: userToAdd.id, + boardId, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + + return NextResponse.json({ member: newMember }, { status: 201 }); + } catch (error) { + console.error("Error adding board member:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// Remove a member from a board +export async function DELETE(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 url = new URL(request.url); + const userIdToRemove = url.searchParams.get("userId"); + + if (!userIdToRemove) { + return NextResponse.json({ error: "User ID is required" }, { status: 400 }); + } + + // Check if board exists + 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 }); + } + + // Check if user has board access (is member or org admin) + const isBoardMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!isBoardMember && !currentUser.isAdmin) { + return NextResponse.json({ error: "Only board members or org admins can remove members" }, { status: 403 }); + } + + // Prevent removing the board creator + if (board.createdBy === userIdToRemove) { + return NextResponse.json({ error: "Cannot remove the board creator" }, { status: 403 }); + } + + // Remove member + await db.boardMember.deleteMany({ + where: { + boardId, + userId: userIdToRemove, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error removing board member:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/boards/[id]/notes/[noteId]/route.ts b/app/api/boards/[id]/notes/[noteId]/route.ts index 0f9ac0f5e..b1557ee71 100644 --- a/app/api/boards/[id]/notes/[noteId]/route.ts +++ b/app/api/boards/[id]/notes/[noteId]/route.ts @@ -73,7 +73,26 @@ export async function PUT( return NextResponse.json({ error: "Note not found" }, { status: 404 }); } - if (note.board.organizationId !== user.organizationId || note.boardId !== boardId) { + // Check if user is explicitly a member + const isExplicitMember = await db.boardMember.findFirst({ + where: { + boardId: note.boardId, + userId: session.user.id, + }, + }); + + // Check if user has org-wide access + const userAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); + + // Check access: board is public, user is explicit member, or (board shared with org AND user has org-wide access) + const hasAccess = note.board.isPublic || + !!isExplicitMember || + (note.board.shareWithOrganization && (userAccess?.hasOrgWideAccess ?? false)); + + if (!hasAccess) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } @@ -291,7 +310,26 @@ export async function DELETE( return NextResponse.json({ error: "Note not found" }, { status: 404 }); } - if (note.board.organizationId !== user.organizationId || note.boardId !== boardId) { + // Check if user is explicitly a member + const isExplicitMember = await db.boardMember.findFirst({ + where: { + boardId: note.boardId, + userId: session.user.id, + }, + }); + + // Check if user has org-wide access + const userAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); + + // Check access: board is public, user is explicit member, or (board shared with org AND user has org-wide access) + const hasAccess = note.board.isPublic || + !!isExplicitMember || + (note.board.shareWithOrganization && (userAccess?.hasOrgWideAccess ?? false)); + + if (!hasAccess) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } diff --git a/app/api/boards/[id]/notes/route.ts b/app/api/boards/[id]/notes/route.ts index 22c161a0f..d1725a5b3 100644 --- a/app/api/boards/[id]/notes/route.ts +++ b/app/api/boards/[id]/notes/route.ts @@ -22,6 +22,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ select: { id: true, isPublic: true, + shareWithOrganization: true, organizationId: true, notes: { where: { @@ -63,18 +64,26 @@ 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, + // Check if user is explicitly a member + const isExplicitMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, }, }); - if (!user?.organizationId) { - return NextResponse.json({ error: "No organization found" }, { status: 403 }); - } + // Check if user has org-wide access + const userAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); - if (board.organizationId !== user.organizationId) { + // Check access: board is public, user is explicit member, or (board shared with org AND user has org-wide access) + const hasAccess = board.isPublic || + !!isExplicitMember || + (board.shareWithOrganization && (userAccess?.hasOrgWideAccess ?? false)); + + if (!hasAccess) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } @@ -141,6 +150,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ name: true, organizationId: true, sendSlackUpdates: true, + isPublic: true, + shareWithOrganization: true, }, }); @@ -148,7 +159,26 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Board not found" }, { status: 404 }); } - if (board.organizationId !== user.organizationId) { + // Check if user is explicitly a member + const isExplicitMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + // Check if user has org-wide access + const userAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); + + // Check access: board is public, user is explicit member, or (board shared with org AND user has org-wide access) + const hasAccess = board.isPublic || + !!isExplicitMember || + (board.shareWithOrganization && (userAccess?.hasOrgWideAccess ?? false)); + + if (!hasAccess) { 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..521bca516 100644 --- a/app/api/boards/[id]/route.ts +++ b/app/api/boards/[id]/route.ts @@ -35,15 +35,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Check if user is member of the organization - const userInOrg = await db.user.findFirst({ + // Check if user is explicitly a member + const isExplicitMember = await db.boardMember.findFirst({ where: { - id: session.user.id, - organizationId: board.organizationId, + boardId: boardId, + userId: session.user.id, }, }); - if (!userInOrg) { + // Check if user has org-wide access + const userAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); + + // Check access: board is public, user is explicit member, or (board shared with org AND user has org-wide access) + const hasAccess = board.isPublic || + !!isExplicitMember || + (board.shareWithOrganization && (userAccess?.hasOrgWideAccess ?? false)); + + if (!hasAccess) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } @@ -53,6 +64,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ board: { ...boardData, + shareWithOrganization: board.shareWithOrganization, organization: { id: organization.id, name: organization.name, @@ -93,7 +105,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ throw error; } - const { name, description, isPublic, sendSlackUpdates } = validatedBody; + const { name, description, isPublic, sendSlackUpdates, shareWithOrganization } = validatedBody; // Check if board exists and user has access const board = await db.board.findUnique({ @@ -121,6 +133,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + // Check if user has board access (is member or org admin) + const isBoardMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!isBoardMember && !currentUser.isAdmin) { + return NextResponse.json({ error: "Only board members or org admins can edit this board" }, { status: 403 }); + } + // For name/description/isPublic updates, check if user can edit this board (board creator or admin) if ( (name !== undefined || description !== undefined || isPublic !== undefined) && @@ -138,11 +162,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ description?: string | null; isPublic?: boolean; sendSlackUpdates?: boolean; + shareWithOrganization?: boolean; } = {}; if (name !== undefined) updateData.name = name.trim() || board.name; if (description !== undefined) updateData.description = description?.trim() || null; if (isPublic !== undefined) updateData.isPublic = isPublic; if (sendSlackUpdates !== undefined) updateData.sendSlackUpdates = sendSlackUpdates; + if (shareWithOrganization !== undefined) updateData.shareWithOrganization = shareWithOrganization; const updatedBoard = await db.board.update({ where: { id: boardId }, @@ -212,6 +238,18 @@ export async function DELETE( return NextResponse.json({ error: "Access denied" }, { status: 403 }); } + // Check if user has board access (is member or org admin) + const isBoardMember = await db.boardMember.findFirst({ + where: { + boardId: boardId, + userId: session.user.id, + }, + }); + + if (!isBoardMember && !currentUser.isAdmin) { + return NextResponse.json({ error: "Only board members or org admins can delete this board" }, { status: 403 }); + } + // Check if user can delete this board (board creator or admin) if (board.createdBy !== session.user.id && !currentUser.isAdmin) { return NextResponse.json( diff --git a/app/api/boards/route.ts b/app/api/boards/route.ts index 0423e7f34..a37441116 100644 --- a/app/api/boards/route.ts +++ b/app/api/boards/route.ts @@ -23,14 +23,37 @@ export async function GET() { return NextResponse.json({ error: "No organization found" }, { status: 404 }); } - // Get all boards for the organization + // Get current user's org access setting + const currentUserAccess = await db.user.findUnique({ + where: { id: session.user.id }, + select: { hasOrgWideAccess: true }, + }); + + // Get boards user has access to based on sharing settings const boards = await db.board.findMany({ - where: { organizationId: user.organizationId }, + where: { + organizationId: user.organizationId, + OR: [ + // User is explicitly a member of the board + { + members: { + some: { + userId: session.user.id, + }, + }, + }, + // Board is shared with organization AND user has org-wide access + { + shareWithOrganization: true, + }, + ], + }, select: { id: true, name: true, description: true, isPublic: true, + shareWithOrganization: true, createdBy: true, createdAt: true, updatedAt: true, @@ -66,6 +89,7 @@ export async function GET() { name: board.name, description: board.description, isPublic: board.isPublic, + shareWithOrganization: board.shareWithOrganization, createdBy: board.createdBy, createdAt: board.createdAt, updatedAt: board.updatedAt, @@ -114,6 +138,11 @@ export async function POST(request: NextRequest) { where: { id: session.user.id }, select: { organizationId: true, + organization: { + select: { + shareAllBoardsByDefault: true, + }, + }, }, }); @@ -121,12 +150,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "No organization found" }, { status: 404 }); } + const shareWithOrganization = user.organization?.shareAllBoardsByDefault ?? true; + // Create new board const board = await db.board.create({ data: { name: trimmedName, description, isPublic: Boolean(isPublic), + shareWithOrganization, organizationId: user.organizationId, createdBy: session.user.id, }, @@ -135,6 +167,7 @@ export async function POST(request: NextRequest) { name: true, description: true, isPublic: true, + shareWithOrganization: true, createdBy: true, createdAt: true, updatedAt: true, @@ -152,6 +185,14 @@ export async function POST(request: NextRequest) { }, }); + // Add creator to board members + await db.boardMember.create({ + data: { + userId: session.user.id, + boardId: board.id, + }, + }); + return NextResponse.json({ board }, { status: 201 }); } catch (error) { console.error("Error creating board:", error); diff --git a/app/api/organization/route.ts b/app/api/organization/route.ts index cf0a252e3..0278f974a 100644 --- a/app/api/organization/route.ts +++ b/app/api/organization/route.ts @@ -4,6 +4,55 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { organizationSchema } from "@/lib/types"; +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await db.user.findUnique({ + where: { id: session.user.id }, + include: { + organization: { + include: { + members: { + select: { + id: true, + name: true, + email: true, + isAdmin: true, + }, + }, + }, + }, + }, + }); + + if (!user?.organization) { + return NextResponse.json({ error: "No organization found" }, { status: 404 }); + } + + return NextResponse.json({ + id: user.id, + name: user.name, + email: user.email, + isAdmin: user.isAdmin, + organization: { + id: user.organization.id, + name: user.organization.name, + slackWebhookUrl: user.organization.slackWebhookUrl, + shareAllBoardsByDefault: user.organization.shareAllBoardsByDefault, + members: user.organization.members, + }, + }); + } catch (error) { + console.error("Error fetching organization:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + export async function PUT(request: NextRequest) { try { const session = await auth(); @@ -27,7 +76,7 @@ export async function PUT(request: NextRequest) { throw error; } - const { name, slackWebhookUrl } = validatedBody; + const { name, slackWebhookUrl, shareAllBoardsByDefault, userAccess } = validatedBody; // Get user with organization const user = await db.user.findUnique({ @@ -54,15 +103,28 @@ export async function PUT(request: NextRequest) { ); } - // Update organization name and Slack webhook URL + // Update organization name, Slack webhook URL, and sharing settings await db.organization.update({ where: { id: user.organizationId }, data: { name: name.trim(), ...(slackWebhookUrl !== undefined && { slackWebhookUrl: slackWebhookUrl?.trim() || null }), + ...(shareAllBoardsByDefault !== undefined && { shareAllBoardsByDefault }), }, }); + // Handle per-user organization access updates + if (userAccess && Array.isArray(userAccess)) { + for (const accessUpdate of userAccess) { + if (accessUpdate.userId && typeof accessUpdate.hasOrgWideAccess === 'boolean') { + await db.user.update({ + where: { id: accessUpdate.userId }, + data: { hasOrgWideAccess: accessUpdate.hasOrgWideAccess }, + }); + } + } + } + // Return updated user data const updatedUser = await db.user.findUnique({ where: { id: session.user.id }, @@ -75,6 +137,7 @@ export async function PUT(request: NextRequest) { name: true, email: true, isAdmin: true, + hasOrgWideAccess: true, }, }, }, @@ -92,6 +155,7 @@ export async function PUT(request: NextRequest) { id: updatedUser!.organization.id, name: updatedUser!.organization.name, slackWebhookUrl: updatedUser!.organization.slackWebhookUrl, + shareAllBoardsByDefault: updatedUser!.organization.shareAllBoardsByDefault, members: updatedUser!.organization.members, } : null, diff --git a/app/boards/[id]/page.tsx b/app/boards/[id]/page.tsx index 6a637d532..7302d7694 100644 --- a/app/boards/[id]/page.tsx +++ b/app/boards/[id]/page.tsx @@ -5,7 +5,9 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { ChevronDown, Search, @@ -99,6 +101,11 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> isPublic: false, sendSlackUpdates: true, }); + const [boardMembers, setBoardMembers] = useState([]); + const [orgMembers, setOrgMembers] = useState([]); + const [filteredOrgMembers, setFilteredOrgMembers] = useState([]); + const [memberSearchTerm, setMemberSearchTerm] = useState(""); + const [loadingOrgMembers, setLoadingOrgMembers] = useState(false); const [copiedPublicUrl, setCopiedPublicUrl] = useState(false); const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(false); const boardRef = useRef(null); @@ -640,6 +647,105 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> } }; + const fetchBoardMembers = async () => { + try { + const response = await fetch(`/api/boards/${boardId}/members`); + if (response.ok) { + const data = await response.json(); + setBoardMembers(data.members || []); + } + } catch (error) { + console.error("Error fetching board members:", error); + } + }; + + + const handleRemoveBoardMember = async (userId: string) => { + try { + const response = await fetch(`/api/boards/${boardId}/members?userId=${userId}`, { + method: "DELETE", + }); + + if (response.ok) { + await fetchBoardMembers(); + } else { + const errorData = await response.json(); + setErrorDialog({ + open: true, + title: "Failed to remove member", + description: errorData.error || "Failed to remove member", + }); + } + } catch (error) { + console.error("Error removing board member:", error); + setErrorDialog({ + open: true, + title: "Failed to remove member", + description: "Failed to remove member", + }); + } + }; + + const fetchOrgMembers = async () => { + setLoadingOrgMembers(true); + try { + const response = await fetch("/api/organization"); + if (response.ok) { + const data = await response.json(); + setOrgMembers(data.organization?.members || []); + } + } catch (error) { + console.error("Error fetching organization members:", error); + } finally { + setLoadingOrgMembers(false); + } + }; + + const handleAddBoardMemberFromOrg = async (email: string) => { + try { + const response = await fetch(`/api/boards/${boardId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + if (response.ok) { + await fetchBoardMembers(); + } else { + const errorData = await response.json(); + setErrorDialog({ + open: true, + title: "Failed to add member", + description: errorData.error || "Failed to add member", + }); + } + } catch (error) { + console.error("Error adding board member:", error); + setErrorDialog({ + open: true, + title: "Failed to add member", + description: "Failed to add member", + }); + } + }; + + const isCurrentUserCreator = (userId: string) => { + return user?.id === userId; + }; + + // Filter org members based on search term + useEffect(() => { + if (memberSearchTerm.trim() === "") { + setFilteredOrgMembers(orgMembers); + } else { + const filtered = orgMembers.filter(member => + (member.name || "").toLowerCase().includes(memberSearchTerm.toLowerCase()) || + member.email.toLowerCase().includes(memberSearchTerm.toLowerCase()) + ); + setFilteredOrgMembers(filtered); + } + }, [orgMembers, memberSearchTerm]); + const handleDeleteBoard = async () => { try { const response = await fetch(`/api/boards/${boardId}`, { @@ -819,6 +925,8 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }> (board as { sendSlackUpdates?: boolean })?.sendSlackUpdates ?? true, }); setBoardSettingsDialog(true); + fetchBoardMembers(); + fetchOrgMembers(); }} aria-label="Board settings" title="Board settings" @@ -1138,8 +1246,126 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }>

When enabled, anyone with the link can view this board

+ + + {/* Organization Members Management */} +
+
+

+ Organization Members ({orgMembers.length}) +

+ +
+ + {/* Search Input */} +
+ + setMemberSearchTerm(e.target.value)} + className="pl-10 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700" + /> +
- {boardSettings.isPublic && ( + {/* Members List */} +
+ {filteredOrgMembers.length === 0 ? ( +

+ {orgMembers.length === 0 ? "No organization members found." : "No members match your search."} +

+ ) : ( + filteredOrgMembers.map((member) => { + const isBoardMember = boardMembers.some(bm => bm.user.id === member.id); + const isCreator = member.id === (board as any)?.createdBy; + + return ( +
+
+ + + + {member.name + ? member.name.charAt(0).toUpperCase() + : member.email.charAt(0).toUpperCase()} + + +
+
+

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

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

{member.email}

+
+
+
+ { + if (checked) { + handleAddBoardMemberFromOrg(member.email); + } else if (!isCreator) { + handleRemoveBoardMember(member.id); + } + }} + disabled={isCreator} + className="data-[state=checked]:bg-green-600" + /> + +
+
+ ); + }) + )} +
+ + {/* Summary */} +
+ + {boardMembers.length} of {orgMembers.length} members have access + + {boardMembers.length > 0 && ( + + {boardMembers.length} shared + + )} +
+
+ + {boardSettings.isPublic && (
@@ -1194,9 +1420,8 @@ export default function BoardPage({ params }: { params: Promise<{ id: string }>

When enabled, note updates will be sent to your organization's Slack channel

-
- +
+
+ + +
+

+ When enabled, all new boards will be automatically shared with all organization members. You can still control sharing for individual boards in their settings. +

+
- {user?.organization?.members?.map((member) => ( + {user?.organization?.members?.map((member: UserWithAccess) => (
+ {/* Organization Access Toggle */} + {user?.isAdmin && ( +
+ { + // Update local state + const existingUpdateIndex = userAccessUpdates.findIndex(update => update.userId === member.id); + if (existingUpdateIndex >= 0) { + const updated = [...userAccessUpdates]; + updated[existingUpdateIndex] = { userId: member.id, hasOrgWideAccess: checked }; + setUserAccessUpdates(updated); + } else { + setUserAccessUpdates([...userAccessUpdates, { userId: member.id, hasOrgWideAccess: checked }]); + } + }} + className="data-[state=checked]:bg-green-600" + /> + +
+ )} + {/* Only show admin toggle to current admins and not for yourself */} {user?.isAdmin && member.id !== user.id && ( +
+
+ updateEmail(index, e.target.value)} + className="flex-1" + /> + {teamEmails.length > 1 && ( + + )} +
+ {email.trim() && email.includes("@") && ( +
+ updateEmailAccess(index, checked)} + className="data-[state=checked]:bg-blue-600" + /> + +
)}
))} @@ -113,6 +142,22 @@ export default function OrganizationSetupForm({ onSubmit }: OrganizationSetupFor

+
+
+ + +
+

+ When enabled, all new boards will be automatically shared with all organization members. You can still control sharing for individual boards later. +

+
+ diff --git a/app/setup/organization/page.tsx b/app/setup/organization/page.tsx index 0751efa1a..08645600d 100644 --- a/app/setup/organization/page.tsx +++ b/app/setup/organization/page.tsx @@ -10,7 +10,7 @@ import { getBaseUrl } from "@/lib/utils"; const resend = new Resend(env.AUTH_RESEND_KEY); -async function createOrganization(orgName: string, teamEmails: string[]) { +async function createOrganization(orgName: string, teamEmails: string[], shareAllBoardsByDefault: boolean = true, emailAccess?: boolean[]) { "use server"; const baseUrl = getBaseUrl(await headers()); @@ -26,6 +26,7 @@ async function createOrganization(orgName: string, teamEmails: string[]) { const organization = await db.organization.create({ data: { name: orgName.trim(), + shareAllBoardsByDefault, }, }); diff --git a/components/note.tsx b/components/note.tsx index 5333a0975..dc833f76f 100644 --- a/components/note.tsx +++ b/components/note.tsx @@ -28,6 +28,9 @@ export interface Board { id: string; name: string; description: string | null; + shareWithOrganization?: boolean; + isPublic?: boolean; + sendSlackUpdates?: boolean; } export interface Note { diff --git a/lib/types/index.ts b/lib/types/index.ts index 16ae46b70..76711c15f 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -23,11 +23,17 @@ export const boardSchema = z.object({ description: z.string().optional(), isPublic: z.boolean().optional(), sendSlackUpdates: z.boolean().optional(), + shareWithOrganization: z.boolean().optional(), }); export const organizationSchema = z.object({ name: z.string().min(1, "Organization name is required"), slackWebhookUrl: z.string().optional(), + shareAllBoardsByDefault: z.boolean().optional(), + userAccess: z.array(z.object({ + userId: z.string(), + hasOrgWideAccess: z.boolean(), + })).optional(), }); export const profileSchema = z.object({ diff --git a/prisma/migrations/20251005130724_add_board_sharing/migration.sql b/prisma/migrations/20251005130724_add_board_sharing/migration.sql new file mode 100644 index 000000000..510a547ed --- /dev/null +++ b/prisma/migrations/20251005130724_add_board_sharing/migration.sql @@ -0,0 +1,31 @@ +-- AlterTable +ALTER TABLE "boards" ADD COLUMN "shareWithOrganization" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "shareAllBoardsByDefault" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "board_members" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "board_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "board_members_userId_idx" ON "board_members"("userId"); + +-- CreateIndex +CREATE INDEX "board_members_boardId_idx" ON "board_members"("boardId"); + +-- CreateIndex +CREATE UNIQUE INDEX "board_members_userId_boardId_key" ON "board_members"("userId", "boardId"); + +-- AddForeignKey +ALTER TABLE "board_members" ADD CONSTRAINT "board_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "board_members" ADD CONSTRAINT "board_members_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "boards"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251005141607_add_user_org_access_control/migration.sql b/prisma/migrations/20251005141607_add_user_org_access_control/migration.sql new file mode 100644 index 000000000..558e8ba42 --- /dev/null +++ b/prisma/migrations/20251005141607_add_user_org_access_control/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "hasOrgWideAccess" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b75393ca6..ab50c6a67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,8 @@ model User { invitedOrganizations OrganizationInvite[] createdSelfServeInvites OrganizationSelfServeInvite[] notes Note[] + boardMemberships BoardMember[] + hasOrgWideAccess Boolean @default(true) // Controls organization-wide board access @@index([organizationId], name: "idx_user_org") @@map("users") @@ -72,6 +74,7 @@ model Organization { id String @id @default(cuid()) name String slackWebhookUrl String? + shareAllBoardsByDefault Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt members User[] @@ -88,12 +91,14 @@ model Board { description String? isPublic Boolean @default(false) sendSlackUpdates Boolean @default(true) + shareWithOrganization Boolean @default(true) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt notes Note[] + members BoardMember[] // Performance indexes @@index([organizationId, createdAt], name: "idx_board_org_created") @@ -137,6 +142,22 @@ model ChecklistItem { @@index([noteId, order]) } +model BoardMember { + id String @id @default(cuid()) + userId String + boardId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + + @@unique([userId, boardId]) + @@index([userId]) + @@index([boardId]) + @@map("board_members") +} + model OrganizationInvite { id String @id @default(cuid()) email String