mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
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:
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
241
apps/sim/app/api/workspaces/invitations/[id]/route.test.ts
Normal file
241
apps/sim/app/api/workspaces/invitations/[id]/route.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
55
apps/sim/app/api/workspaces/invitations/[id]/route.ts
Normal file
55
apps/sim/app/api/workspaces/invitations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
324
apps/sim/app/api/workspaces/invitations/route.test.ts
Normal file
324
apps/sim/app/api/workspaces/invitations/route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -65,7 +65,6 @@
|
||||
"@browserbasehq/stagehand": "^2.0.0",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@next/font": "14.2.15",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-collector": "^0.25.0",
|
||||
"@opentelemetry/exporter-jaeger": "^2.0.0",
|
||||
@@ -612,8 +611,6 @@
|
||||
|
||||
"@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="],
|
||||
|
||||
"@next/font": ["@next/font@14.2.15", "", { "peerDependencies": { "next": "*" } }, "sha512-QopYhBmCDDrNDynbi+ZD1hDZXmQXVFo7TmAFp4DQgO/kogz1OLbQ92hPigJbj572eZ3GaaVxNIyYVn3/eAsehg=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw=="],
|
||||
|
||||
Reference in New Issue
Block a user