diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts new file mode 100644 index 00000000000..84d3d7c618f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -0,0 +1,178 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema' + +// Extract the enum type from Drizzle schema +type PermissionType = (typeof permissionTypeEnum.enumValues)[number] + +interface UpdatePermissionsRequest { + updates: Array<{ + userId: string + permissions: PermissionType // Single permission type instead of object with booleans + }> +} + +// Helper function to fetch users with permissions for a workspace +async function getUsersWithPermissions(workspaceId: string) { + const usersWithPermissions = await db + .select({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + permissionType: permissions.permissionType, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + .orderBy(user.email) + + // Since each user has only one permission, we can use the results directly + return usersWithPermissions.map((row) => ({ + userId: row.userId, + email: row.email, + name: row.name, + image: row.image, + permissionType: row.permissionType, + })) +} + +/** + * GET /api/workspaces/[id]/permissions + * + * Retrieves all users who have permissions for the specified workspace. + * Returns user details along with their specific permissions. + * + * @param workspaceId - The workspace ID from the URL parameters + * @returns Array of users with their permissions for the workspace + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: workspaceId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + // Verify the current user has access to this workspace + const userMembership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .limit(1) + + if (userMembership.length === 0) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) + } + + const result = await getUsersWithPermissions(workspaceId) + + return NextResponse.json({ + users: result, + total: result.length, + }) + } catch (error) { + console.error('Error fetching workspace permissions:', error) + return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 }) + } +} + +/** + * PATCH /api/workspaces/[id]/permissions + * + * Updates permissions for existing workspace members. + * Only admin users can update permissions. + * + * @param workspaceId - The workspace ID from the URL parameters + * @param updates - Array of permission updates for users + * @returns Success message or error + */ +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: workspaceId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + // Verify the current user has admin access to this workspace + const userPermissions = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.userId, session.user.id), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.permissionType, 'admin') + ) + ) + .limit(1) + + if (userPermissions.length === 0) { + return NextResponse.json( + { error: 'Admin access required to update permissions' }, + { status: 403 } + ) + } + + // Parse and validate request body + const body: UpdatePermissionsRequest = await request.json() + + // Prevent users from modifying their own admin permissions + const selfUpdate = body.updates.find((update) => update.userId === session.user.id) + if (selfUpdate && selfUpdate.permissions !== 'admin') { + return NextResponse.json( + { error: 'Cannot remove your own admin permissions' }, + { status: 400 } + ) + } + + // Process updates in a transaction + await db.transaction(async (tx) => { + for (const update of body.updates) { + // Delete existing permissions for this user and workspace + await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, update.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + + // Insert the single new permission + await tx.insert(permissions).values({ + id: crypto.randomUUID(), + userId: update.userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: update.permissions, + createdAt: new Date(), + updatedAt: new Date(), + }) + } + }) + + const updatedUsers = await getUsersWithPermissions(workspaceId) + + return NextResponse.json({ + message: 'Permissions updated successfully', + users: updatedUsers, + total: updatedUsers.length, + }) + } catch (error) { + console.error('Error updating workspace permissions:', error) + return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index ab6c0cadc44..7c2ba26f247 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -1,8 +1,9 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getUserEntityPermissions } from '@/lib/permissions/utils' import { db } from '@/db' -import { workspace, workspaceMember } from '@/db/schema' +import { permissions, workspace } from '@/db/schema' export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params @@ -14,16 +15,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const workspaceId = id - // Check if user is a member of this workspace - const membership = await db - .select() - .from(workspaceMember) - .where( - and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, session.user.id)) - ) - .then((rows) => rows[0]) - - if (!membership) { + // Check if user has read access to this workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'read') { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } @@ -41,7 +35,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ workspace: { ...workspaceDetails, - role: membership.role, + permissions: userPermission, }, }) } @@ -56,21 +50,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const workspaceId = id - // Check if user is a member with appropriate permissions - const membership = await db - .select() - .from(workspaceMember) - .where( - and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, session.user.id)) - ) - .then((rows) => rows[0]) - - if (!membership) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } - - // For now, only allow owners to update workspace - if (membership.role !== 'owner') { + // Check if user has admin permissions to update workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin') { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } @@ -100,7 +82,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ workspace: { ...updatedWorkspace, - role: membership.role, + permissions: userPermission, }, }) } catch (error) { @@ -122,22 +104,23 @@ export async function DELETE( const workspaceId = id - // Check if user is the owner - const membership = await db - .select() - .from(workspaceMember) - .where( - and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, session.user.id)) - ) - .then((rows) => rows[0]) - - if (!membership || membership.role !== 'owner') { + // Check if user has admin permissions to delete workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin') { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } try { - // Delete workspace (cascade will handle members) - await db.delete(workspace).where(eq(workspace.id, workspaceId)) + // Use a transaction to ensure data consistency + await db.transaction(async (tx) => { + // 1. Delete all permissions associated with this workspace + await tx + .delete(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + // 2. Delete workspace (cascade will handle members, workflows, etc.) + await tx.delete(workspace).where(eq(workspace.id, workspaceId)) + }) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts index 030e4ad1d4e..2b30618005a 100644 --- a/apps/sim/app/api/workspaces/invitations/accept/route.ts +++ b/apps/sim/app/api/workspaces/invitations/accept/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/env' import { db } from '@/db' -import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' +import { permissions, user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' // Accept an invitation via token export async function GET(req: NextRequest) { @@ -153,24 +153,44 @@ export async function GET(req: NextRequest) { ) } - // Add user to workspace - await db.insert(workspaceMember).values({ - id: randomUUID(), - workspaceId: invitation.workspaceId, - userId: session.user.id, - role: invitation.role, - joinedAt: new Date(), - updatedAt: new Date(), - }) - - // Mark invitation as accepted - await db - .update(workspaceInvitation) - .set({ - status: 'accepted', + // Add user to workspace, permissions, and mark invitation as accepted in a transaction + await db.transaction(async (tx) => { + // Add user to workspace + await tx.insert(workspaceMember).values({ + id: randomUUID(), + workspaceId: invitation.workspaceId, + userId: session.user.id, + role: invitation.role, + joinedAt: new Date(), updatedAt: new Date(), }) - .where(eq(workspaceInvitation.id, invitation.id)) + + // Create permissions for the user + const permissionsToInsert = [ + { + id: randomUUID(), + entityType: 'workspace' as const, + entityId: invitation.workspaceId, + userId: session.user.id, + permissionType: invitation.permissions || 'read', + createdAt: new Date(), + updatedAt: new Date(), + }, + ] + + if (permissionsToInsert.length > 0) { + await tx.insert(permissions).values(permissionsToInsert) + } + + // Mark invitation as accepted + await tx + .update(workspaceInvitation) + .set({ + status: 'accepted', + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + }) // Redirect to the workspace return NextResponse.redirect( diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 1e9ab928e8a..6c7795f3549 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -9,13 +9,22 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { getEmailDomain } from '@/lib/urls/utils' import { db } from '@/db' -import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' +import { + type permissionTypeEnum, + user, + workspace, + workspaceInvitation, + workspaceMember, +} from '@/db/schema' export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceInvitationsAPI') const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null +// Define the permission type +type PermissionType = (typeof permissionTypeEnum.enumValues)[number] + // Get all invitations for the user's workspaces export async function GET(req: NextRequest) { const session = await getSession() @@ -66,12 +75,21 @@ export async function POST(req: NextRequest) { } try { - const { workspaceId, email, role = 'member' } = await req.json() + const { workspaceId, email, role = 'member', permission = 'read' } = await req.json() if (!workspaceId || !email) { return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) } + // Validate permission type + const validPermissions: PermissionType[] = ['admin', 'write', 'read'] + if (!validPermissions.includes(permission)) { + return NextResponse.json( + { error: `Invalid permission: must be one of ${validPermissions.join(', ')}` }, + { status: 400 } + ) + } + // Check if user is authorized to invite to this workspace (must be owner) const membership = await db .select() @@ -160,22 +178,22 @@ export async function POST(req: NextRequest) { expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry // Create the invitation - const invitation = await db - .insert(workspaceInvitation) - .values({ - id: randomUUID(), - workspaceId, - email, - inviterId: session.user.id, - role, - status: 'pending', - token, - expiresAt, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - .then((rows) => rows[0]) + const invitationData = { + id: randomUUID(), + workspaceId, + email, + inviterId: session.user.id, + role, + status: 'pending', + token, + permissions: permission, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + } + + // Create invitation + await db.insert(workspaceInvitation).values(invitationData) // Send the invitation email await sendInvitationEmail({ @@ -185,7 +203,7 @@ export async function POST(req: NextRequest) { token: token, }) - return NextResponse.json({ success: true, invitation }) + return NextResponse.json({ success: true, invitation: invitationData }) } catch (error) { console.error('Error creating workspace invitation:', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index c3056e8db0b..57febd2cdae 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -4,80 +4,6 @@ import { getSession } from '@/lib/auth' import { db } from '@/db' import { workspaceMember } from '@/db/schema' -// Update a member's role -export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membershipId = id - - try { - const { role } = await req.json() - - if (!role) { - return NextResponse.json({ error: 'Role is required' }, { status: 400 }) - } - - // Get the membership to update - const membership = await db - .select({ - id: workspaceMember.id, - workspaceId: workspaceMember.workspaceId, - userId: workspaceMember.userId, - role: workspaceMember.role, - }) - .from(workspaceMember) - .where(eq(workspaceMember.id, membershipId)) - .then((rows) => rows[0]) - - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) - } - - // Check if current user is an owner of the workspace - const currentUserMembership = await db - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, membership.workspaceId), - eq(workspaceMember.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (!currentUserMembership || currentUserMembership.role !== 'owner') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - // Prevent changing your own role if you're the owner - if (membership.userId === session.user.id && membership.role === 'owner') { - return NextResponse.json( - { error: 'Cannot change the role of the workspace owner' }, - { status: 400 } - ) - } - - // Update the role - await db - .update(workspaceMember) - .set({ - role, - updatedAt: new Date(), - }) - .where(eq(workspaceMember.id, membershipId)) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error('Error updating workspace member:', error) - return NextResponse.json({ error: 'Failed to update workspace member' }, { status: 500 }) - } -} - // DELETE /api/workspaces/members/[id] - Remove a member from a workspace export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params diff --git a/apps/sim/app/api/workspaces/members/route.ts b/apps/sim/app/api/workspaces/members/route.ts index 82e0cfe0483..2820fa1dd98 100644 --- a/apps/sim/app/api/workspaces/members/route.ts +++ b/apps/sim/app/api/workspaces/members/route.ts @@ -2,7 +2,49 @@ import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { db } from '@/db' -import { user, workspaceMember } from '@/db/schema' +import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema' + +// Extract the enum type from Drizzle schema +type PermissionType = (typeof permissionTypeEnum.enumValues)[number] + +/** + * Helper function to check if a user has admin permission for a workspace + */ +async function hasAdminPermission(userId: string, workspaceId: string): Promise { + const result = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.permissionType, 'admin') + ) + ) + .limit(1) + + return result.length > 0 +} + +/** + * Helper function to create default permissions for a new member + */ +async function createMemberPermissions( + userId: string, + workspaceId: string, + memberPermission: PermissionType = 'read' +): Promise { + await db.insert(permissions).values({ + id: crypto.randomUUID(), + userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: memberPermission, + createdAt: new Date(), + updatedAt: new Date(), + }) +} // Add a member to a workspace export async function POST(req: Request) { @@ -13,7 +55,7 @@ export async function POST(req: Request) { } try { - const { workspaceId, userEmail, role = 'member' } = await req.json() + const { workspaceId, userEmail, permission = 'read' } = await req.json() if (!workspaceId || !userEmail) { return NextResponse.json( @@ -22,19 +64,19 @@ export async function POST(req: Request) { ) } - // Check if current user is an owner or admin of the workspace - const currentUserMembership = await db - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, session.user.id) - ) + // Validate permission type + const validPermissions: PermissionType[] = ['admin', 'write', 'read'] + if (!validPermissions.includes(permission)) { + return NextResponse.json( + { error: `Invalid permission: must be one of ${validPermissions.join(', ')}` }, + { status: 400 } ) - .then((rows) => rows[0]) + } + + // Check if current user has admin permission for the workspace + const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) - if (!currentUserMembership || currentUserMembership.role !== 'owner') { + if (!hasAdmin) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } @@ -49,33 +91,53 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - // Check if user is already a member - const existingMembership = await db + // Check if user already has permissions for this workspace + const existingPermissions = await db .select() - .from(workspaceMember) + .from(permissions) .where( - and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, targetUser.id)) + and( + eq(permissions.userId, targetUser.id), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - .then((rows) => rows[0]) - if (existingMembership) { + if (existingPermissions.length > 0) { return NextResponse.json( - { error: 'User is already a member of this workspace' }, + { error: 'User already has permissions for this workspace' }, { status: 400 } ) } - // Add user to workspace - await db.insert(workspaceMember).values({ - id: crypto.randomUUID(), - workspaceId, - userId: targetUser.id, - role, - joinedAt: new Date(), - updatedAt: new Date(), + // Use a transaction to ensure data consistency + await db.transaction(async (tx) => { + // Add user to workspace members table (keeping for compatibility) + await tx.insert(workspaceMember).values({ + id: crypto.randomUUID(), + workspaceId, + userId: targetUser.id, + role: 'member', // Default role for compatibility + joinedAt: new Date(), + updatedAt: new Date(), + }) + + // Create single permission for the new member + await tx.insert(permissions).values({ + id: crypto.randomUUID(), + userId: targetUser.id, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: permission, + createdAt: new Date(), + updatedAt: new Date(), + }) }) - return NextResponse.json({ success: true }) + return NextResponse.json({ + success: true, + message: `User added to workspace with ${permission} permission`, + }) } catch (error) { console.error('Error adding workspace member:', error) return NextResponse.json({ error: 'Failed to add workspace member' }, { status: 500 }) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 50236cafc5f..310d9d309e4 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -2,7 +2,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { db } from '@/db' -import { workflow, workspace, workspaceMember } from '@/db/schema' +import { permissions, workflow, workspace, workspaceMember } from '@/db/schema' // Get all workspaces for the current user export async function GET() { @@ -98,6 +98,17 @@ async function createWorkspace(userId: string, name: string) { updatedAt: new Date(), }) + // Create default permissions for the workspace owner + await db.insert(permissions).values({ + id: crypto.randomUUID(), + entityType: 'workspace' as const, + entityId: workspaceId, + userId: userId, + permissionType: 'admin' as const, + createdAt: new Date(), + updatedAt: new Date(), + }) + // Return the workspace data directly instead of querying again return { id: workspaceId, diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx index 1dc67e4df0b..cf1571a1194 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Rocket } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { DeployModal } from '../deploy-modal/deploy-modal' @@ -16,6 +17,7 @@ interface DeploymentControlsProps { deployedState: WorkflowState | null isLoadingDeployedState: boolean refetchDeployedState: () => Promise + userPermissions: WorkspaceUserPermissions } export function DeploymentControls({ @@ -25,6 +27,7 @@ export function DeploymentControls({ deployedState, isLoadingDeployedState, refetchDeployedState, + userPermissions, }: DeploymentControlsProps) { const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(activeWorkflowId) @@ -52,6 +55,31 @@ export function DeploymentControls({ } catch (error) {} } + const canDeploy = userPermissions.canAdmin + const isDisabled = isDeploying || !canDeploy + + const handleDeployClick = useCallback(() => { + if (canDeploy) { + setIsModalOpen(true) + } + }, [canDeploy, setIsModalOpen]) + + const getTooltipText = () => { + if (!canDeploy) { + return 'Admin permissions required to deploy workflows as API' + } + if (isDeploying) { + return 'Deploying...' + } + if (isDeployed && workflowNeedsRedeployment) { + return 'Workflow changes detected' + } + if (isDeployed) { + return 'Deployment Settings' + } + return 'Deploy as API' + } + return ( <> @@ -60,9 +88,13 @@ export function DeploymentControls({ - - - Delete Workflow - + const renderDeleteButton = () => { + const canEdit = userPermissions.canEdit + const hasMultipleWorkflows = Object.keys(workflows).length > 1 + const isDisabled = !canEdit || !hasMultipleWorkflows + + const getTooltipText = () => { + if (!canEdit) return 'Edit permissions required to delete workflows' + if (!hasMultipleWorkflows) return 'Cannot delete the last workflow' + return 'Delete Workflow' + } - - - Delete Workflow - - Are you sure you want to delete this workflow? This action cannot be undone. - - - - Cancel - - Delete - - - - - ) + return ( + + + + + + + + {getTooltipText()} + + + + + Delete Workflow + + Are you sure you want to delete this workflow? This action cannot be undone. + + + + Cancel + + Delete + + + + + ) + } /** * Render deploy button with tooltip @@ -655,6 +707,7 @@ export function ControlBar() { deployedState={deployedState} isLoadingDeployedState={isLoadingDeployedState} refetchDeployedState={fetchDeployedState} + userPermissions={userPermissions} /> ) @@ -805,35 +858,44 @@ export function ControlBar() { /** * Render workflow duplicate button */ - const renderDuplicateButton = () => ( - - - - - Duplicate Workflow - - ) + const renderDuplicateButton = () => { + const canEdit = userPermissions.canEdit + + return ( + + + + + + {canEdit ? 'Duplicate Workflow' : 'Edit permissions required to duplicate workflows'} + + + ) + } /** * Render auto-layout button */ const renderAutoLayoutButton = () => { const handleAutoLayoutClick = () => { - if (isExecuting || isMultiRunning || isDebugging) { + if (isExecuting || isMultiRunning || isDebugging || !userPermissions.canEdit) { return } window.dispatchEvent(new CustomEvent('trigger-auto-layout')) } + const isDisabled = isExecuting || isMultiRunning || isDebugging || !userPermissions.canEdit + return ( @@ -841,14 +903,18 @@ export function ControlBar() { variant='ghost' size='icon' onClick={handleAutoLayoutClick} - className='hover:text-primary' - disabled={isExecuting || isMultiRunning || isDebugging} + className={cn('hover:text-primary', isDisabled && 'cursor-not-allowed opacity-50')} + disabled={isDisabled} > Auto Layout - Auto Layout + + {!userPermissions.canEdit + ? 'Edit permissions required to use auto-layout' + : 'Auto Layout'} + ) } @@ -918,7 +984,11 @@ export function ControlBar() { * Render debug mode toggle button */ const renderDebugModeToggle = () => { + const canDebug = userPermissions.canRead // Debug mode now requires only read permissions + const handleToggleDebugMode = () => { + if (!canDebug) return + if (isDebugModeEnabled) { if (!isExecuting) { useExecutionStore.getState().setIsDebugging(false) @@ -935,133 +1005,65 @@ export function ControlBar() { variant='ghost' size='icon' onClick={handleToggleDebugMode} - disabled={isExecuting || isMultiRunning} - className={cn(isDebugModeEnabled && 'text-amber-500')} + disabled={isExecuting || isMultiRunning || !canDebug} + className={cn( + isDebugModeEnabled && 'text-amber-500', + !canDebug && 'cursor-not-allowed opacity-50' + )} > Toggle Debug Mode - {isDebugModeEnabled ? 'Disable Debug Mode' : 'Enable Debug Mode'} + {!canDebug + ? 'Read permissions required to use debug mode' + : isDebugModeEnabled + ? 'Disable Debug Mode' + : 'Enable Debug Mode'} ) } - // Helper function to open subscription settings - const openSubscriptionSettings = () => { - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { - detail: { tab: 'subscription' }, - }) - ) - } - } - /** * Render run workflow button with multi-run dropdown and cancel button */ - const renderRunButton = () => ( -
- {showRunProgress && isMultiRunning && ( -
- -

- {completedRuns}/{runCount} runs -

-
- )} + const renderRunButton = () => { + const canRun = userPermissions.canRead // Running only requires read permissions + const isLoadingPermissions = userPermissions.isLoading + const isButtonDisabled = + isExecuting || isMultiRunning || isCancelling || (!canRun && !isLoadingPermissions) - {/* Show how many blocks have been executed in debug mode if debugging */} - {isDebugging && ( -
-
- Debugging Mode + return ( +
+ {showRunProgress && isMultiRunning && ( +
+ +

+ {completedRuns}/{runCount} runs +

-
- )} + )} - {renderDebugControls()} + {/* Show how many blocks have been executed in debug mode if debugging */} + {isDebugging && ( +
+
+ Debugging Mode +
+
+ )} -
- {/* Main Run/Debug Button */} - - - - - - {usageExceeded ? ( -
-

Usage Limit Exceeded

-

- You've used {usageData?.currentUsage.toFixed(2)}$ of {usageData?.limit}$. Upgrade - your plan to continue. -

-
- ) : ( - <> - {isDebugModeEnabled - ? 'Debug Workflow' - : runCount === 1 - ? 'Run Workflow' - : `Run Workflow ${runCount} times`} - - )} -
-
+ {renderDebugControls()} - {/* Dropdown Trigger - Only show when not in debug mode and not multi-running */} - {!isDebugModeEnabled && !isMultiRunning && ( - - +
+ {/* Main Run/Debug Button */} + + - - - {RUN_COUNT_OPTIONS.map((count) => ( - setRunCount(count)} - className={cn('justify-center', runCount === count && 'bg-muted')} - > - {count} - - ))} - - - )} - - {/* Cancel Button - Only show when multi-running */} - {isMultiRunning && ( - - - - {runCount > 1 ? 'Cancel Runs' : 'Cancel Run'} + + {!canRun && !isLoadingPermissions ? ( + 'Read permissions required to run workflows' + ) : usageExceeded ? ( +
+

Usage Limit Exceeded

+

+ You've used {usageData?.currentUsage.toFixed(2)}$ of {usageData?.limit}$. + Upgrade your plan to continue. +

+
+ ) : ( + <> + {isDebugModeEnabled + ? 'Debug Workflow' + : runCount === 1 + ? 'Run Workflow' + : `Run Workflow ${runCount} times`} + + )} +
- )} + + {/* Dropdown Trigger - Only show when not in debug mode and not multi-running */} + {!isDebugModeEnabled && !isMultiRunning && ( + + + + + + {RUN_COUNT_OPTIONS.map((count) => ( + setRunCount(count)} + className={cn('justify-center', runCount === count && 'bg-muted')} + > + {count} + + ))} + + + )} + + {/* Cancel Button - Only show when multi-running */} + {isMultiRunning && ( + + + + + Cancel Runs + + )} +
-
- ) + ) + } return (
diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx index 2220ea7c966..b625fa40403 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx @@ -1,19 +1,26 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { BlockConfig } from '@/blocks/types' export type ToolbarBlockProps = { config: BlockConfig + disabled?: boolean } -export function ToolbarBlock({ config }: ToolbarBlockProps) { +export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } e.dataTransfer.setData('application/json', JSON.stringify({ type: config.type })) e.dataTransfer.effectAllowed = 'move' } // Handle click to add block const handleClick = useCallback(() => { - if (config.type === 'connectionBlock') return + if (config.type === 'connectionBlock' || disabled) return // Dispatch a custom event to be caught by the workflow component const event = new CustomEvent('add-block-from-toolbar', { @@ -22,23 +29,30 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) { }, }) window.dispatchEvent(event) - }, [config.type]) + }, [config.type, disabled]) - return ( + const blockContent = (
@@ -47,4 +61,15 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx index d07ca5e5713..6097e644273 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx @@ -1,9 +1,19 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { LoopTool } from '../../../loop-node/loop-config' +type LoopToolbarItemProps = { + disabled?: boolean +} + // Custom component for the Loop Tool -export default function LoopToolbarItem() { +export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } // Only send the essential data for the loop node const simplifiedData = { type: 'loop', @@ -13,30 +23,45 @@ export default function LoopToolbarItem() { } // Handle click to add loop block - const handleClick = useCallback((e: React.MouseEvent) => { - // Dispatch a custom event to be caught by the workflow component - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: 'loop', - clientX: e.clientX, - clientY: e.clientY, - }, - }) - window.dispatchEvent(event) - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (disabled) return + + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'loop', + clientX: e.clientX, + clientY: e.clientY, + }, + }) + window.dispatchEvent(event) + }, + [disabled] + ) - return ( + const blockContent = (
- +

{LoopTool.name}

@@ -44,4 +69,15 @@ export default function LoopToolbarItem() {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx index 9f277ba67ba..08c732dacbb 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx @@ -1,9 +1,19 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { ParallelTool } from '../../../parallel-node/parallel-config' +type ParallelToolbarItemProps = { + disabled?: boolean +} + // Custom component for the Parallel Tool -export default function ParallelToolbarItem() { +export default function ParallelToolbarItem({ disabled = false }: ParallelToolbarItemProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } // Only send the essential data for the parallel node const simplifiedData = { type: 'parallel', @@ -13,31 +23,46 @@ export default function ParallelToolbarItem() { } // Handle click to add parallel block - const handleClick = useCallback((e: React.MouseEvent) => { - // Dispatch a custom event to be caught by the workflow component - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: 'parallel', - clientX: e.clientX, - clientY: e.clientY, - }, - bubbles: true, - }) - window.dispatchEvent(event) - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (disabled) return + + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'parallel', + clientX: e.clientX, + clientY: e.clientY, + }, + bubbles: true, + }) + window.dispatchEvent(event) + }, + [disabled] + ) - return ( + const blockContent = (
- +

{ParallelTool.name}

@@ -45,4 +70,15 @@ export default function ParallelToolbarItem() {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx index a5b6d6a1746..5a257b81b48 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx @@ -2,18 +2,33 @@ import { useMemo, useState } from 'react' import { PanelLeftClose, PanelRight, Search } from 'lucide-react' +import { useParams } from 'next/navigation' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { getAllBlocks, getBlocksByCategory } from '@/blocks' import type { BlockCategory } from '@/blocks/types' +import { useUserPermissions } from '@/hooks/use-user-permissions' import { useSidebarStore } from '@/stores/sidebar/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { ToolbarBlock } from './components/toolbar-block/toolbar-block' import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block' import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' export function Toolbar() { + const params = useParams() + const workflowId = params?.id as string + + // Get the workspace ID from the workflow registry + const activeWorkspaceId = useWorkflowRegistry((state) => state.activeWorkspaceId) + const currentWorkflow = useWorkflowRegistry((state) => + workflowId ? state.workflows[workflowId] : null + ) + const workspaceId = currentWorkflow?.workspaceId || activeWorkspaceId + + const userPermissions = useUserPermissions(workspaceId) + const [activeTab, setActiveTab] = useState('blocks') const [searchQuery, setSearchQuery] = useState('') const { mode, isExpanded } = useSidebarStore() @@ -87,12 +102,12 @@ export function Toolbar() {
{blocks.map((block) => ( - + ))} {activeTab === 'blocks' && !searchQuery && ( <> - - + + )}
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx index 7a626eab205..aa0d6ac2e34 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx @@ -7,9 +7,10 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface ActionBarProps { blockId: string blockType: string + disabled?: boolean } -export function ActionBar({ blockId, blockType }: ActionBarProps) { +export function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { const removeBlock = useWorkflowStore((state) => state.removeBlock) const toggleBlockEnabled = useWorkflowStore((state) => state.toggleBlockEnabled) const toggleBlockHandles = useWorkflowStore((state) => state.toggleBlockHandles) @@ -52,13 +53,20 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { - {isEnabled ? 'Disable Block' : 'Enable Block'} + + {disabled ? 'Read-only mode' : isEnabled ? 'Disable Block' : 'Enable Block'} + {!isStarterBlock && ( @@ -67,13 +75,20 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { - Duplicate Block + + {disabled ? 'Read-only mode' : 'Duplicate Block'} + )} @@ -82,8 +97,13 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { - {horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports'} + {disabled ? 'Read-only mode' : horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports'} @@ -103,13 +123,23 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { - Delete Block + + {disabled ? 'Read-only mode' : 'Delete Block'} + )}
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx index 10accd8d66d..baf322f53e7 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx @@ -1,10 +1,12 @@ import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' import { type ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections' import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface ConnectionBlocksProps { blockId: string setIsConnecting: (isConnecting: boolean) => void + isDisabled?: boolean } interface ResponseField { @@ -13,7 +15,11 @@ interface ResponseField { description?: string } -export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksProps) { +export function ConnectionBlocks({ + blockId, + setIsConnecting, + isDisabled = false, +}: ConnectionBlocksProps) { const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId) if (!hasIncomingConnections) return null @@ -23,6 +29,11 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP connection: ConnectedBlock, field?: ResponseField ) => { + if (isDisabled) { + e.preventDefault() + return + } + e.stopPropagation() // Prevent parent drag handlers from firing setIsConnecting(true) e.dataTransfer.setData( @@ -127,10 +138,15 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP return ( handleDragStart(e, connection, field)} onDragEnd={handleDragEnd} - className='group flex w-max cursor-grab items-center rounded-lg border bg-card p-2 shadow-sm transition-colors hover:bg-accent/50 active:cursor-grabbing' + className={cn( + 'group flex w-max items-center rounded-lg border bg-card p-2 shadow-sm transition-colors', + !isDisabled + ? 'cursor-grab hover:bg-accent/50 active:cursor-grabbing' + : 'cursor-not-allowed opacity-60' + )} >
{displayName} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx index 842462abfb5..72f0deb46b7 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx @@ -11,6 +11,7 @@ interface CheckboxListProps { layout?: 'full' | 'half' isPreview?: boolean subBlockValues?: Record + disabled?: boolean } export function CheckboxList({ @@ -21,6 +22,7 @@ export function CheckboxList({ layout, isPreview = false, subBlockValues, + disabled = false, }: CheckboxListProps) { return (
@@ -35,8 +37,8 @@ export function CheckboxList({ const value = isPreview ? previewValue : storeValue const handleChange = (checked: boolean) => { - // Only update store when not in preview mode - if (!isPreview) { + // Only update store when not in preview mode or disabled + if (!isPreview && !disabled) { setStoreValue(checked) } } @@ -47,7 +49,7 @@ export function CheckboxList({ id={`${blockId}-${option.id}`} checked={Boolean(value)} onCheckedChange={handleChange} - disabled={isPreview} + disabled={isPreview || disabled} />

-
+ + +
+ {hasPendingChanges && userPerms.canAdmin && ( +
+ + +
+ )} +
diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx index 473a3494c04..aac282e0dbd 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -29,6 +29,7 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' +import { useUserPermissions } from '@/hooks/use-user-permissions' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -243,6 +244,9 @@ export function WorkspaceHeader({ // Get workflowRegistry state and actions const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() + // Get user permissions for the active workspace + const userPermissions = useUserPermissions(activeWorkspace?.id || '') + const userName = sessionData?.user?.name || sessionData?.user?.email || 'User' // Set isClientLoading to false after hydration @@ -348,18 +352,14 @@ export function WorkspaceHeader({ } const handleUpdateWorkspace = async (id: string, name: string) => { - // Check if user has permission to update the workspace - const workspace = workspaces.find((w) => w.id === id) - if (!workspace || workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can update workspaces') - return - } - + // For update operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check setIsWorkspacesLoading(true) try { const response = await fetch(`/api/workspaces/${id}`, { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json', }, @@ -367,19 +367,26 @@ export function WorkspaceHeader({ }) if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can update workspaces' + ) + } throw new Error('Failed to update workspace') } - const { workspace } = await response.json() + const { workspace: updatedWorkspace } = await response.json() // Update workspaces list setWorkspaces((prevWorkspaces) => - prevWorkspaces.map((w) => (w.id === workspace.id ? { ...w, name: workspace.name } : w)) + prevWorkspaces.map((w) => + w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w + ) ) // If active workspace was updated, update it too - if (activeWorkspace?.id === workspace.id) { - setActiveWorkspace({ ...activeWorkspace, name: workspace.name } as Workspace) + if (activeWorkspace?.id === updatedWorkspace.id) { + setActiveWorkspace({ ...activeWorkspace, name: updatedWorkspace.name } as Workspace) } } catch (err) { console.error('Error updating workspace:', err) @@ -389,13 +396,9 @@ export function WorkspaceHeader({ } const handleDeleteWorkspace = async (id: string) => { - // Check if user has permission to delete the workspace - const workspace = workspaces.find((w) => w.id === id) - if (!workspace || workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can delete workspaces') - return - } - + // For delete operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check setIsDeleting(true) try { @@ -404,6 +407,11 @@ export function WorkspaceHeader({ }) if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can delete workspaces' + ) + } throw new Error('Failed to delete workspace') } @@ -429,9 +437,8 @@ export function WorkspaceHeader({ const openEditModal = (workspace: Workspace, e: React.MouseEvent) => { e.stopPropagation() - // Check if user has permission to edit the workspace - if (workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can edit workspaces') + // Only show edit/delete options for the active workspace if user has admin permissions + if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) { return } setEditingWorkspace(workspace) @@ -584,7 +591,7 @@ export function WorkspaceHeader({ onClick={() => switchWorkspace(workspace)} > {workspace.name} - {workspace.role === 'owner' && ( + {userPermissions.canAdmin && activeWorkspace?.id === workspace.id && (