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
This commit is contained in:
Waleed Latif
2025-07-15 22:35:35 -07:00
committed by GitHub
parent 27c248a70c
commit bdfe7e9b99
20 changed files with 1554 additions and 886 deletions

View File

@@ -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,
}))

View File

@@ -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,

View File

@@ -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 })
}

View File

@@ -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)

View File

@@ -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' })
})
})

View File

@@ -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 })
}
}

View File

@@ -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)

View File

@@ -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('<html>email content</html>'),
}))
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()
})
})
})

View File

@@ -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

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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<HTMLInputElement>(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 }}
/>
<span className='flex-1 select-none truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => 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
/>
) : (
<span className='flex-1 select-none truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
)}
</Link>
{!isMarketplace && (
{!isMarketplace && !isEditing && (
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<WorkflowContextMenu workflow={workflow} level={level} />
<WorkflowContextMenu onStartEdit={handleStartEdit} />
</div>
)}
</div>

View File

@@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className='h-3 w-3' />
<span className='sr-only'>Workflow options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
{userPermissions.canEdit && (
<DropdownMenuItem
onClick={handleRename}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Pencil className='mr-2 h-4 w-4' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Rename dialog */}
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Rename Workflow</DialogTitle>
</DialogHeader>
<form onSubmit={handleRenameSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='rename-workflow'>Workflow Name</Label>
<Input
id='rename-workflow'
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder='Enter workflow name...'
maxLength={100}
autoFocus
required
/>
</div>
<div className='flex justify-end space-x-2'>
<Button type='button' variant='outline' onClick={handleCancel}>
Cancel
</Button>
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
{isRenaming ? 'Renaming...' : 'Rename'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
<MoreHorizontal className='h-3 w-3' />
<span className='sr-only'>Workflow options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
onClick={(e) => e.stopPropagation()}
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
{userPermissions.canEdit && (
<DropdownMenuItem
onClick={handleRename}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Pencil className='mr-2 h-4 w-4' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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<WorkspaceHeaderProps>(
}) => {
// 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<WorkspaceHeaderProps>(
// 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<WorkspaceHeaderProps>(
}}
/>
) : (
<div
onClick={handleWorkspaceNameClick}
className='cursor-pointer truncate font-medium text-sm leading-none transition-all hover:brightness-75 dark:hover:brightness-125'
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
>
{displayName}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={handleWorkspaceNameClick}
className={`truncate font-medium text-sm leading-none transition-all ${
userPermissions.canAdmin
? 'cursor-pointer hover:brightness-75 dark:hover:brightness-125'
: 'cursor-default'
}`}
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
>
{displayName}
</div>
</TooltipTrigger>
{!userPermissions.canAdmin && (
<TooltipContent side='bottom'>
Admin permissions required to rename workspace
</TooltipContent>
)}
</Tooltip>
)}
</div>

View File

@@ -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<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -189,198 +192,244 @@ const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string =>
}
}
const PermissionsTable = React.memo<PermissionsTableProps>(
({
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 <PermissionsTableSkeleton />
}
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 <PermissionsTableSkeleton />
}
if (
userPermissions.length === 0 &&
!session?.user?.email &&
!workspacePermissions?.users?.length
)
return null
if (isSaving) {
return (
<div className='space-y-4'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<div className='rounded-md border bg-card'>
<div className='flex items-center justify-center py-12'>
<div className='flex items-center space-x-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
<span className='font-medium text-sm'>Saving permission changes...</span>
</div>
</div>
</div>
<div className='flex min-h-[2rem] items-start'>
<p className='text-muted-foreground text-xs'>
Please wait while we update the permissions.
</p>
</div>
</div>
)
}
const currentUserIsAdmin = userPerms.canAdmin
if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length)
return null
if (isSaving) {
return (
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
type='button'
>
<HelpCircle className='h-4 w-4' />
<span className='sr-only'>Member permissions help</span>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[320px]'>
<div className='space-y-2'>
{userPerms.isLoading || permissionsLoading ? (
<p className='text-sm'>Loading permissions...</p>
) : !currentUserIsAdmin ? (
<p className='text-sm'>
Only administrators can invite new members and modify permissions.
</p>
) : (
<div className='space-y-1'>
<p className='text-sm'>Admin grants all permissions automatically.</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</div>
<div className='rounded-md border'>
{allUsers.length > 0 && (
<div className='divide-y'>
{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 (
<div key={uniqueKey} className='flex items-center justify-between p-4'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>
{user.email}
</span>
{isPendingInvitation && (
<span className={getStatusBadgeStyles('sent')}>Sent</span>
)}
</div>
<div className='mt-1 flex items-center gap-2'>
{isExistingUser && !isCurrentUser && (
<span className={getStatusBadgeStyles('member')}>Member</span>
)}
{hasChanges && (
<span className={getStatusBadgeStyles('modified')}>Modified</span>
)}
</div>
</div>
<div className='flex-shrink-0'>
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) =>
onPermissionChange(userIdentifier, newPermission)
}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
className='w-auto'
/>
</div>
</div>
)
})}
<h3 className='font-medium text-sm'>Member Permissions</h3>
<div className='rounded-md border bg-card'>
<div className='flex items-center justify-center py-12'>
<div className='flex items-center space-x-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
<span className='font-medium text-sm'>Saving permission changes...</span>
</div>
)}
</div>
</div>
<div className='flex min-h-[2rem] items-start'>
<p className='text-muted-foreground text-xs'>
Please wait while we update the permissions.
</p>
</div>
</div>
)
}
)
PermissionsTable.displayName = 'PermissionsTable'
const currentUserIsAdmin = userPerms.canAdmin
return (
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
type='button'
>
<HelpCircle className='h-4 w-4' />
<span className='sr-only'>Member permissions help</span>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[320px]'>
<div className='space-y-2'>
{userPerms.isLoading || permissionsLoading ? (
<p className='text-sm'>Loading permissions...</p>
) : !currentUserIsAdmin ? (
<p className='text-sm'>
Only administrators can invite new members and modify permissions.
</p>
) : (
<div className='space-y-1'>
<p className='text-sm'>Admin grants all permissions automatically.</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</div>
<div className='rounded-md border'>
{allUsers.length > 0 && (
<div className='divide-y'>
{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 (
<div key={uniqueKey} className='flex items-center justify-between p-4'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className={getStatusBadgeStyles('sent')}>Sent</span>
)}
{/* Show remove button for existing workspace members (not current user, not pending) */}
{canShowRemoveButton && onRemoveMember && (
<Button
variant='ghost'
size='sm'
onClick={() => onRemoveMember(user.userId!, user.email)}
disabled={disabled || isSaving}
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
title={`Remove ${user.email} from workspace`}
>
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Remove {user.email}</span>
</Button>
)}
{/* Show remove button for pending invitations */}
{isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onRemoveInvitation && (
<Button
variant='ghost'
size='sm'
onClick={() => onRemoveInvitation(user.invitationId!, user.email)}
disabled={disabled || isSaving}
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
title={`Cancel invitation for ${user.email}`}
>
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Cancel invitation for {user.email}</span>
</Button>
)}
</div>
<div className='mt-1 flex items-center gap-2'>
{isExistingUser && !isCurrentUser && (
<span className={getStatusBadgeStyles('member')}>Member</span>
)}
{hasChanges && (
<span className={getStatusBadgeStyles('modified')}>Modified</span>
)}
</div>
</div>
<div className='flex-shrink-0'>
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) =>
onPermissionChange(userIdentifier, newPermission)
}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
className='w-auto'
/>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
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<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(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<HTMLInputElement>) => {
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) {
<PermissionsTable
userPermissions={userPermissions}
onPermissionChange={handlePermissionChange}
disabled={isSubmitting || isSaving}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
workspacePermissions={workspacePermissions}
@@ -946,6 +1148,74 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
</form>
</div>
</DialogContent>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Remove Member</DialogTitle>
</DialogHeader>
<div className='py-4'>
<p className='text-muted-foreground text-sm'>
Are you sure you want to remove{' '}
<span className='font-medium text-foreground'>{memberToRemove?.email}</span> from this
workspace? This action cannot be undone.
</p>
</div>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
onClick={handleRemoveMemberCancel}
disabled={isRemovingMember}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleRemoveMemberConfirm}
disabled={isRemovingMember}
className='gap-2'
>
{isRemovingMember && <Loader2 className='h-4 w-4 animate-spin' />}
Remove Member
</Button>
</div>
</DialogContent>
</Dialog>
{/* Remove Invitation Confirmation Dialog */}
<Dialog open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Cancel Invitation</DialogTitle>
</DialogHeader>
<div className='py-4'>
<p className='text-muted-foreground text-sm'>
Are you sure you want to cancel the invitation for{' '}
<span className='font-medium text-foreground'>{invitationToRemove?.email}</span>? This
action cannot be undone.
</p>
</div>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
onClick={handleRemoveInvitationCancel}
disabled={isRemovingInvitation}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleRemoveInvitationConfirm}
disabled={isRemovingInvitation}
className='gap-2'
>
{isRemovingInvitation && <Loader2 className='h-4 w-4 animate-spin' />}
Cancel Invitation
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}

View File

@@ -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',
{

View File

@@ -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' },
])
})
})
})

View File

@@ -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",

View File

@@ -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=="],