mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 16:35:01 -05:00
improvement(lib): refactored lib/ to be more aligned with queries and api directory (#2160)
* fix(lib): consolidate into core dir in lib/ * refactored lib/
This commit is contained in:
@@ -2,8 +2,8 @@ import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowFolder, workspace as workspaceTable } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/duplicate'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceDuplicate')
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Utility functions for generating names for workspaces and folders
|
||||
*/
|
||||
|
||||
import type { Workspace } from '@/lib/organization/types'
|
||||
import type { Workspace } from '@/lib/workspaces/organization/types'
|
||||
import type { WorkflowFolder } from '@/stores/folders/store'
|
||||
|
||||
export interface NameableEntity {
|
||||
|
||||
23
apps/sim/lib/workspaces/organization/index.ts
Normal file
23
apps/sim/lib/workspaces/organization/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Export types
|
||||
export type {
|
||||
Invitation,
|
||||
Member,
|
||||
MemberUsageData,
|
||||
Organization,
|
||||
OrganizationBillingData,
|
||||
OrganizationFormData,
|
||||
Subscription,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@/lib/workspaces/organization/types'
|
||||
// Export utility functions
|
||||
export {
|
||||
calculateSeatUsage,
|
||||
generateSlug,
|
||||
getUsedSeats,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
validateEmail,
|
||||
validateSlug,
|
||||
} from '@/lib/workspaces/organization/utils'
|
||||
167
apps/sim/lib/workspaces/organization/types.ts
Normal file
167
apps/sim/lib/workspaces/organization/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export interface User {
|
||||
name?: string
|
||||
email?: string
|
||||
id?: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
role: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo?: string | null
|
||||
members?: Member[]
|
||||
invitations?: Invitation[]
|
||||
createdAt: string | Date
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
seats?: number
|
||||
referenceId: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
metadata?: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
permission: string
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
isOwner: boolean
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationFormData {
|
||||
name: string
|
||||
slug: string
|
||||
logo: string
|
||||
}
|
||||
|
||||
export interface MemberUsageData {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
percentUsed: number
|
||||
isOverLimit: boolean
|
||||
role: string
|
||||
joinedAt: string
|
||||
lastActive: string | null
|
||||
}
|
||||
|
||||
export interface OrganizationBillingData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
seatsCount: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
members?: MemberUsageData[]
|
||||
userRole?: string
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationState {
|
||||
// Core organization data
|
||||
organizations: Organization[]
|
||||
activeOrganization: Organization | null
|
||||
|
||||
// Team management
|
||||
subscriptionData: Subscription | null
|
||||
userWorkspaces: Workspace[]
|
||||
|
||||
// Organization billing and usage
|
||||
organizationBillingData: OrganizationBillingData | null
|
||||
|
||||
// Organization settings
|
||||
orgFormData: OrganizationFormData
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
isLoadingSubscription: boolean
|
||||
isLoadingOrgBilling: boolean
|
||||
isCreatingOrg: boolean
|
||||
isInviting: boolean
|
||||
isSavingOrgSettings: boolean
|
||||
|
||||
// Error states
|
||||
error: string | null
|
||||
orgSettingsError: string | null
|
||||
|
||||
// Success states
|
||||
inviteSuccess: boolean
|
||||
orgSettingsSuccess: string | null
|
||||
|
||||
// Cache timestamps
|
||||
lastFetched: number | null
|
||||
lastSubscriptionFetched: number | null
|
||||
lastOrgBillingFetched: number | null
|
||||
|
||||
// User permissions
|
||||
hasTeamPlan: boolean
|
||||
hasEnterprisePlan: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationStore extends OrganizationState {
|
||||
loadData: () => Promise<void>
|
||||
loadOrganizationSubscription: (orgId: string) => Promise<void>
|
||||
loadOrganizationBillingData: (organizationId: string, force?: boolean) => Promise<void>
|
||||
loadUserWorkspaces: (userId?: string) => Promise<void>
|
||||
refreshOrganization: () => Promise<void>
|
||||
|
||||
// Organization management
|
||||
createOrganization: (name: string, slug: string) => Promise<void>
|
||||
setActiveOrganization: (orgId: string) => Promise<void>
|
||||
updateOrganizationSettings: () => Promise<void>
|
||||
|
||||
// Team management
|
||||
inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise<void>
|
||||
removeMember: (memberId: string, shouldReduceSeats?: boolean) => Promise<void>
|
||||
cancelInvitation: (invitationId: string) => Promise<void>
|
||||
|
||||
// Seat management
|
||||
addSeats: (newSeatCount: number) => Promise<void>
|
||||
reduceSeats: (newSeatCount: number) => Promise<void>
|
||||
|
||||
transferSubscriptionToOrganization: (orgId: string) => Promise<void>
|
||||
|
||||
getUserRole: (userEmail?: string) => string
|
||||
isAdminOrOwner: (userEmail?: string) => boolean
|
||||
getUsedSeats: () => { used: number; members: number; pending: number }
|
||||
|
||||
setOrgFormData: (data: Partial<OrganizationFormData>) => void
|
||||
|
||||
clearError: () => void
|
||||
clearSuccessMessages: () => void
|
||||
}
|
||||
89
apps/sim/lib/workspaces/organization/utils.ts
Normal file
89
apps/sim/lib/workspaces/organization/utils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Utility functions for organization-related operations
|
||||
* These are pure functions that compute values from organization data
|
||||
*/
|
||||
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import type { Organization } from '@/lib/workspaces/organization/types'
|
||||
|
||||
/**
|
||||
* Get the role of a user in an organization
|
||||
*/
|
||||
export function getUserRole(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): string {
|
||||
if (!userEmail || !organization?.members) {
|
||||
return 'member'
|
||||
}
|
||||
const currentMember = organization.members.find((m) => m.user?.email === userEmail)
|
||||
return currentMember?.role ?? 'member'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin or owner in an organization
|
||||
*/
|
||||
export function isAdminOrOwner(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
const role = getUserRole(organization, userEmail)
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate seat usage for an organization
|
||||
*/
|
||||
export function calculateSeatUsage(organization: Organization | null | undefined): {
|
||||
used: number
|
||||
members: number
|
||||
pending: number
|
||||
} {
|
||||
if (!organization) {
|
||||
return { used: 0, members: 0, pending: 0 }
|
||||
}
|
||||
|
||||
const membersCount = organization.members?.length || 0
|
||||
const pendingInvitationsCount =
|
||||
organization.invitations?.filter((inv) => inv.status === 'pending').length || 0
|
||||
|
||||
return {
|
||||
used: membersCount + pendingInvitationsCount,
|
||||
members: membersCount,
|
||||
pending: pendingInvitationsCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used seats from an organization
|
||||
* Alias for calculateSeatUsage
|
||||
*/
|
||||
export function getUsedSeats(organization: Organization | null | undefined) {
|
||||
return calculateSeatUsage(organization)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from a name
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
|
||||
.replace(/-+/g, '-') // Replace consecutive hyphens with single hyphen
|
||||
.replace(/^-|-$/g, '') // Remove leading and trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate organization slug format
|
||||
*/
|
||||
export function validateSlug(slug: string): boolean {
|
||||
const slugRegex = /^[a-z0-9-_]+$/
|
||||
return slugRegex.test(slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function validateEmail(email: string): boolean {
|
||||
return quickValidateEmail(email.trim().toLowerCase()).isValid
|
||||
}
|
||||
616
apps/sim/lib/workspaces/permissions/utils.test.ts
Normal file
616
apps/sim/lib/workspaces/permissions/utils.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
innerJoin: vi.fn(),
|
||||
leftJoin: vi.fn(),
|
||||
orderBy: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
permissions: {
|
||||
permissionType: 'permission_type',
|
||||
userId: 'user_id',
|
||||
entityType: 'entity_type',
|
||||
entityId: 'entity_id',
|
||||
id: 'permission_id',
|
||||
},
|
||||
permissionTypeEnum: {
|
||||
enumValues: ['admin', 'write', 'read'] as const,
|
||||
},
|
||||
user: {
|
||||
id: 'user_id',
|
||||
email: 'user_email',
|
||||
name: 'user_name',
|
||||
},
|
||||
workspace: {
|
||||
id: 'workspace_id',
|
||||
name: 'workspace_name',
|
||||
ownerId: 'workspace_owner_id',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn().mockReturnValue('and-condition'),
|
||||
eq: vi.fn().mockReturnValue('eq-condition'),
|
||||
or: vi.fn().mockReturnValue('or-condition'),
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
getManageableWorkspaces,
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
hasAdminPermission,
|
||||
hasWorkspaceAdminAccess,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
describe('getUserEntityPermissions', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return single permission when user has only one', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should prioritize admin over other permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user999', 'workspace', 'workspace999')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return write permission when user only has write access', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should prioritize write over read permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should work with workflow entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'admin' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should work with organization entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'organization', 'org456')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle generic entity types', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'custom_entity', 'entity123')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAdminPermission', () => {
|
||||
it('should return true when user has admin permission for workspace', async () => {
|
||||
const chain = createMockChain([{ id: 'perm1' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('admin-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('should return false when user has write permission but not admin', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('write-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has read permission but not admin', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('read-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle non-existent workspace', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('user123', 'non-existent-workspace')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('', 'workspace123')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersWithPermissions', () => {
|
||||
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')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return users with their permissions for workspace', async () => {
|
||||
const mockUsersResults = [
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
},
|
||||
]
|
||||
|
||||
const usersChain = createMockChain(mockUsersResults)
|
||||
mockDb.select.mockReturnValue(usersChain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
permissionType: 'admin',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should return multiple users with different permission levels', async () => {
|
||||
const mockUsersResults = [
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
email: 'writer@example.com',
|
||||
name: 'Writer User',
|
||||
permissionType: 'write' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
email: 'reader@example.com',
|
||||
name: 'Reader User',
|
||||
permissionType: 'read' as PermissionType,
|
||||
},
|
||||
]
|
||||
|
||||
const usersChain = createMockChain(mockUsersResults)
|
||||
mockDb.select.mockReturnValue(usersChain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].permissionType).toBe('admin')
|
||||
expect(result[1].permissionType).toBe('write')
|
||||
expect(result[2].permissionType).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle users with empty names', async () => {
|
||||
const mockUsersResults = [
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'test@example.com',
|
||||
name: '',
|
||||
permissionType: 'read' as PermissionType,
|
||||
},
|
||||
]
|
||||
|
||||
const usersChain = createMockChain(mockUsersResults)
|
||||
mockDb.select.mockReturnValue(usersChain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasWorkspaceAdminAccess', () => {
|
||||
it('should return true when user owns the workspace', async () => {
|
||||
const chain = createMockChain([{ ownerId: 'user123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when user has direct admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ id: 'perm1' }])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has no admin access', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has write permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has read permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', '')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Security Tests', () => {
|
||||
it('should handle SQL injection attempts in user IDs', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions(
|
||||
"'; DROP TABLE users; --",
|
||||
'workspace',
|
||||
'workspace123'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle very long entity IDs', async () => {
|
||||
const longEntityId = 'a'.repeat(1000)
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', longEntityId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle unicode characters in entity names', async () => {
|
||||
const chain = createMockChain([{ permissionType: 'read' as PermissionType }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', '📝workspace', '🏢org-id')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should verify permission hierarchy ordering is consistent', () => {
|
||||
const permissionOrder: Record<PermissionType, number> = { admin: 3, write: 2, read: 1 }
|
||||
|
||||
expect(permissionOrder.admin).toBeGreaterThan(permissionOrder.write)
|
||||
expect(permissionOrder.write).toBeGreaterThan(permissionOrder.read)
|
||||
})
|
||||
|
||||
it('should handle workspace ownership checks with null owner IDs', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: null }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null user ID correctly when owner ID is different', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess(null as any, 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getManageableWorkspaces', () => {
|
||||
it('should return empty array when user has no manageable workspaces', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return owned workspaces', async () => {
|
||||
const mockWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain([]) // No admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123', accessType: 'owner' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should return workspaces with direct admin permissions', async () => {
|
||||
const mockAdminWorkspaces = [{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should combine owned and admin workspaces without duplicates', async () => {
|
||||
const mockOwnedWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123' },
|
||||
]
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' }, // Duplicate (should be filtered)
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockOwnedWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty workspace names', async () => {
|
||||
const mockWorkspaces = [{ id: 'ws1', name: '', ownerId: 'user123' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces)
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
|
||||
it('should handle multiple admin permissions for same workspace', async () => {
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }, // Duplicate
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces with duplicates
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(2) // Should include duplicates from admin permissions
|
||||
})
|
||||
|
||||
it('should handle empty user ID gracefully', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
184
apps/sim/lib/workspaces/permissions/utils.ts
Normal file
184
apps/sim/lib/workspaces/permissions/utils.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
/**
|
||||
* Get the highest permission level a user has for a specific entity
|
||||
*
|
||||
* @param userId - The ID of the user to check permissions for
|
||||
* @param entityType - The type of entity (e.g., 'workspace', 'workflow', etc.)
|
||||
* @param entityId - The ID of the specific entity
|
||||
* @returns Promise<PermissionType | null> - The highest permission the user has for the entity, or null if none
|
||||
*/
|
||||
export async function getUserEntityPermissions(
|
||||
userId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<PermissionType | null> {
|
||||
const result = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, entityType),
|
||||
eq(permissions.entityId, entityId)
|
||||
)
|
||||
)
|
||||
|
||||
if (result.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const permissionOrder: Record<PermissionType, number> = { admin: 3, write: 2, read: 1 }
|
||||
const highestPermission = result.reduce((highest, current) => {
|
||||
return permissionOrder[current.permissionType] > permissionOrder[highest.permissionType]
|
||||
? current
|
||||
: highest
|
||||
})
|
||||
|
||||
return highestPermission.permissionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has admin permission for a specific workspace
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param workspaceId - The ID of the workspace to check
|
||||
* @returns Promise<boolean> - True if the user has admin permission for the workspace, false otherwise
|
||||
*/
|
||||
export async function hasAdminPermission(userId: string, workspaceId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return result.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of users with their associated permissions for a given workspace.
|
||||
*
|
||||
* @param workspaceId - The ID of the workspace to retrieve user permissions for.
|
||||
* @returns A promise that resolves to an array of user objects, each containing user details and their permission type.
|
||||
*/
|
||||
export async function getUsersWithPermissions(workspaceId: string): Promise<
|
||||
Array<{
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
permissionType: PermissionType
|
||||
}>
|
||||
> {
|
||||
const usersWithPermissions = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
|
||||
.orderBy(user.email)
|
||||
|
||||
return usersWithPermissions.map((row) => ({
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
permissionType: row.permissionType,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has admin access to a specific workspace
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param workspaceId - The ID of the workspace to check
|
||||
* @returns Promise<boolean> - True if the user has admin access to the workspace, false otherwise
|
||||
*/
|
||||
export async function hasWorkspaceAdminAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const workspaceResult = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceResult.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (workspaceResult[0].ownerId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return await hasAdminPermission(userId, workspaceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of workspaces that the user has access to
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @returns Promise<Array<{
|
||||
* id: string
|
||||
* name: string
|
||||
* ownerId: string
|
||||
* accessType: 'direct' | 'owner'
|
||||
* }>> - A list of workspaces that the user has access to
|
||||
*/
|
||||
export async function getManageableWorkspaces(userId: string): Promise<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'owner'
|
||||
}>
|
||||
> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.ownerId, userId))
|
||||
|
||||
const adminWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
|
||||
const ownedSet = new Set(ownedWorkspaces.map((w) => w.id))
|
||||
const combined = [
|
||||
...ownedWorkspaces.map((ws) => ({ ...ws, accessType: 'owner' as const })),
|
||||
...adminWorkspaces
|
||||
.filter((ws) => !ownedSet.has(ws.id))
|
||||
.map((ws) => ({ ...ws, accessType: 'direct' as const })),
|
||||
]
|
||||
|
||||
return combined
|
||||
}
|
||||
82
apps/sim/lib/workspaces/presence-colors.ts
Normal file
82
apps/sim/lib/workspaces/presence-colors.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
const APP_COLORS = [
|
||||
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
|
||||
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
|
||||
{ from: '#EC4899', to: '#F97316' }, // pink to orange
|
||||
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
|
||||
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
|
||||
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
|
||||
]
|
||||
|
||||
interface PresenceColorPalette {
|
||||
gradient: string
|
||||
accentColor: string
|
||||
baseColor: string
|
||||
}
|
||||
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/
|
||||
|
||||
function hashIdentifier(identifier: string | number): number {
|
||||
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
||||
return Math.abs(Math.trunc(identifier))
|
||||
}
|
||||
|
||||
if (typeof identifier === 'string') {
|
||||
return Math.abs(Array.from(identifier).reduce((acc, char) => acc + char.charCodeAt(0), 0))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function withAlpha(hexColor: string, alpha: number): string {
|
||||
if (!HEX_COLOR_REGEX.test(hexColor)) {
|
||||
return hexColor
|
||||
}
|
||||
|
||||
const normalized = hexColor.slice(1)
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((char) => `${char}${char}`)
|
||||
.join('')
|
||||
: normalized
|
||||
|
||||
const r = Number.parseInt(expanded.slice(0, 2), 16)
|
||||
const g = Number.parseInt(expanded.slice(2, 4), 16)
|
||||
const b = Number.parseInt(expanded.slice(4, 6), 16)
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})`
|
||||
}
|
||||
|
||||
function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string {
|
||||
const rotation = (rotationSeed * 25) % 360
|
||||
return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})`
|
||||
}
|
||||
|
||||
export function getPresenceColors(
|
||||
identifier: string | number,
|
||||
explicitColor?: string
|
||||
): PresenceColorPalette {
|
||||
const paletteIndex = hashIdentifier(identifier)
|
||||
|
||||
if (explicitColor) {
|
||||
const normalizedColor = explicitColor.trim()
|
||||
const lighterShade = HEX_COLOR_REGEX.test(normalizedColor)
|
||||
? withAlpha(normalizedColor, 0.85)
|
||||
: normalizedColor
|
||||
|
||||
return {
|
||||
gradient: buildGradient(lighterShade, normalizedColor, paletteIndex),
|
||||
accentColor: normalizedColor,
|
||||
baseColor: lighterShade,
|
||||
}
|
||||
}
|
||||
|
||||
const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length]
|
||||
|
||||
return {
|
||||
gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex),
|
||||
accentColor: colorPair.to,
|
||||
baseColor: colorPair.from,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user