diff --git a/apps/builder/src/features/auth/api/customAdapter.ts b/apps/builder/src/features/auth/api/customAdapter.ts index 6a2e33c02f..fcb95b12bd 100644 --- a/apps/builder/src/features/auth/api/customAdapter.ts +++ b/apps/builder/src/features/auth/api/customAdapter.ts @@ -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, diff --git a/apps/builder/src/features/auth/types/cognito.ts b/apps/builder/src/features/auth/types/cognito.ts index 0f57fff8df..00bb4fd1e9 100644 --- a/apps/builder/src/features/auth/types/cognito.ts +++ b/apps/builder/src/features/auth/types/cognito.ts @@ -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 @@ -34,4 +36,5 @@ export type NextAuthJWTWithCognito = Record & name?: string image?: string provider?: string + cloudChatAuthorization?: boolean } diff --git a/apps/builder/src/features/workspace/api/listWorkspaces.ts b/apps/builder/src/features/workspace/api/listWorkspaces.ts index 366aeb0aad..5e0f65b52b 100644 --- a/apps/builder/src/features/workspace/api/listWorkspaces.ts +++ b/apps/builder/src/features/workspace/api/listWorkspaces.ts @@ -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({ @@ -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 } }, + } + } +} diff --git a/apps/builder/src/features/workspace/helpers/cognitoUtils.test.ts b/apps/builder/src/features/workspace/helpers/cognitoUtils.test.ts index fe690baa2d..d8876317aa 100644 --- a/apps/builder/src/features/workspace/helpers/cognitoUtils.test.ts +++ b/apps/builder/src/features/workspace/helpers/cognitoUtils.test.ts @@ -15,6 +15,7 @@ import { mapCognitoRoleToWorkspaceRole, hasWorkspaceAccess, checkCognitoWorkspaceAccess, + getCognitoAccessibleWorkspaceIds, } from './cognitoUtils' describe('extractCognitoUserClaims', () => { @@ -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) }) }) @@ -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', } @@ -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', @@ -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', () => { @@ -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'], + }) + }) +}) diff --git a/apps/builder/src/features/workspace/helpers/cognitoUtils.ts b/apps/builder/src/features/workspace/helpers/cognitoUtils.ts index 5ec85dd7e6..bfb178be6f 100644 --- a/apps/builder/src/features/workspace/helpers/cognitoUtils.ts +++ b/apps/builder/src/features/workspace/helpers/cognitoUtils.ts @@ -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 => { @@ -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. diff --git a/apps/builder/src/pages/api/auth/[...nextauth].ts b/apps/builder/src/pages/api/auth/[...nextauth].ts index 2a8b7ee52e..0651229d41 100644 --- a/apps/builder/src/pages/api/auth/[...nextauth].ts +++ b/apps/builder/src/pages/api/auth/[...nextauth].ts @@ -1,4 +1,4 @@ -import NextAuth, { Account, AuthOptions } from 'next-auth' +import NextAuth, { Account, AuthOptions, User as NextAuthUser } from 'next-auth' import { JWT } from 'next-auth/jwt' import CredentialsProvider from 'next-auth/providers/credentials' import EmailProvider from 'next-auth/providers/email' @@ -13,7 +13,7 @@ import { Provider } from 'next-auth/providers' import { NextApiRequest, NextApiResponse } from 'next' import { customAdapter } from '../../../features/auth/api/customAdapter' import { User } from '@typebot.io/prisma' -import { getAtPath, isDefined } from '@typebot.io/lib' +import { getAtPath, isDefined, emailIsCloudhumans } from '@typebot.io/lib' import { mockedUser } from '@typebot.io/lib/mockedUser' import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations' import { sendVerificationRequest } from '@/features/auth/helpers/sendVerificationRequest' @@ -27,6 +27,7 @@ import { trackEvents } from '@typebot.io/telemetry/trackEvents' import { NextAuthJWTWithCognito, DatabaseUserWithCognito, + CognitoClaims, } from '@/features/auth/types/cognito' import logger from '@/helpers/logger' import { verifyCognitoToken } from '@/features/auth/helpers/verifyCognitoToken' @@ -260,10 +261,9 @@ export const getAuthOptions = ({ }, }, callbacks: { - jwt: async ({ token, user, account }) => { + jwt: async ({ token, user, account, profile }) => { const nextAuthJWT = token as NextAuthJWTWithCognito - // If user is provided (first sign in), add user info to token if (user && account) { nextAuthJWT.userId = user.id nextAuthJWT.email = user.email || undefined @@ -271,49 +271,31 @@ export const getAuthOptions = ({ nextAuthJWT.image = user.image || undefined nextAuthJWT.provider = account.provider - // Extract Cognito claims from cloudchat-embedded provider - if (account.provider === 'cloudchat-embedded') { - const userFromCognitoAuth = user as DatabaseUserWithCognito - const claimsFromCognitoToken = userFromCognitoAuth.cognitoClaims - const cloudChatAuthorization = - userFromCognitoAuth.cloudChatAuthorization - - nextAuthJWT.cloudChatAuthorization = cloudChatAuthorization - if (claimsFromCognitoToken) { - logger.debug( - 'Transferring claims from Cognito auth to NextAuth JWT', - { - hasEddieWorkspaces: - !!claimsFromCognitoToken['custom:eddie_workspaces'], - eddieWorkspacesCount: - typeof claimsFromCognitoToken['custom:eddie_workspaces'] === - 'string' - ? claimsFromCognitoToken['custom:eddie_workspaces'].split( - ',' - ).length - : 0, - } - ) - - nextAuthJWT.cognitoClaims = claimsFromCognitoToken - logger.debug('Final cognitoClaims set', { - hasHubRole: !!claimsFromCognitoToken['custom:hub_role'], - hasEddieWorkspaces: - !!claimsFromCognitoToken['custom:eddie_workspaces'], - }) - - logger.info('User authenticated via Cognito token', { - hubRole: claimsFromCognitoToken['custom:hub_role'], - hasEddieWorkspaces: - !!claimsFromCognitoToken['custom:eddie_workspaces'], - provider: 'cognito', - }) - } + const cognitoClaims = extractCognitoClaims( + account.provider, + user as NextAuthUser, + profile as Partial + ) + if (cognitoClaims) { + nextAuthJWT.cognitoClaims = cognitoClaims + logger.info('User authenticated with cognito claims', { + email: user.email, + hubRole: cognitoClaims['custom:hub_role'], + hasEddieWorkspaces: !!cognitoClaims['custom:eddie_workspaces'], + provider: account.provider, + }) } else { - logger.info('User authenticated via OAuth provider', { + logger.info('User authenticated without cognito claims', { + email: user.email, provider: account.provider, }) } + + if (account.provider === 'cloudchat-embedded') { + nextAuthJWT.cloudChatAuthorization = ( + user as DatabaseUserWithCognito + ).cloudChatAuthorization + } } return nextAuthJWT as JWT & NextAuthJWTWithCognito }, @@ -472,4 +454,29 @@ const getRequiredGroups = (provider: string): string[] => { const checkHasGroups = (userGroups: string[], requiredGroups: string[]) => userGroups?.some((userGroup) => requiredGroups?.includes(userGroup)) +const extractCognitoClaims = ( + provider: string, + user: NextAuthUser, + profile?: Partial +): CognitoClaims | undefined => { + switch (provider) { + case 'google': + if (emailIsCloudhumans(user.email)) + return { 'custom:hub_role': 'ADMIN', 'custom:eddie_workspaces': '' } + return undefined + case 'custom-oauth': + if (profile) { + return { + 'custom:hub_role': profile['custom:hub_role'] ?? 'CLIENT', + 'custom:eddie_workspaces': profile['custom:eddie_workspaces'] ?? '', + } + } + return (user as DatabaseUserWithCognito).cognitoClaims + case 'cloudchat-embedded': + return (user as DatabaseUserWithCognito).cognitoClaims + default: + return undefined + } +} + export default handler