Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/builder/src/features/auth/api/customAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function customAdapter(p: PrismaClient): Adapter {
name: data.name ? `${data.name}'s workspace` : `My workspace`,
plan: parseWorkspaceDefaultPlan(data.email),
}

const createdUser = await p.user.create({
data: {
...data,
Expand Down
11 changes: 7 additions & 4 deletions apps/builder/src/features/auth/types/cognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ export type CognitoJWTPayload = {
email: string
}

export type CognitoClaims = Pick<
CognitoJWTPayload,
'custom:eddie_workspaces' | 'custom:hub_role'
>

type WithCognitoClaims = {
cognitoClaims?: Pick<
CognitoJWTPayload,
'custom:eddie_workspaces' | 'custom:hub_role'
>
cognitoClaims?: CognitoClaims
}

// Database user extended with Cognito claims
Expand All @@ -34,4 +36,5 @@ export type NextAuthJWTWithCognito = Record<string, unknown> &
name?: string
image?: string
provider?: string
cloudChatAuthorization?: boolean
}
75 changes: 46 additions & 29 deletions apps/builder/src/features/workspace/api/listWorkspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { workspaceSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { checkCognitoWorkspaceAccess } from '../helpers/cognitoUtils'
import { Prisma } from '@typebot.io/prisma'
import {
getCognitoAccessibleWorkspaceIds,
type CognitoAccess,
} from '../helpers/cognitoUtils'

export const listWorkspaces = authenticatedProcedure
.meta({
Expand All @@ -24,36 +28,49 @@ export const listWorkspaces = authenticatedProcedure
})
)
.query(async ({ ctx: { user } }) => {
// First, get workspaces where user is a member in the database
const dbWorkspaces = await prisma.workspace.findMany({
where: { members: { some: { userId: user.id } } },
select: { name: true, id: true, icon: true, plan: true },
})

// Create a set of workspace IDs that user already has database access to
const dbWorkspaceIds = new Set(dbWorkspaces.map((w) => w.id))

// Then, check for Cognito-based workspace access
let cognitoWorkspaces: typeof dbWorkspaces = []

// Get workspaces that user doesn't already have database access to
const remainingWorkspaces = await prisma.workspace.findMany({
where: {
id: { notIn: Array.from(dbWorkspaceIds) },
},
select: { name: true, id: true, icon: true, plan: true },
})

cognitoWorkspaces = remainingWorkspaces.filter((workspace) => {
const cognitoAccess = checkCognitoWorkspaceAccess(user, workspace.id)
return cognitoAccess.hasAccess
})

// Combine workspaces (no need for Map since they're now guaranteed to be unique)
const workspaces = [...dbWorkspaces, ...cognitoWorkspaces]
const cognitoAccess = getCognitoAccessibleWorkspaceIds(user)
const workspaces = await findWorkspaces(user.id, cognitoAccess)

if (!workspaces || workspaces.length === 0)
if (workspaces.length === 0)
throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' })

return { workspaces }
})

const findWorkspaces = (userId: string, cognitoAccess: CognitoAccess) => {
const workspaceFilter = getWorkspaceFilter(userId, cognitoAccess)
return prisma.workspace.findMany({
where: workspaceFilter,
select: {
id: true,
name: true,
icon: true,
plan: true,
},
})
}

const getWorkspaceFilter = (
userId: string,
cognitoAccess: CognitoAccess
): Prisma.WorkspaceWhereInput => {
switch (cognitoAccess.type) {
case 'admin':
return {
NOT: { name: { contains: "'s workspace" } },
}
case 'restricted':
return {
OR: [
{
members: { some: { userId } },
},
{ id: { in: cognitoAccess.ids } },
],
}
case 'none':
return {
members: { some: { userId } },
}
}
}
115 changes: 102 additions & 13 deletions apps/builder/src/features/workspace/helpers/cognitoUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
mapCognitoRoleToWorkspaceRole,
hasWorkspaceAccess,
checkCognitoWorkspaceAccess,
getCognitoAccessibleWorkspaceIds,
} from './cognitoUtils'

describe('extractCognitoUserClaims', () => {
Expand Down Expand Up @@ -131,26 +132,26 @@ describe('mapCognitoRoleToWorkspaceRole', () => {
expect(mapCognitoRoleToWorkspaceRole('CLIENT')).toBe(WorkspaceRole.MEMBER)
})

it('should default unknown roles to MEMBER', () => {
expect(mapCognitoRoleToWorkspaceRole('UNKNOWN')).toBe(WorkspaceRole.MEMBER)
it('should default unknown roles to GUEST', () => {
expect(mapCognitoRoleToWorkspaceRole('UNKNOWN')).toBe(WorkspaceRole.GUEST)
})

it('should handle empty string', () => {
expect(mapCognitoRoleToWorkspaceRole('')).toBe(WorkspaceRole.MEMBER)
expect(mapCognitoRoleToWorkspaceRole('')).toBe(WorkspaceRole.GUEST)
})

it('should handle null/undefined gracefully', () => {
expect(mapCognitoRoleToWorkspaceRole(null as unknown as string)).toBe(
WorkspaceRole.MEMBER
WorkspaceRole.GUEST
)
expect(mapCognitoRoleToWorkspaceRole(undefined as unknown as string)).toBe(
WorkspaceRole.MEMBER
WorkspaceRole.GUEST
)
})

it('should be case sensitive', () => {
expect(mapCognitoRoleToWorkspaceRole('admin')).toBe(WorkspaceRole.MEMBER)
expect(mapCognitoRoleToWorkspaceRole('Admin')).toBe(WorkspaceRole.MEMBER)
expect(mapCognitoRoleToWorkspaceRole('admin')).toBe(WorkspaceRole.GUEST)
expect(mapCognitoRoleToWorkspaceRole('Admin')).toBe(WorkspaceRole.GUEST)
})
})

Expand Down Expand Up @@ -225,9 +226,9 @@ describe('hasWorkspaceAccess', () => {
expect(hasWorkspaceAccess(claims, 'ws-123')).toBe(true)
})

it('should use exact match (case-sensitive) for workspace id', () => {
it('should use exact match (case-sensitive) for workspace id in eddie_workspaces', () => {
const claims = {
'custom:hub_role': 'ADMIN' as const,
'custom:hub_role': 'CLIENT' as const,
'custom:eddie_workspaces': 'ws-123,WS-456',
}

Expand Down Expand Up @@ -263,7 +264,7 @@ describe('checkCognitoWorkspaceAccess', () => {
})
})

it('should return hasAccess false when workspace id is not in eddie_workspaces', () => {
it('should return hasAccess true for ADMIN even when workspace id is not in eddie_workspaces', () => {
const user = {
id: 'user123',
email: 'test@example.com',
Expand All @@ -273,9 +274,9 @@ describe('checkCognitoWorkspaceAccess', () => {
},
}

expect(checkCognitoWorkspaceAccess(user, 'ws-123')).toEqual({
hasAccess: false,
})
const result = checkCognitoWorkspaceAccess(user, 'ws-123')
expect(result.hasAccess).toBe(true)
expect(result.role).toBe(WorkspaceRole.ADMIN)
})

it('should return hasAccess true with mapped role when hub_role is present', () => {
Expand Down Expand Up @@ -326,3 +327,91 @@ describe('checkCognitoWorkspaceAccess', () => {
expect(result.role).toBe(WorkspaceRole.MEMBER)
})
})

describe('getCognitoAccessibleWorkspaceIds', () => {
it('should return { type: "all" } when user is ADMIN', () => {
const user = {
cognitoClaims: {
'custom:hub_role': 'ADMIN',
'custom:eddie_workspaces': 'ws-123,ws-456',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({ type: 'admin' })
})

it('should return specific workspace IDs for non-admin with eddie_workspaces', () => {
const user = {
cognitoClaims: {
'custom:hub_role': 'CLIENT',
'custom:eddie_workspaces': 'ws-123,ws-456,ws-789',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({
type: 'restricted',
ids: ['ws-123', 'ws-456', 'ws-789'],
})
})

it('should trim whitespace from workspace IDs', () => {
const user = {
cognitoClaims: {
'custom:eddie_workspaces': ' ws-123 , ws-456 , ws-789 ',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({
type: 'restricted',
ids: ['ws-123', 'ws-456', 'ws-789'],
})
})

it('should filter out empty strings from workspace IDs', () => {
const user = {
cognitoClaims: {
'custom:eddie_workspaces': 'ws-123,,ws-456,',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({
type: 'restricted',
ids: ['ws-123', 'ws-456'],
})
})

it('should return { type: "none" } when no cognito claims', () => {
const user = {}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({ type: 'none' })
})

it('should return { type: "none" } when cognitoClaims is undefined', () => {
const user = { cognitoClaims: undefined }

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({ type: 'none' })
})

it('should return { type: "none" } for non-admin without eddie_workspaces', () => {
const user = {
cognitoClaims: {
'custom:hub_role': 'CLIENT',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({ type: 'none' })
})

it('should return single workspace ID correctly', () => {
const user = {
cognitoClaims: {
'custom:eddie_workspaces': 'ws-123',
},
}

expect(getCognitoAccessibleWorkspaceIds(user)).toEqual({
type: 'restricted',
ids: ['ws-123'],
})
})
})
24 changes: 24 additions & 0 deletions apps/builder/src/features/workspace/helpers/cognitoUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface CognitoUserClaims {
'custom:eddie_workspaces'?: string
}

export type CognitoAccess =
| { type: 'admin' }
| { type: 'restricted'; ids: string[] }
| { type: 'none' }

export const extractCognitoUserClaims = (
user: unknown
): CognitoUserClaims | undefined => {
Expand Down Expand Up @@ -104,6 +109,25 @@ export const mapCognitoRoleToWorkspaceRole = (
}
}

export const getCognitoAccessibleWorkspaceIds = (user: {
cognitoClaims?: CognitoUserClaims
}): CognitoAccess => {
const claims = extractCognitoUserClaims(user)
if (!claims) return { type: 'none' }

if (claims['custom:hub_role'] === 'ADMIN') return { type: 'admin' }

if (claims['custom:eddie_workspaces']) {
const ids = claims['custom:eddie_workspaces']
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length > 0) return { type: 'restricted', ids }
}

return { type: 'none' }
}

/**
* Centralized function to check Cognito-based workspace access and return role information.
* This replaces the duplicated pattern of extracting claims, checking access, and mapping roles.
Expand Down
Loading