From bdfe7e9b99657d2d31149ecda38f571d58314446 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Jul 2025 22:35:35 -0700 Subject: [PATCH] fix(invitation): allow admins to remove members from workspace (#701) * fix(invitation): added ability for admins to remove members of their workspace * lint * remove references to workspace_member db table * remove deprecated @next/font * only allow admin to rename workspace * bring workflow name change inline, remove dialog --- .../organizations/[id]/workspaces/route.ts | 11 +- .../organizations/invitations/accept/route.ts | 48 +- .../api/workspaces/[id]/permissions/route.ts | 14 +- apps/sim/app/api/workspaces/[id]/route.ts | 5 +- .../workspaces/invitations/[id]/route.test.ts | 241 +++++++ .../api/workspaces/invitations/[id]/route.ts | 55 ++ .../workspaces/invitations/accept/route.ts | 47 +- .../api/workspaces/invitations/route.test.ts | 324 +++++++++ .../app/api/workspaces/invitations/route.ts | 56 +- .../app/api/workspaces/members/[id]/route.ts | 78 ++- apps/sim/app/api/workspaces/members/route.ts | 33 +- apps/sim/app/api/workspaces/route.ts | 67 +- .../folder-tree/components/workflow-item.tsx | 107 ++- .../workflow-context-menu.tsx | 142 +--- .../workspace-header/workspace-header.tsx | 41 +- .../components/invite-modal/invite-modal.tsx | 642 +++++++++++++----- apps/sim/db/schema.ts | 1 + apps/sim/lib/permissions/utils.test.ts | 524 +++++--------- apps/sim/package.json | 1 - bun.lock | 3 - 20 files changed, 1554 insertions(+), 886 deletions(-) create mode 100644 apps/sim/app/api/workspaces/invitations/[id]/route.test.ts create mode 100644 apps/sim/app/api/workspaces/invitations/[id]/route.ts create mode 100644 apps/sim/app/api/workspaces/invitations/route.test.ts diff --git a/apps/sim/app/api/organizations/[id]/workspaces/route.ts b/apps/sim/app/api/organizations/[id]/workspaces/route.ts index 52cc5ff5c..99079431c 100644 --- a/apps/sim/app/api/organizations/[id]/workspaces/route.ts +++ b/apps/sim/app/api/organizations/[id]/workspaces/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { member, permissions, user, workspace, workspaceMember } from '@/db/schema' +import { member, permissions, user, workspace } from '@/db/schema' const logger = createLogger('OrganizationWorkspacesAPI') @@ -116,10 +116,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: workspace.id, name: workspace.name, ownerId: workspace.ownerId, - createdAt: workspace.createdAt, isOwner: eq(workspace.ownerId, memberId), permissionType: permissions.permissionType, - joinedAt: workspaceMember.joinedAt, + createdAt: permissions.createdAt, }) .from(workspace) .leftJoin( @@ -130,10 +129,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ eq(permissions.userId, memberId) ) ) - .leftJoin( - workspaceMember, - and(eq(workspaceMember.workspaceId, workspace.id), eq(workspaceMember.userId, memberId)) - ) .where( or( // Member owns the workspace @@ -148,7 +143,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ name: workspace.name, isOwner: workspace.isOwner, permission: workspace.permissionType, - joinedAt: workspace.joinedAt, + joinedAt: workspace.createdAt, createdAt: workspace.createdAt, })) diff --git a/apps/sim/app/api/organizations/invitations/accept/route.ts b/apps/sim/app/api/organizations/invitations/accept/route.ts index c2a839afb..e9d616065 100644 --- a/apps/sim/app/api/organizations/invitations/accept/route.ts +++ b/apps/sim/app/api/organizations/invitations/accept/route.ts @@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { invitation, member, permissions, workspaceInvitation, workspaceMember } from '@/db/schema' +import { invitation, member, permissions, workspaceInvitation } from '@/db/schema' const logger = createLogger('OrganizationInvitationAcceptance') @@ -135,18 +135,6 @@ export async function GET(req: NextRequest) { wsInvitation.expiresAt && new Date().toISOString() <= wsInvitation.expiresAt.toISOString() ) { - // Check if user isn't already a member of the workspace - const existingWorkspaceMember = await tx - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, wsInvitation.workspaceId), - eq(workspaceMember.userId, session.user.id) - ) - ) - .limit(1) - // Check if user doesn't already have permissions on the workspace const existingPermission = await tx .select() @@ -160,17 +148,7 @@ export async function GET(req: NextRequest) { ) .limit(1) - if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) { - // Add user as workspace member - await tx.insert(workspaceMember).values({ - id: randomUUID(), - workspaceId: wsInvitation.workspaceId, - userId: session.user.id, - role: wsInvitation.role, - joinedAt: new Date(), - updatedAt: new Date(), - }) - + if (existingPermission.length === 0) { // Add workspace permissions await tx.insert(permissions).values({ id: randomUUID(), @@ -311,17 +289,6 @@ export async function POST(req: NextRequest) { wsInvitation.expiresAt && new Date().toISOString() <= wsInvitation.expiresAt.toISOString() ) { - const existingWorkspaceMember = await tx - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, wsInvitation.workspaceId), - eq(workspaceMember.userId, session.user.id) - ) - ) - .limit(1) - const existingPermission = await tx .select() .from(permissions) @@ -334,16 +301,7 @@ export async function POST(req: NextRequest) { ) .limit(1) - if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) { - await tx.insert(workspaceMember).values({ - id: randomUUID(), - workspaceId: wsInvitation.workspaceId, - userId: session.user.id, - role: wsInvitation.role, - joinedAt: new Date(), - updatedAt: new Date(), - }) - + if (existingPermission.length === 0) { await tx.insert(permissions).values({ id: randomUUID(), userId: session.user.id, diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 38ea6b555..0c8fc2877 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,9 +1,10 @@ +import crypto from 'crypto' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { db } from '@/db' -import { permissions, type permissionTypeEnum, workspaceMember } from '@/db/schema' +import { permissions, type permissionTypeEnum } from '@/db/schema' type PermissionType = (typeof permissionTypeEnum.enumValues)[number] @@ -33,18 +34,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } // Verify the current user has access to this workspace - const userMembership = await db + const userPermission = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, session.user.id) + eq(permissions.entityId, workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) ) ) .limit(1) - if (userMembership.length === 0) { + if (userPermission.length === 0) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 864336565..ab0de4b28 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' -import { workflow, workspaceMember } from '@/db/schema' +import { workflow } from '@/db/schema' const logger = createLogger('WorkspaceByIdAPI') @@ -126,9 +126,6 @@ export async function DELETE( // workflow_schedule, webhook, marketplace, chat, and memory records await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) - // Delete workspace members - await tx.delete(workspaceMember).where(eq(workspaceMember.workspaceId, workspaceId)) - // Delete all permissions associated with this workspace await tx .delete(permissions) diff --git a/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts new file mode 100644 index 000000000..a6d3aa34d --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts @@ -0,0 +1,241 @@ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSession } from '@/lib/auth' +import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' +import { db } from '@/db' +import { workspaceInvitation } from '@/db/schema' +import { DELETE } from './route' + +vi.mock('@/lib/auth', () => ({ + getSession: vi.fn(), +})) + +vi.mock('@/lib/permissions/utils', () => ({ + hasWorkspaceAdminAccess: vi.fn(), +})) + +vi.mock('@/db', () => ({ + db: { + select: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/db/schema', () => ({ + workspaceInvitation: { + id: 'id', + workspaceId: 'workspaceId', + email: 'email', + inviterId: 'inviterId', + status: 'status', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((a, b) => ({ type: 'eq', a, b })), +})) + +describe('DELETE /api/workspaces/invitations/[id]', () => { + const mockSession = { + user: { + id: 'user123', + email: 'user@example.com', + name: 'Test User', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + image: null, + stripeCustomerId: null, + }, + session: { + id: 'session123', + token: 'token123', + userId: 'user123', + expiresAt: new Date(Date.now() + 86400000), // 1 day from now + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + activeOrganizationId: null, + }, + } + + const mockInvitation = { + id: 'invitation123', + workspaceId: 'workspace456', + email: 'invited@example.com', + inviterId: 'inviter789', + status: 'pending', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return 401 when user is not authenticated', async () => { + vi.mocked(getSession).mockResolvedValue(null) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'invitation123' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('should return 404 when invitation does not exist', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession) + + // Mock invitation not found + const mockQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn((callback: (rows: any[]) => any) => { + // Simulate empty rows array + return Promise.resolve(callback([])) + }), + } + vi.mocked(db.select).mockReturnValue(mockQuery as any) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'non-existent' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(404) + expect(data).toEqual({ error: 'Invitation not found' }) + }) + + it('should return 403 when user does not have admin access', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession) + + // Mock invitation found + const mockQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn((callback: (rows: any[]) => any) => { + // Return the first invitation from the array + return Promise.resolve(callback([mockInvitation])) + }), + } + vi.mocked(db.select).mockReturnValue(mockQuery as any) + + // Mock user does not have admin access + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'invitation123' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(403) + expect(data).toEqual({ error: 'Insufficient permissions' }) + expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456') + }) + + it('should return 400 when trying to delete non-pending invitation', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession) + + // Mock invitation with accepted status + const acceptedInvitation = { ...mockInvitation, status: 'accepted' } + const mockQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn((callback: (rows: any[]) => any) => { + // Return the first invitation from the array + return Promise.resolve(callback([acceptedInvitation])) + }), + } + vi.mocked(db.select).mockReturnValue(mockQuery as any) + + // Mock user has admin access + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'invitation123' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'Can only delete pending invitations' }) + }) + + it('should successfully delete pending invitation when user has admin access', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession) + + // Mock invitation found + const mockQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn((callback: (rows: any[]) => any) => { + // Return the first invitation from the array + return Promise.resolve(callback([mockInvitation])) + }), + } + vi.mocked(db.select).mockReturnValue(mockQuery as any) + + // Mock user has admin access + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) + + // Mock successful deletion + const mockDelete = { + where: vi.fn().mockResolvedValue({ rowCount: 1 }), + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'invitation123' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(200) + expect(data).toEqual({ success: true }) + expect(db.delete).toHaveBeenCalledWith(workspaceInvitation) + expect(mockDelete.where).toHaveBeenCalled() + }) + + it('should return 500 when database error occurs', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession) + + // Mock database error + const mockQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockRejectedValue(new Error('Database connection failed')), + } + vi.mocked(db.select).mockReturnValue(mockQuery as any) + + const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { + method: 'DELETE', + }) + + const params = Promise.resolve({ id: 'invitation123' }) + const response = await DELETE(req, { params }) + + expect(response).toBeInstanceOf(NextResponse) + const data = await response.json() + expect(response.status).toBe(500) + expect(data).toEqual({ error: 'Failed to delete invitation' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/invitations/[id]/route.ts b/apps/sim/app/api/workspaces/invitations/[id]/route.ts new file mode 100644 index 000000000..27d0dae84 --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/[id]/route.ts @@ -0,0 +1,55 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' +import { db } from '@/db' +import { workspaceInvitation } from '@/db/schema' + +// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation +export async function DELETE(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 }) + } + + try { + // Get the invitation to delete + const invitation = await db + .select({ + id: workspaceInvitation.id, + workspaceId: workspaceInvitation.workspaceId, + email: workspaceInvitation.email, + inviterId: workspaceInvitation.inviterId, + status: workspaceInvitation.status, + }) + .from(workspaceInvitation) + .where(eq(workspaceInvitation.id, id)) + .then((rows) => rows[0]) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + // Check if current user has admin access to the workspace + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) + + if (!hasAdminAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + // Only allow deleting pending invitations + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) + } + + // Delete the invitation + await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id)) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting workspace invitation:', error) + return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts index 560b36f68..10d001b34 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 { permissions, user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' +import { permissions, user, workspace, workspaceInvitation } from '@/db/schema' // Accept an invitation via token export async function GET(req: NextRequest) { @@ -126,20 +126,21 @@ export async function GET(req: NextRequest) { ) } - // Check if user is already a member - const existingMembership = await db + // Check if user already has permissions for this workspace + const existingPermission = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, invitation.workspaceId), - eq(workspaceMember.userId, session.user.id) + eq(permissions.entityId, invitation.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) ) ) .then((rows) => rows[0]) - if (existingMembership) { - // User is already a member, just mark the invitation as accepted and redirect + if (existingPermission) { + // User already has permissions, just mark the invitation as accepted and redirect await db .update(workspaceInvitation) .set({ @@ -156,35 +157,19 @@ export async function GET(req: NextRequest) { ) } - // Add user to workspace, permissions, and mark invitation as accepted in a transaction + // Add user permissions and mark invitation as accepted in a transaction await db.transaction(async (tx) => { - // Add user to workspace - await tx.insert(workspaceMember).values({ + // Create permissions for the user + await tx.insert(permissions).values({ id: randomUUID(), - workspaceId: invitation.workspaceId, + entityType: 'workspace' as const, + entityId: invitation.workspaceId, userId: session.user.id, - role: invitation.role, - joinedAt: new Date(), + permissionType: invitation.permissions || 'read', + createdAt: new Date(), updatedAt: new Date(), }) - // 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) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts new file mode 100644 index 000000000..f46f218dc --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils' + +describe('Workspace Invitations API Route', () => { + const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' } + const mockUser = { id: 'user-1', email: 'test@example.com' } + const mockInvitation = { id: 'invitation-1', status: 'pending' } + + let mockDbResults: any[] = [] + let mockGetSession: any + let mockResendSend: any + let mockInsertValues: any + + beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + + mockDbResults = [] + mockConsoleLogger() + mockAuth(mockUser) + + vi.doMock('crypto', () => ({ + randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'), + })) + + mockGetSession = vi.fn() + vi.doMock('@/lib/auth', () => ({ + getSession: mockGetSession, + })) + + mockInsertValues = vi.fn().mockResolvedValue(undefined) + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((callback: any) => { + const result = mockDbResults.shift() || [] + return callback ? callback(result) : Promise.resolve(result) + }), + insert: vi.fn().mockReturnThis(), + values: mockInsertValues, + } + + vi.doMock('@/db', () => ({ + db: mockDbChain, + })) + + vi.doMock('@/db/schema', () => ({ + user: { id: 'user_id', email: 'user_email', name: 'user_name', image: 'user_image' }, + workspace: { id: 'workspace_id', name: 'workspace_name', ownerId: 'owner_id' }, + permissions: { + userId: 'user_id', + entityId: 'entity_id', + entityType: 'entity_type', + permissionType: 'permission_type', + }, + workspaceInvitation: { + id: 'invitation_id', + workspaceId: 'workspace_id', + email: 'invitation_email', + status: 'invitation_status', + token: 'invitation_token', + inviterId: 'inviter_id', + role: 'invitation_role', + permissions: 'invitation_permissions', + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + permissionTypeEnum: { enumValues: ['admin', 'write', 'read'] as const }, + })) + + mockResendSend = vi.fn().mockResolvedValue({ id: 'email-id' }) + vi.doMock('resend', () => ({ + Resend: vi.fn().mockImplementation(() => ({ + emails: { send: mockResendSend }, + })), + })) + + vi.doMock('@react-email/render', () => ({ + render: vi.fn().mockResolvedValue('email content'), + })) + + vi.doMock('@/components/emails/workspace-invitation', () => ({ + WorkspaceInvitationEmail: vi.fn(), + })) + + vi.doMock('@/lib/env', () => ({ + env: { + RESEND_API_KEY: 'test-resend-key', + NEXT_PUBLIC_APP_URL: 'https://test.simstudio.ai', + EMAIL_DOMAIN: 'test.simstudio.ai', + }, + })) + + vi.doMock('@/lib/urls/utils', () => ({ + getEmailDomain: vi.fn().mockReturnValue('simstudio.ai'), + })) + + vi.doMock('drizzle-orm', () => ({ + and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })), + eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })), + inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), + })) + }) + + describe('GET /api/workspaces/invitations', () => { + it('should return 401 when user is not authenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const { GET } = await import('./route') + const req = createMockRequest('GET') + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('should return empty invitations when user has no workspaces', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockDbResults = [[], []] // No workspaces, no invitations + + const { GET } = await import('./route') + const req = createMockRequest('GET') + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ invitations: [] }) + }) + + it('should return invitations for user workspaces', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + const mockWorkspaces = [{ id: 'workspace-1' }, { id: 'workspace-2' }] + const mockInvitations = [ + { id: 'invitation-1', workspaceId: 'workspace-1', email: 'test@example.com' }, + { id: 'invitation-2', workspaceId: 'workspace-2', email: 'test2@example.com' }, + ] + mockDbResults = [mockWorkspaces, mockInvitations] + + const { GET } = await import('./route') + const req = createMockRequest('GET') + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ invitations: mockInvitations }) + }) + }) + + describe('POST /api/workspaces/invitations', () => { + it('should return 401 when user is not authenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('should return 400 when workspaceId is missing', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + + const { POST } = await import('./route') + const req = createMockRequest('POST', { email: 'test@example.com' }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'Workspace ID and email are required' }) + }) + + it('should return 400 when email is missing', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + + const { POST } = await import('./route') + const req = createMockRequest('POST', { workspaceId: 'workspace-1' }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'Workspace ID and email are required' }) + }) + + it('should return 400 when permission type is invalid', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + permission: 'invalid-permission', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ + error: 'Invalid permission: must be one of admin, write, read', + }) + }) + + it('should return 403 when user does not have admin permissions', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockDbResults = [[]] // No admin permissions found + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toEqual({ error: 'You need admin permissions to invite users' }) + }) + + it('should return 404 when workspace is not found', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockDbResults = [ + [{ permissionType: 'admin' }], // User has admin permissions + [], // Workspace not found + ] + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data).toEqual({ error: 'Workspace not found' }) + }) + + it('should return 400 when user already has workspace access', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockDbResults = [ + [{ permissionType: 'admin' }], // User has admin permissions + [mockWorkspace], // Workspace exists + [mockUser], // User exists + [{ permissionType: 'read' }], // User already has access + ] + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ + error: 'test@example.com already has access to this workspace', + email: 'test@example.com', + }) + }) + + it('should return 400 when invitation already exists', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockDbResults = [ + [{ permissionType: 'admin' }], // User has admin permissions + [mockWorkspace], // Workspace exists + [], // User doesn't exist + [mockInvitation], // Invitation exists + ] + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ + error: 'test@example.com has already been invited to this workspace', + email: 'test@example.com', + }) + }) + + it('should successfully create invitation and send email', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-123', name: 'Test User', email: 'sender@example.com' }, + }) + mockDbResults = [ + [{ permissionType: 'admin' }], // User has admin permissions + [mockWorkspace], // Workspace exists + [], // User doesn't exist + [], // No existing invitation + ] + + const { POST } = await import('./route') + const req = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'test@example.com', + permission: 'read', + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.invitation).toBeDefined() + expect(data.invitation.email).toBe('test@example.com') + expect(data.invitation.permissions).toBe('read') + expect(data.invitation.token).toBe('mock-uuid-1234') + expect(mockInsertValues).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 36b69d026..5ffe0e002 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -10,11 +10,11 @@ import { createLogger } from '@/lib/logs/console-logger' import { getEmailDomain } from '@/lib/urls/utils' import { db } from '@/db' import { + permissions, type permissionTypeEnum, user, workspace, workspaceInvitation, - workspaceMember, } from '@/db/schema' export const dynamic = 'force-dynamic' @@ -33,15 +33,16 @@ export async function GET(req: NextRequest) { } try { - // Get all workspaces where the user is a member (any role) + // Get all workspaces where the user has permissions const userWorkspaces = await db .select({ id: workspace.id }) .from(workspace) .innerJoin( - workspaceMember, + permissions, and( - eq(workspaceMember.workspaceId, workspace.id), - eq(workspaceMember.userId, session.user.id) + eq(permissions.entityId, workspace.id), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) ) ) @@ -89,20 +90,25 @@ export async function POST(req: NextRequest) { ) } - // Check if user is authorized to invite to this workspace (must be owner) - const membership = await db + // Check if user has admin permissions for this workspace + const userPermission = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, session.user.id) + eq(permissions.entityId, workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id), + eq(permissions.permissionType, 'admin') ) ) .then((rows) => rows[0]) - if (!membership) { - return NextResponse.json({ error: 'You are not a member of this workspace' }, { status: 403 }) + if (!userPermission) { + return NextResponse.json( + { error: 'You need admin permissions to invite users' }, + { status: 403 } + ) } // Get the workspace details for the email @@ -125,22 +131,23 @@ export async function POST(req: NextRequest) { .then((rows) => rows[0]) if (existingUser) { - // Check if the user is already a member of this workspace - const existingMembership = await db + // Check if the user already has permissions for this workspace + const existingPermission = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, existingUser.id) + eq(permissions.entityId, workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, existingUser.id) ) ) .then((rows) => rows[0]) - if (existingMembership) { + if (existingPermission) { return NextResponse.json( { - error: `${email} is already a member of this workspace`, + error: `${email} already has access to this workspace`, email, }, { status: 400 } @@ -245,14 +252,19 @@ async function sendInvitationEmail({ ) } - await resend.emails.send({ - from: `noreply@${getEmailDomain()}`, + const emailDomain = env.EMAIL_DOMAIN || getEmailDomain() + const fromAddress = `noreply@${emailDomain}` + + logger.info(`Attempting to send email from ${fromAddress} to ${to}`) + + const result = await resend.emails.send({ + from: fromAddress, to, subject: `You've been invited to join "${workspaceName}" on Sim Studio`, html: emailHtml, }) - logger.info(`Invitation email sent to ${to}`) + logger.info(`Invitation email sent successfully to ${to}`, { result }) } catch (error) { logger.error('Error sending invitation email:', error) // Continue even if email fails - the invitation is still created diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 57febd2cd..6d0c536e3 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -1,79 +1,85 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { db } from '@/db' -import { workspaceMember } from '@/db/schema' +import { permissions } from '@/db/schema' // 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 + const { id: userId } = await params const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const membershipId = id - try { - // Get the membership to delete - 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]) + // Get the workspace ID from the request body or URL + const body = await req.json() + const workspaceId = body.workspaceId - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) + if (!workspaceId) { + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) } - // Check if current user is an owner of the workspace or the member being removed - const isOwner = await db + // Check if the user to be removed actually has permissions for this workspace + const userPermission = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, membership.workspaceId), - eq(workspaceMember.userId, session.user.id), - eq(workspaceMember.role, 'owner') + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) ) ) - .then((rows) => rows.length > 0) + .then((rows) => rows[0]) - const isSelf = membership.userId === session.user.id + if (!userPermission) { + return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) + } - if (!isOwner && !isSelf) { + // Check if current user has admin access to this workspace + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) + const isSelf = userId === session.user.id + + if (!hasAdminAccess && !isSelf) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - // Prevent removing yourself if you're the owner and the last owner - if (isSelf && membership.role === 'owner') { - const otherOwners = await db + // Prevent removing yourself if you're the last admin + if (isSelf && userPermission.permissionType === 'admin') { + const otherAdmins = await db .select() - .from(workspaceMember) + .from(permissions) .where( and( - eq(workspaceMember.workspaceId, membership.workspaceId), - eq(workspaceMember.role, 'owner') + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.permissionType, 'admin') ) ) .then((rows) => rows.filter((row) => row.userId !== session.user.id)) - if (otherOwners.length === 0) { + if (otherAdmins.length === 0) { return NextResponse.json( - { error: 'Cannot remove the last owner from a workspace' }, + { error: 'Cannot remove the last admin from a workspace' }, { status: 400 } ) } } - // Delete the membership - await db.delete(workspaceMember).where(eq(workspaceMember.id, membershipId)) + // Delete the user's permissions for this workspace + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/app/api/workspaces/members/route.ts b/apps/sim/app/api/workspaces/members/route.ts index 6f76cf8ed..3bbf48589 100644 --- a/apps/sim/app/api/workspaces/members/route.ts +++ b/apps/sim/app/api/workspaces/members/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { hasAdminPermission } from '@/lib/permissions/utils' import { db } from '@/db' -import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema' +import { permissions, type permissionTypeEnum, user } from '@/db/schema' type PermissionType = (typeof permissionTypeEnum.enumValues)[number] @@ -71,28 +71,15 @@ export async function POST(req: Request) { ) } - // 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(), - }) + // Create single permission for the new member + await db.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({ diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 6c34dc44d..50bf9b7cf 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -2,9 +2,8 @@ import crypto from 'crypto' import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getUserEntityPermissions } from '@/lib/permissions/utils' import { db } from '@/db' -import { permissions, workflow, workflowBlocks, workspace, workspaceMember } from '@/db/schema' +import { permissions, workflow, workflowBlocks, workspace } from '@/db/schema' // Get all workspaces for the current user export async function GET() { @@ -14,19 +13,18 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Get all workspaces where the user is a member with a single join query - const memberWorkspaces = await db + // Get all workspaces where the user has permissions + const userWorkspaces = await db .select({ workspace: workspace, - role: workspaceMember.role, - membershipId: workspaceMember.id, + permissionType: permissions.permissionType, }) - .from(workspaceMember) - .innerJoin(workspace, eq(workspaceMember.workspaceId, workspace.id)) - .where(eq(workspaceMember.userId, session.user.id)) - .orderBy(desc(workspaceMember.joinedAt)) + .from(permissions) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) + .orderBy(desc(workspace.createdAt)) - if (memberWorkspaces.length === 0) { + if (userWorkspaces.length === 0) { // Create a default workspace for the user const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name) @@ -37,23 +35,14 @@ export async function GET() { } // If user has workspaces but might have orphaned workflows, migrate them - await ensureWorkflowsHaveWorkspace(session.user.id, memberWorkspaces[0].workspace.id) + await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) - // Get permissions for each workspace and format the response - const workspacesWithPermissions = await Promise.all( - memberWorkspaces.map(async ({ workspace: workspaceDetails, role, membershipId }) => { - const userPermissions = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceDetails.id - ) - - return { - ...workspaceDetails, - role, - membershipId, - permissions: userPermissions, - } + // Format the response with permission information + const workspacesWithPermissions = userWorkspaces.map( + ({ workspace: workspaceDetails, permissionType }) => ({ + ...workspaceDetails, + role: permissionType === 'admin' ? 'owner' : 'member', // Map admin to owner for compatibility + permissions: permissionType, }) ) @@ -108,13 +97,14 @@ async function createWorkspace(userId: string, name: string) { updatedAt: now, }) - // Add the user as a member with owner role - await tx.insert(workspaceMember).values({ + // Create admin permissions for the workspace owner + await tx.insert(permissions).values({ id: crypto.randomUUID(), - workspaceId, - userId, - role: 'owner', - joinedAt: now, + entityType: 'workspace' as const, + entityId: workspaceId, + userId: userId, + permissionType: 'admin' as const, + createdAt: now, updatedAt: now, }) @@ -263,17 +253,6 @@ async function createWorkspace(userId: string, name: string) { throw error } - // 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/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx index 33916362e..6c2f0128d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx @@ -1,12 +1,14 @@ 'use client' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import Link from 'next/link' import { useParams } from 'next/navigation' +import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console-logger' import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { WorkflowContextMenu } from '../../workflow-context-menu/workflow-context-menu' @@ -32,14 +34,83 @@ export function WorkflowItem({ isFirstItem = false, }: WorkflowItemProps) { const [isDragging, setIsDragging] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(workflow.name) + const [isRenaming, setIsRenaming] = useState(false) const dragStartedRef = useRef(false) + const inputRef = useRef(null) const params = useParams() const workspaceId = params.workspaceId as string const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore() const isSelected = useIsWorkflowSelected(workflow.id) + const { updateWorkflow } = useWorkflowRegistry() + + // Update editValue when workflow name changes + useEffect(() => { + setEditValue(workflow.name) + }, [workflow.name]) + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) + + const handleStartEdit = () => { + if (isMarketplace) return + setIsEditing(true) + setEditValue(workflow.name) + } + + const handleSaveEdit = async () => { + if (!editValue.trim() || editValue.trim() === workflow.name) { + setIsEditing(false) + setEditValue(workflow.name) + return + } + + setIsRenaming(true) + try { + await updateWorkflow(workflow.id, { name: editValue.trim() }) + logger.info(`Successfully renamed workflow from "${workflow.name}" to "${editValue.trim()}"`) + setIsEditing(false) + } catch (error) { + logger.error('Failed to rename workflow:', { + error, + workflowId: workflow.id, + oldName: workflow.name, + newName: editValue.trim(), + }) + // Reset to original name on error + setEditValue(workflow.name) + } finally { + setIsRenaming(false) + } + } + + const handleCancelEdit = () => { + setIsEditing(false) + setEditValue(workflow.name) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelEdit() + } + } + + const handleInputBlur = () => { + handleSaveEdit() + } const handleClick = (e: React.MouseEvent) => { - if (dragStartedRef.current) { + if (dragStartedRef.current || isEditing) { e.preventDefault() return } @@ -55,7 +126,7 @@ export function WorkflowItem({ } const handleDragStart = (e: React.DragEvent) => { - if (isMarketplace) return + if (isMarketplace || isEditing) return dragStartedRef.current = true setIsDragging(true) @@ -95,7 +166,7 @@ export function WorkflowItem({ : '', isDragging ? 'opacity-50' : '' )} - draggable={!isMarketplace} + draggable={!isMarketplace && !isEditing} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onClick={handleClick} @@ -134,7 +205,7 @@ export function WorkflowItem({ ? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px` : `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`, }} - draggable={!isMarketplace} + draggable={!isMarketplace && !isEditing} onDragStart={handleDragStart} onDragEnd={handleDragEnd} data-workflow-id={workflow.id} @@ -148,15 +219,29 @@ export function WorkflowItem({ className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded' style={{ backgroundColor: workflow.color }} /> - - {workflow.name} - {isMarketplace && ' (Preview)'} - + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className='h-6 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + disabled={isRenaming} + onClick={(e) => e.preventDefault()} // Prevent navigation when clicking input + /> + ) : ( + + {workflow.name} + {isMarketplace && ' (Preview)'} + + )} - {!isMarketplace && ( + {!isMarketplace && !isEditing && (
e.stopPropagation()}> - +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx index 0f5a9b572..b7e1f9123 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx @@ -1,139 +1,57 @@ 'use client' -import { useState } from 'react' import { MoreHorizontal, Pencil } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { createLogger } from '@/lib/logs/console-logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' - -const logger = createLogger('WorkflowContextMenu') interface WorkflowContextMenuProps { - workflow: WorkflowMetadata - onRename?: (workflowId: string, newName: string) => void - level: number + onStartEdit?: () => void } -export function WorkflowContextMenu({ workflow, onRename, level }: WorkflowContextMenuProps) { - const [showRenameDialog, setShowRenameDialog] = useState(false) - const [renameName, setRenameName] = useState(workflow.name) - const [isRenaming, setIsRenaming] = useState(false) - +export function WorkflowContextMenu({ onStartEdit }: WorkflowContextMenuProps) { // Get user permissions for the workspace const userPermissions = useUserPermissionsContext() - const { updateWorkflow } = useWorkflowRegistry() - const handleRename = () => { - setRenameName(workflow.name) - setShowRenameDialog(true) - } - - const handleRenameSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!renameName.trim()) return - - setIsRenaming(true) - try { - if (onRename) { - onRename(workflow.id, renameName.trim()) - } else { - // Default rename behavior using updateWorkflow - await updateWorkflow(workflow.id, { name: renameName.trim() }) - logger.info( - `Successfully renamed workflow from "${workflow.name}" to "${renameName.trim()}"` - ) - } - setShowRenameDialog(false) - } catch (error) { - logger.error('Failed to rename workflow:', { - error, - workflowId: workflow.id, - oldName: workflow.name, - newName: renameName.trim(), - }) - } finally { - setIsRenaming(false) + if (onStartEdit) { + onStartEdit() } } - const handleCancel = () => { - setRenameName(workflow.name) - setShowRenameDialog(false) - } - return ( - <> - - - - - + + - - - - - - + + Workflow options + + + e.stopPropagation()} + className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]' + > + {userPermissions.canEdit && ( + + + Rename + + )} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index d144a70eb..9954a794c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' const logger = createLogger('WorkspaceHeader') @@ -50,6 +51,7 @@ export const WorkspaceHeader = React.memo( }) => { // External hooks const { data: sessionData } = useSession() + const userPermissions = useUserPermissionsContext() const [isClientLoading, setIsClientLoading] = useState(true) const [isEditingName, setIsEditingName] = useState(false) const [editingName, setEditingName] = useState('') @@ -87,9 +89,13 @@ export const WorkspaceHeader = React.memo( // Handle workspace name click const handleWorkspaceNameClick = useCallback(() => { + // Only allow admins to rename workspace + if (!userPermissions.canAdmin) { + return + } setEditingName(displayName) setIsEditingName(true) - }, [displayName]) + }, [displayName, userPermissions.canAdmin]) // Handle workspace name editing actions const handleEditingAction = useCallback( @@ -207,16 +213,29 @@ export const WorkspaceHeader = React.memo( }} /> ) : ( -
- {displayName} -
+ + +
+ {displayName} +
+
+ {!userPermissions.canAdmin && ( + + Admin permissions required to rename workspace + + )} +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx index ff5100845..8883703ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx @@ -1,7 +1,7 @@ 'use client' import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' -import { HelpCircle, Loader2, X } from 'lucide-react' +import { HelpCircle, Loader2, Trash2, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -41,11 +41,14 @@ interface UserPermissions { permissionType: PermissionType isCurrentUser?: boolean isPendingInvitation?: boolean + invitationId?: string } interface PermissionsTableProps { userPermissions: UserPermissions[] onPermissionChange: (userId: string, permissionType: PermissionType) => void + onRemoveMember?: (userId: string, email: string) => void + onRemoveInvitation?: (invitationId: string, email: string) => void disabled?: boolean existingUserPermissionChanges: Record> isSaving?: boolean @@ -189,198 +192,244 @@ const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string => } } -const PermissionsTable = React.memo( - ({ - userPermissions, - onPermissionChange, - disabled, - existingUserPermissionChanges, - isSaving, - workspacePermissions, - permissionsLoading, - pendingInvitations, - isPendingInvitationsLoading, - }) => { - // Always call hooks first - before any conditional returns - const { data: session } = useSession() - const userPerms = useUserPermissionsContext() +const PermissionsTable = ({ + userPermissions, + onPermissionChange, + onRemoveMember, + onRemoveInvitation, + disabled, + existingUserPermissionChanges, + isSaving, + workspacePermissions, + permissionsLoading, + pendingInvitations, + isPendingInvitationsLoading, +}: PermissionsTableProps) => { + const { data: session } = useSession() + const userPerms = useUserPermissionsContext() - // All useMemo hooks must be called before any conditional returns - const existingUsers: UserPermissions[] = useMemo( - () => - workspacePermissions?.users?.map((user) => { - const changes = existingUserPermissionChanges[user.userId] || {} - const permissionType = user.permissionType || 'read' + const existingUsers: UserPermissions[] = useMemo( + () => + workspacePermissions?.users?.map((user) => { + const changes = existingUserPermissionChanges[user.userId] || {} + const permissionType = user.permissionType || 'read' - return { - userId: user.userId, - email: user.email, - permissionType: - changes.permissionType !== undefined ? changes.permissionType : permissionType, - isCurrentUser: user.email === session?.user?.email, + return { + userId: user.userId, + email: user.email, + permissionType: + changes.permissionType !== undefined ? changes.permissionType : permissionType, + isCurrentUser: user.email === session?.user?.email, + } + }) || [], + [workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email] + ) + + const currentUser: UserPermissions | null = useMemo( + () => + session?.user?.email + ? existingUsers.find((user) => user.isCurrentUser) || { + email: session.user.email, + permissionType: 'admin', + isCurrentUser: true, } - }) || [], - [workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email] + : null, + [session?.user?.email, existingUsers] + ) + + const filteredExistingUsers = useMemo( + () => existingUsers.filter((user) => !user.isCurrentUser), + [existingUsers] + ) + + const allUsers: UserPermissions[] = useMemo(() => { + // Get emails of existing users to filter out duplicate invitations + const existingUserEmails = new Set([ + ...(currentUser ? [currentUser.email] : []), + ...filteredExistingUsers.map((user) => user.email), + ]) + + // Filter out pending invitations for users who are already members + const filteredPendingInvitations = pendingInvitations.filter( + (invitation) => !existingUserEmails.has(invitation.email) ) - const currentUser: UserPermissions | null = useMemo( - () => - session?.user?.email - ? existingUsers.find((user) => user.isCurrentUser) || { - email: session.user.email, - permissionType: 'admin', - isCurrentUser: true, - } - : null, - [session?.user?.email, existingUsers] - ) + return [ + ...(currentUser ? [currentUser] : []), + ...filteredExistingUsers, + ...userPermissions, + ...filteredPendingInvitations, + ] + }, [currentUser, filteredExistingUsers, userPermissions, pendingInvitations]) - const filteredExistingUsers = useMemo( - () => existingUsers.filter((user) => !user.isCurrentUser), - [existingUsers] - ) + if (permissionsLoading || userPerms.isLoading || isPendingInvitationsLoading) { + return + } - const allUsers: UserPermissions[] = useMemo( - () => [ - ...(currentUser ? [currentUser] : []), - ...filteredExistingUsers, - ...userPermissions, - ...pendingInvitations, - ], - [currentUser, filteredExistingUsers, userPermissions, pendingInvitations] - ) - - // Now we can safely have conditional returns after all hooks are called - if (permissionsLoading || userPerms.isLoading || isPendingInvitationsLoading) { - return - } - - if ( - userPermissions.length === 0 && - !session?.user?.email && - !workspacePermissions?.users?.length - ) - return null - - if (isSaving) { - return ( -
-

Member Permissions

-
-
-
- - Saving permission changes... -
-
-
-
-

- Please wait while we update the permissions. -

-
-
- ) - } - - const currentUserIsAdmin = userPerms.canAdmin + if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length) + return null + if (isSaving) { return (
-
-

Member Permissions

- - - - - -
- {userPerms.isLoading || permissionsLoading ? ( -

Loading permissions...

- ) : !currentUserIsAdmin ? ( -

- Only administrators can invite new members and modify permissions. -

- ) : ( -
-

Admin grants all permissions automatically.

-
- )} -
-
-
-
-
- {allUsers.length > 0 && ( -
- {allUsers.map((user) => { - const isCurrentUser = user.isCurrentUser === true - const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) - const isPendingInvitation = user.isPendingInvitation === true - const userIdentifier = user.userId || user.email - const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined - - const uniqueKey = user.userId - ? `existing-${user.userId}` - : isPendingInvitation - ? `pending-${user.email}` - : `new-${user.email}` - - return ( -
-
-
- - {user.email} - - {isPendingInvitation && ( - Sent - )} -
-
- {isExistingUser && !isCurrentUser && ( - Member - )} - {hasChanges && ( - Modified - )} -
-
-
- - onPermissionChange(userIdentifier, newPermission) - } - disabled={ - disabled || - !currentUserIsAdmin || - isPendingInvitation || - (isCurrentUser && user.permissionType === 'admin') - } - className='w-auto' - /> -
-
- ) - })} +

Member Permissions

+
+
+
+ + Saving permission changes...
- )} +
+
+
+

+ Please wait while we update the permissions. +

) } -) -PermissionsTable.displayName = 'PermissionsTable' + const currentUserIsAdmin = userPerms.canAdmin + + return ( +
+
+

Member Permissions

+ + + + + +
+ {userPerms.isLoading || permissionsLoading ? ( +

Loading permissions...

+ ) : !currentUserIsAdmin ? ( +

+ Only administrators can invite new members and modify permissions. +

+ ) : ( +
+

Admin grants all permissions automatically.

+
+ )} +
+
+
+
+
+ {allUsers.length > 0 && ( +
+ {allUsers.map((user) => { + const isCurrentUser = user.isCurrentUser === true + const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) + const isPendingInvitation = user.isPendingInvitation === true + const userIdentifier = user.userId || user.email + // Check if current permission is different from original permission + const originalPermission = workspacePermissions?.users?.find( + (eu) => eu.userId === user.userId + )?.permissionType + const currentPermission = + existingUserPermissionChanges[userIdentifier]?.permissionType ?? user.permissionType + const hasChanges = originalPermission && currentPermission !== originalPermission + // Check if user is in workspace permissions directly + const isWorkspaceMember = workspacePermissions?.users?.some( + (eu) => eu.email === user.email && eu.userId + ) + const canShowRemoveButton = + isWorkspaceMember && + !isCurrentUser && + !isPendingInvitation && + currentUserIsAdmin && + user.userId + + const uniqueKey = user.userId + ? `existing-${user.userId}` + : isPendingInvitation + ? `pending-${user.email}` + : `new-${user.email}` + + return ( +
+
+
+ {user.email} + {isPendingInvitation && ( + Sent + )} + {/* Show remove button for existing workspace members (not current user, not pending) */} + {canShowRemoveButton && onRemoveMember && ( + + )} + {/* Show remove button for pending invitations */} + {isPendingInvitation && + currentUserIsAdmin && + user.invitationId && + onRemoveInvitation && ( + + )} +
+
+ {isExistingUser && !isCurrentUser && ( + Member + )} + {hasChanges && ( + Modified + )} +
+
+
+ + onPermissionChange(userIdentifier, newPermission) + } + disabled={ + disabled || + !currentUserIsAdmin || + isPendingInvitation || + (isCurrentUser && user.permissionType === 'admin') + } + className='w-auto' + /> +
+
+ ) + })} +
+ )} +
+
+ ) +} export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [inputValue, setInputValue] = useState('') @@ -397,6 +446,15 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [showSent, setShowSent] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [successMessage, setSuccessMessage] = useState(null) + const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>( + null + ) + const [isRemovingMember, setIsRemovingMember] = useState(false) + const [invitationToRemove, setInvitationToRemove] = useState<{ + invitationId: string + email: string + } | null>(null) + const [isRemovingInvitation, setIsRemovingInvitation] = useState(false) const params = useParams() const workspaceId = params.workspaceId as string @@ -429,6 +487,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { email: inv.email, permissionType: inv.permissions, isPendingInvitation: true, + invitationId: inv.id, })) || [] setPendingInvitations(workspacePendingInvitations) @@ -444,11 +503,15 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { if (open && workspaceId) { fetchPendingInvitations() } - }, [open, fetchPendingInvitations]) + }, [open, workspaceId, fetchPendingInvitations]) + // Clear errors when modal opens useEffect(() => { - setErrorMessage(null) - }, [pendingInvitations, workspacePermissions]) + if (open) { + setErrorMessage(null) + setSuccessMessage(null) + } + }, [open]) const addEmail = useCallback( (email: string) => { @@ -523,10 +586,19 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier) if (existingUser) { - setExistingUserPermissionChanges((prev) => ({ - ...prev, - [identifier]: { permissionType }, - })) + setExistingUserPermissionChanges((prev) => { + const newChanges = { ...prev } + + // If the new permission matches the original, remove the change entry + if (existingUser.permissionType === permissionType) { + delete newChanges[identifier] + } else { + // Otherwise, track the change + newChanges[identifier] = { permissionType } + } + + return newChanges + }) } else { setUserPermissions((prev) => prev.map((user) => (user.email === identifier ? { ...user, permissionType } : user)) @@ -599,6 +671,126 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setTimeout(() => setSuccessMessage(null), 3000) }, [userPerms.canAdmin, hasPendingChanges]) + const handleRemoveMemberClick = useCallback((userId: string, email: string) => { + setMemberToRemove({ userId, email }) + }, []) + + const handleRemoveMemberConfirm = useCallback(async () => { + if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return + + setIsRemovingMember(true) + setErrorMessage(null) + + try { + // Verify the user exists in workspace permissions + const userRecord = workspacePermissions?.users?.find( + (user) => user.userId === memberToRemove.userId + ) + + if (!userRecord) { + throw new Error('User is not a member of this workspace') + } + + const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workspaceId: workspaceId, + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to remove member') + } + + // Update the workspace permissions to remove the user + if (workspacePermissions) { + const updatedUsers = workspacePermissions.users.filter( + (user) => user.userId !== memberToRemove.userId + ) + updatePermissions({ + users: updatedUsers, + total: workspacePermissions.total - 1, + }) + } + + // Clear any pending changes for this user + setExistingUserPermissionChanges((prev) => { + const updated = { ...prev } + delete updated[memberToRemove.userId] + return updated + }) + + setSuccessMessage(`${memberToRemove.email} has been removed from the workspace`) + setTimeout(() => setSuccessMessage(null), 3000) + } catch (error) { + logger.error('Error removing member:', error) + const errorMsg = + error instanceof Error ? error.message : 'Failed to remove member. Please try again.' + setErrorMessage(errorMsg) + } finally { + setIsRemovingMember(false) + setMemberToRemove(null) + } + }, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions]) + + const handleRemoveMemberCancel = useCallback(() => { + setMemberToRemove(null) + }, []) + + const handleRemoveInvitationClick = useCallback((invitationId: string, email: string) => { + setInvitationToRemove({ invitationId, email }) + }, []) + + const handleRemoveInvitationConfirm = useCallback(async () => { + if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return + + setIsRemovingInvitation(true) + setErrorMessage(null) + + try { + const response = await fetch( + `/api/workspaces/invitations/${invitationToRemove.invitationId}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to cancel invitation') + } + + // Remove the invitation from the pending invitations list + setPendingInvitations((prev) => + prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId) + ) + + setSuccessMessage(`Invitation for ${invitationToRemove.email} has been cancelled`) + setTimeout(() => setSuccessMessage(null), 3000) + } catch (error) { + logger.error('Error cancelling invitation:', error) + const errorMsg = + error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.' + setErrorMessage(errorMsg) + } finally { + setIsRemovingInvitation(false) + setInvitationToRemove(null) + } + }, [invitationToRemove, workspaceId, userPerms.canAdmin]) + + const handleRemoveInvitationCancel = useCallback(() => { + setInvitationToRemove(null) + }, []) + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { @@ -645,6 +837,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { addEmail(inputValue) } + // Clear messages at start of submission setErrorMessage(null) setSuccessMessage(null) @@ -732,7 +925,9 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } } catch (err) { logger.error('Error inviting members:', err) - setErrorMessage('An unexpected error occurred. Please try again.') + const errorMessage = + err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.' + setErrorMessage(errorMessage) } finally { setIsSubmitting(false) } @@ -750,6 +945,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { ) const resetState = useCallback(() => { + // Batch state updates using React's automatic batching in React 18+ setInputValue('') setEmails([]) setInvalidEmails([]) @@ -762,6 +958,10 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setShowSent(false) setErrorMessage(null) setSuccessMessage(null) + setMemberToRemove(null) + setIsRemovingMember(false) + setInvitationToRemove(null) + setIsRemovingInvitation(false) }, []) return ( @@ -880,7 +1080,9 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
+ + {/* Remove Member Confirmation Dialog */} + + + + Remove Member + +
+

+ Are you sure you want to remove{' '} + {memberToRemove?.email} from this + workspace? This action cannot be undone. +

+
+
+ + +
+
+
+ + {/* Remove Invitation Confirmation Dialog */} + + + + Cancel Invitation + +
+

+ Are you sure you want to cancel the invitation for{' '} + {invitationToRemove?.email}? This + action cannot be undone. +

+
+
+ + +
+
+
) } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index a7f7ab60e..84457845e 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -628,6 +628,7 @@ export const workspace = pgTable('workspace', { updatedAt: timestamp('updated_at').notNull().defaultNow(), }) +// @deprecated - Use permissions table instead. This table is kept for backward compatibility during migration. export const workspaceMember = pgTable( 'workspace_member', { diff --git a/apps/sim/lib/permissions/utils.test.ts b/apps/sim/lib/permissions/utils.test.ts index 35fd89e11..ef40e982d 100644 --- a/apps/sim/lib/permissions/utils.test.ts +++ b/apps/sim/lib/permissions/utils.test.ts @@ -1,9 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getUserEntityPermissions, - getUsersWithPermissions, - hasAdminPermission, -} from '@/lib/permissions/utils' vi.mock('@/db', () => ({ db: { @@ -32,6 +27,16 @@ vi.mock('@/db/schema', () => ({ name: 'user_name', image: 'user_image', }, + workspace: { + id: 'workspace_id', + name: 'workspace_name', + ownerId: 'workspace_owner_id', + }, + member: { + userId: 'member_user_id', + organizationId: 'member_organization_id', + role: 'member_role', + }, })) vi.mock('drizzle-orm', () => ({ @@ -39,253 +44,120 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn().mockReturnValue('eq-condition'), })) +import { + getManageableWorkspaces, + getUserEntityPermissions, + getUsersWithPermissions, + hasAdminPermission, + hasWorkspaceAdminAccess, + isOrganizationAdminForWorkspace, + isOrganizationOwnerOrAdmin, +} from '@/lib/permissions/utils' import { db } from '@/db' -import { permissions, user } from '@/db/schema' const mockDb = db as any - type PermissionType = 'admin' | 'write' | 'read' +function createMockChain(finalResult: any) { + const chain: any = {} + + chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult)) + chain.select = vi.fn().mockReturnValue(chain) + chain.from = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.limit = vi.fn().mockReturnValue(chain) + chain.innerJoin = vi.fn().mockReturnValue(chain) + chain.orderBy = vi.fn().mockReturnValue(chain) + + return chain +} + describe('Permission Utils', () => { beforeEach(() => { vi.clearAllMocks() - - mockDb.select.mockReturnValue(mockDb) - mockDb.from.mockReturnValue(mockDb) - mockDb.where.mockReturnValue(mockDb) - mockDb.limit.mockResolvedValue([]) - mockDb.innerJoin.mockReturnValue(mockDb) - mockDb.orderBy.mockReturnValue(mockDb) }) describe('getUserEntityPermissions', () => { - it.concurrent('should return null when user has no permissions', async () => { - mockDb.where.mockResolvedValue([]) + it('should return null when user has no permissions', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') expect(result).toBeNull() - expect(mockDb.select).toHaveBeenCalledWith({ permissionType: permissions.permissionType }) - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.where).toHaveBeenCalledWith('and-condition') }) - it.concurrent( - 'should return the highest permission when user has multiple permissions', - async () => { - const mockResults = [ - { permissionType: 'read' as PermissionType }, - { permissionType: 'admin' as PermissionType }, - { permissionType: 'write' as PermissionType }, - ] - mockDb.where.mockResolvedValue(mockResults) + it('should return the highest permission when user has multiple permissions', async () => { + const mockResults = [ + { permissionType: 'read' as PermissionType }, + { permissionType: 'admin' as PermissionType }, + { permissionType: 'write' as PermissionType }, + ] + const chain = createMockChain(mockResults) + mockDb.select.mockReturnValue(chain) - const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') + const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') - expect(result).toBe('admin') - expect(mockDb.select).toHaveBeenCalledWith({ - permissionType: permissions.permissionType, - }) - expect(mockDb.from).toHaveBeenCalledWith(permissions) - } - ) + expect(result).toBe('admin') + }) - it.concurrent('should return single permission when user has only one', async () => { + it('should return single permission when user has only one', async () => { const mockResults = [{ permissionType: 'read' as PermissionType }] - mockDb.where.mockResolvedValue(mockResults) + const chain = createMockChain(mockResults) + mockDb.select.mockReturnValue(chain) const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789') expect(result).toBe('read') }) - it.concurrent('should handle different entity types', async () => { - const mockResults = [{ permissionType: 'write' as PermissionType }] - mockDb.where.mockResolvedValue(mockResults) - - const result = await getUserEntityPermissions('user456', 'organization', 'org123') - - expect(result).toBe('write') - }) - - it.concurrent( - 'should return highest permission when multiple exist (admin > write > read)', - async () => { - const mockResults = [ - { permissionType: 'read' as PermissionType }, - { permissionType: 'write' as PermissionType }, - ] - mockDb.where.mockResolvedValue(mockResults) - - const result = await getUserEntityPermissions('user789', 'workspace', 'workspace123') - - expect(result).toBe('write') - } - ) - - it.concurrent('should prioritize admin over other permissions', async () => { + it('should prioritize admin over other permissions', async () => { const mockResults = [ { permissionType: 'write' as PermissionType }, { permissionType: 'admin' as PermissionType }, { permissionType: 'read' as PermissionType }, ] - mockDb.where.mockResolvedValue(mockResults) + const chain = createMockChain(mockResults) + mockDb.select.mockReturnValue(chain) const result = await getUserEntityPermissions('user999', 'workspace', 'workspace999') expect(result).toBe('admin') }) - - it.concurrent('should handle edge case with single admin permission', async () => { - const mockResults = [{ permissionType: 'admin' as PermissionType }] - mockDb.where.mockResolvedValue(mockResults) - - const result = await getUserEntityPermissions('admin-user', 'workspace', 'workspace-admin') - - expect(result).toBe('admin') - }) - - it.concurrent('should correctly prioritize write over read', async () => { - const mockResults = [ - { permissionType: 'read' as PermissionType }, - { permissionType: 'write' as PermissionType }, - { permissionType: 'read' as PermissionType }, - ] - mockDb.where.mockResolvedValue(mockResults) - - const result = await getUserEntityPermissions('write-user', 'workflow', 'workflow-write') - - expect(result).toBe('write') - }) }) describe('hasAdminPermission', () => { - it.concurrent('should return true when user has admin permission for workspace', async () => { - const mockResult = [ - { - /* some admin permission record */ - }, - ] - mockDb.limit.mockResolvedValue(mockResult) + it('should return true when user has admin permission for workspace', async () => { + const chain = createMockChain([{ permissionType: 'admin' }]) + mockDb.select.mockReturnValue(chain) const result = await hasAdminPermission('admin-user', 'workspace123') expect(result).toBe(true) - expect(mockDb.select).toHaveBeenCalledWith() - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.limit).toHaveBeenCalledWith(1) }) - it.concurrent( - 'should return false when user has no admin permission for workspace', - async () => { - mockDb.limit.mockResolvedValue([]) + it('should return false when user has no admin permission for workspace', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) - const result = await hasAdminPermission('regular-user', 'workspace123') - - expect(result).toBe(false) - expect(mockDb.select).toHaveBeenCalledWith() - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.limit).toHaveBeenCalledWith(1) - } - ) - - it.concurrent('should handle different user and workspace combinations', async () => { - // Test with no admin permission - mockDb.limit.mockResolvedValue([]) - - const result1 = await hasAdminPermission('user456', 'workspace789') - expect(result1).toBe(false) - - // Test with admin permission - const mockAdminResult = [{ permissionType: 'admin' }] - mockDb.limit.mockResolvedValue(mockAdminResult) - - const result2 = await hasAdminPermission('admin789', 'workspace456') - expect(result2).toBe(true) - }) - - it.concurrent( - 'should call database with correct parameters for workspace admin check', - async () => { - mockDb.limit.mockResolvedValue([]) - - await hasAdminPermission('test-user-id', 'test-workspace-id') - - expect(mockDb.select).toHaveBeenCalledWith() - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.limit).toHaveBeenCalledWith(1) - } - ) - - it.concurrent( - 'should return true even if multiple admin records exist (due to limit 1)', - async () => { - // This shouldn't happen in practice, but tests the limit functionality - const mockResult = [{ permissionType: 'admin' }] // Only one record due to limit(1) - mockDb.limit.mockResolvedValue(mockResult) - - const result = await hasAdminPermission('super-admin', 'workspace999') - - expect(result).toBe(true) - expect(mockDb.limit).toHaveBeenCalledWith(1) - } - ) - - it.concurrent('should handle edge cases with empty strings', async () => { - mockDb.limit.mockResolvedValue([]) - - const result = await hasAdminPermission('', '') - - expect(result).toBe(false) - expect(mockDb.select).toHaveBeenCalled() - }) - - it.concurrent('should return false for non-existent workspace', async () => { - mockDb.limit.mockResolvedValue([]) - - const result = await hasAdminPermission('user123', 'non-existent-workspace') - - expect(result).toBe(false) - }) - - it.concurrent('should return false for non-existent user', async () => { - mockDb.limit.mockResolvedValue([]) - - const result = await hasAdminPermission('non-existent-user', 'workspace123') + const result = await hasAdminPermission('regular-user', 'workspace123') expect(result).toBe(false) }) }) describe('getUsersWithPermissions', () => { - it.concurrent( - 'should return empty array when no users have permissions for workspace', - async () => { - mockDb.orderBy.mockResolvedValue([]) + it('should return empty array when no users have permissions for workspace', async () => { + const usersChain = createMockChain([]) + mockDb.select.mockReturnValue(usersChain) - const result = await getUsersWithPermissions('workspace123') + const result = await getUsersWithPermissions('workspace123') - expect(result).toEqual([]) - expect(mockDb.select).toHaveBeenCalledWith({ - userId: user.id, - email: user.email, - name: user.name, - image: user.image, - permissionType: permissions.permissionType, - }) - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) - } - ) + expect(result).toEqual([]) + }) - it.concurrent('should return users with their permissions for workspace', async () => { - const mockResults = [ + it('should return users with their permissions for workspace', async () => { + const mockUsersResults = [ { userId: 'user1', email: 'alice@example.com', @@ -293,22 +165,10 @@ describe('Permission Utils', () => { image: 'https://example.com/alice.jpg', permissionType: 'admin' as PermissionType, }, - { - userId: 'user2', - email: 'bob@example.com', - name: 'Bob Johnson', - image: 'https://example.com/bob.jpg', - permissionType: 'write' as PermissionType, - }, - { - userId: 'user3', - email: 'charlie@example.com', - name: 'Charlie Brown', - image: null, - permissionType: 'read' as PermissionType, - }, ] - mockDb.orderBy.mockResolvedValue(mockResults) + + const usersChain = createMockChain(mockUsersResults) + mockDb.select.mockReturnValue(usersChain) const result = await getUsersWithPermissions('workspace456') @@ -320,158 +180,136 @@ describe('Permission Utils', () => { image: 'https://example.com/alice.jpg', permissionType: 'admin', }, - { - userId: 'user2', - email: 'bob@example.com', - name: 'Bob Johnson', - image: 'https://example.com/bob.jpg', - permissionType: 'write', - }, - { - userId: 'user3', - email: 'charlie@example.com', - name: 'Charlie Brown', - image: null, - permissionType: 'read', - }, ]) - expect(mockDb.select).toHaveBeenCalledWith({ - userId: user.id, - email: user.email, - name: user.name, - image: user.image, - permissionType: permissions.permissionType, + }) + }) + + describe('isOrganizationAdminForWorkspace', () => { + it('should return false when workspace does not exist', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) + + const result = await isOrganizationAdminForWorkspace('user123', 'workspace456') + + expect(result).toBe(false) + }) + + it('should return false when user has no organization memberships', async () => { + // Mock workspace exists, but user has no org memberships + let callCount = 0 + mockDb.select.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return createMockChain([{ ownerId: 'workspace-owner-123' }]) + } + return createMockChain([]) // No memberships }) - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) + + const result = await isOrganizationAdminForWorkspace('user123', 'workspace456') + + expect(result).toBe(false) + }) + }) + + describe('hasWorkspaceAdminAccess', () => { + it('should return true when user has direct admin permission', async () => { + const chain = createMockChain([{ permissionType: 'admin' }]) + mockDb.select.mockReturnValue(chain) + + const result = await hasWorkspaceAdminAccess('user123', 'workspace456') + + expect(result).toBe(true) }) - it.concurrent('should handle single user with permission', async () => { - const mockResults = [ - { - userId: 'solo-user', - email: 'solo@example.com', - name: 'Solo User', - image: 'https://example.com/solo.jpg', - permissionType: 'admin' as PermissionType, - }, - ] - mockDb.orderBy.mockResolvedValue(mockResults) + it('should return false when user has neither direct nor organization admin access', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) - const result = await getUsersWithPermissions('workspace-solo') + const result = await hasWorkspaceAdminAccess('user123', 'workspace456') - expect(result).toEqual([ - { - userId: 'solo-user', - email: 'solo@example.com', - name: 'Solo User', - image: 'https://example.com/solo.jpg', - permissionType: 'admin', - }, - ]) + expect(result).toBe(false) + }) + }) + + describe('isOrganizationOwnerOrAdmin', () => { + it('should return true when user is owner of organization', async () => { + const chain = createMockChain([{ role: 'owner' }]) + mockDb.select.mockReturnValue(chain) + + const result = await isOrganizationOwnerOrAdmin('user123', 'org456') + + expect(result).toBe(true) }) - it.concurrent('should handle users with null names and images', async () => { - const mockResults = [ - { - userId: 'user-minimal', - email: 'minimal@example.com', - name: null, - image: null, - permissionType: 'read' as PermissionType, - }, - ] - mockDb.orderBy.mockResolvedValue(mockResults) + it('should return true when user is admin of organization', async () => { + const chain = createMockChain([{ role: 'admin' }]) + mockDb.select.mockReturnValue(chain) - const result = await getUsersWithPermissions('workspace-minimal') + const result = await isOrganizationOwnerOrAdmin('user123', 'org456') - expect(result).toEqual([ - { - userId: 'user-minimal', - email: 'minimal@example.com', - name: null, - image: null, - permissionType: 'read', - }, - ]) + expect(result).toBe(true) }) - it.concurrent('should call database with correct parameters', async () => { - mockDb.orderBy.mockResolvedValue([]) + it('should return false when user is regular member of organization', async () => { + const chain = createMockChain([{ role: 'member' }]) + mockDb.select.mockReturnValue(chain) - await getUsersWithPermissions('test-workspace-123') + const result = await isOrganizationOwnerOrAdmin('user123', 'org456') - expect(mockDb.select).toHaveBeenCalledWith({ - userId: user.id, - email: user.email, - name: user.name, - image: user.image, - permissionType: permissions.permissionType, + expect(result).toBe(false) + }) + + it('should return false when user is not member of organization', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) + + const result = await isOrganizationOwnerOrAdmin('user123', 'org456') + + expect(result).toBe(false) + }) + + it('should handle errors gracefully', async () => { + mockDb.select.mockImplementation(() => { + throw new Error('Database error') }) - expect(mockDb.from).toHaveBeenCalledWith(permissions) - expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') - expect(mockDb.where).toHaveBeenCalledWith('and-condition') - expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) + + const result = await isOrganizationOwnerOrAdmin('user123', 'org456') + + expect(result).toBe(false) }) + }) - it.concurrent('should handle different workspace IDs', async () => { - mockDb.orderBy.mockResolvedValue([]) + describe('getManageableWorkspaces', () => { + it('should return empty array when user has no manageable workspaces', async () => { + const chain = createMockChain([]) + mockDb.select.mockReturnValue(chain) - const result1 = await getUsersWithPermissions('workspace-abc-123') - const result2 = await getUsersWithPermissions('workspace-xyz-789') - - expect(result1).toEqual([]) - expect(result2).toEqual([]) - expect(mockDb.select).toHaveBeenCalled() - expect(mockDb.from).toHaveBeenCalled() - expect(mockDb.innerJoin).toHaveBeenCalled() - expect(mockDb.where).toHaveBeenCalled() - expect(mockDb.orderBy).toHaveBeenCalled() - }) - - it.concurrent('should handle all permission types correctly', async () => { - const mockResults = [ - { - userId: 'admin-user', - email: 'admin@example.com', - name: 'Admin User', - image: 'admin.jpg', - permissionType: 'admin' as PermissionType, - }, - { - userId: 'write-user', - email: 'writer@example.com', - name: 'Write User', - image: 'writer.jpg', - permissionType: 'write' as PermissionType, - }, - { - userId: 'read-user', - email: 'reader@example.com', - name: 'Read User', - image: 'reader.jpg', - permissionType: 'read' as PermissionType, - }, - ] - mockDb.orderBy.mockResolvedValue(mockResults) - - const result = await getUsersWithPermissions('workspace-all-perms') - - expect(result).toHaveLength(3) - expect(result[0].permissionType).toBe('admin') - expect(result[1].permissionType).toBe('write') - expect(result[2].permissionType).toBe('read') - }) - - it.concurrent('should handle empty workspace ID', async () => { - mockDb.orderBy.mockResolvedValue([]) - - const result = await getUsersWithPermissions('') + const result = await getManageableWorkspaces('user123') expect(result).toEqual([]) - expect(mockDb.select).toHaveBeenCalled() + }) + + it('should return direct admin workspaces', async () => { + const mockDirectWorkspaces = [ + { id: 'ws1', name: 'Workspace 1', ownerId: 'owner1' }, + { id: 'ws2', name: 'Workspace 2', ownerId: 'owner2' }, + ] + + let callCount = 0 + mockDb.select.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return createMockChain(mockDirectWorkspaces) // direct admin workspaces + } + return createMockChain([]) // no organization memberships + }) + + const result = await getManageableWorkspaces('user123') + + expect(result).toEqual([ + { id: 'ws1', name: 'Workspace 1', ownerId: 'owner1', accessType: 'direct' }, + { id: 'ws2', name: 'Workspace 2', ownerId: 'owner2', accessType: 'direct' }, + ]) }) }) }) diff --git a/apps/sim/package.json b/apps/sim/package.json index f47836d6d..d35fe9f84 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -34,7 +34,6 @@ "@browserbasehq/stagehand": "^2.0.0", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@hookform/resolvers": "^4.1.3", - "@next/font": "14.2.15", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-collector": "^0.25.0", "@opentelemetry/exporter-jaeger": "^2.0.0", diff --git a/bun.lock b/bun.lock index 1f3da93fd..c6bbe7a28 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,6 @@ "@browserbasehq/stagehand": "^2.0.0", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@hookform/resolvers": "^4.1.3", - "@next/font": "14.2.15", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-collector": "^0.25.0", "@opentelemetry/exporter-jaeger": "^2.0.0", @@ -612,8 +611,6 @@ "@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="], - "@next/font": ["@next/font@14.2.15", "", { "peerDependencies": { "next": "*" } }, "sha512-QopYhBmCDDrNDynbi+ZD1hDZXmQXVFo7TmAFp4DQgO/kogz1OLbQ92hPigJbj572eZ3GaaVxNIyYVn3/eAsehg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw=="],