Compare commits

..
Author SHA1 Message Date
neo773 1d5d31eca2 Update workflow-execution-context.service.ts 2026-06-07 01:24:18 +05:30
neo773 3b109ec14e Regenerate GraphQL and address Cubic bot 2026-06-07 01:21:01 +05:30
neo773 7781dd8457 Add 2.11 upgrade command reassigning workflow creators to email account owners 2026-06-07 00:42:23 +05:30
neo773 e5ed2889ed Add test coverage 2026-06-06 23:53:30 +05:30
neo773 026b8b5c34 wip 2026-06-06 23:22:23 +05:30
neo773 5a6813d9e0 Revert "revert #21177 (#21284)"
This reverts commit 186d5b8faa.
2026-06-06 23:22:12 +05:30
44 changed files with 1588 additions and 551 deletions
@@ -3008,6 +3008,7 @@ type Query {
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
getRoles: [Role!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
webhooks: [Webhook!]!
@@ -3027,7 +3028,6 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
@@ -3262,6 +3262,7 @@ type Mutation {
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
removeRoleFromAgent(agentId: UUID!): Boolean!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
runAgent(input: RunAgentInput!): RunAgentResult!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
@@ -3280,7 +3281,6 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -2614,6 +2614,7 @@ export interface Query {
findManyAgents: Agent[]
findOneAgent: Agent
getRoles: Role[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
webhooks: Webhook[]
@@ -2624,7 +2625,6 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
@@ -2786,6 +2786,7 @@ export interface Mutation {
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
removeRoleFromAgent: Scalars['Boolean']
deleteConnectedAccount: ConnectedAccountPublicDTO
runAgent: RunAgentResult
createWebhook: Webhook
updateWebhook: Webhook
@@ -2804,7 +2805,6 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -5673,6 +5673,7 @@ export interface QueryGenqlSelection{
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
getRoles?: RoleGenqlSelection
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
webhooks?: WebhookGenqlSelection
@@ -5689,7 +5690,6 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
@@ -5874,6 +5874,7 @@ export interface MutationGenqlSelection{
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
runAgent?: (RunAgentResultGenqlSelection & { __args: {input: RunAgentInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
@@ -5892,7 +5893,6 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
@@ -6153,6 +6153,9 @@ export default {
"getRoles": [
29
],
"myConnectedAccounts": [
271
],
"getToolIndex": [
277
],
@@ -6232,9 +6235,6 @@ export default {
]
}
],
"myConnectedAccounts": [
271
],
"myCalendarChannels": [
305,
{
@@ -7714,6 +7714,15 @@ export default {
]
}
],
"deleteConnectedAccount": [
271,
{
"id": [
3,
"UUID!"
]
}
],
"runAgent": [
279,
{
@@ -7876,15 +7885,6 @@ export default {
]
}
],
"deleteConnectedAccount": [
271,
{
"id": [
3,
"UUID!"
]
}
],
"updateCalendarChannel": [
305,
{
@@ -1,29 +1,14 @@
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
import { styled } from '@linaria/react';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const StyledRightPanelContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
export const RightPanelSkeletonLoader = () => {
const location = useLocation();
const isSettingsPage = isMatchingLocation(location, AppPath.SettingsCatchAll);
return (
<StyledRightPanelContainer>
{isSettingsPage ? (
<SettingsSkeletonLoader />
) : (
<RecordIndexSkeletonLoader />
)}
</StyledRightPanelContainer>
);
};
export const RightPanelSkeletonLoader = () => (
<StyledRightPanelContainer>
<PageContentSkeletonLoader />
</StyledRightPanelContainer>
);
@@ -7,7 +7,6 @@ import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
import { AppPath } from 'twenty-shared/types';
import { lazy } from 'react';
@@ -210,50 +209,48 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route element={<MainAppLayoutWithSidePanel />}>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
<Route element={<BlankLayout />}>
<Route
@@ -31,14 +31,12 @@ const StyledPreviewWrapper = styled.div`
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
isPrimaryAction?: boolean;
};
type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
isPrimaryAction = false,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi, isInPreviewMode } =
useContext(CommandMenuContext);
@@ -62,10 +60,7 @@ const CommandMenuItemButtonRenderer = ({
if (isInPreviewMode) {
return (
<StyledPreviewWrapper>
<CommandMenuButton
command={command}
isPrimaryAction={isPrimaryAction}
/>
<CommandMenuButton command={command} />
</StyledPreviewWrapper>
);
}
@@ -75,7 +70,6 @@ const CommandMenuItemButtonRenderer = ({
command={command}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
isPrimaryAction={isPrimaryAction}
/>
);
};
@@ -173,17 +167,11 @@ const CommandMenuItemSelectableRenderer = ({
// oxlint-disable-next-line twenty/effect-components
export const CommandMenuItemRenderer = ({
item,
isPrimaryAction,
}: CommandMenuItemRendererProps) => {
const { displayType } = useContext(CommandMenuContext);
if (displayType === 'button') {
return (
<CommandMenuItemButtonRenderer
item={item}
isPrimaryAction={isPrimaryAction}
/>
);
return <CommandMenuItemButtonRenderer item={item} />;
}
if (displayType === 'listItem' || displayType === 'dropdownItem') {
@@ -68,7 +68,7 @@ export const PinnedCommandMenuItemButtons = () => {
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item, index) => (
{pinnedInlineCommandMenuItems.map((item) => (
<StyledCommandMenuItemContainer
key={item.id}
layout
@@ -80,10 +80,7 @@ export const PinnedCommandMenuItemButtons = () => {
ease: 'easeInOut',
}}
>
<CommandMenuItemRenderer
item={item}
isPrimaryAction={index === 0}
/>
<CommandMenuItemRenderer item={item} />
</StyledCommandMenuItemContainer>
))}
</StyledItemsContainer>
@@ -28,7 +28,6 @@ export type CommandMenuButtonProps = {
onClick?: (event?: MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
isPrimaryAction?: boolean;
};
export const CommandMenuButton = ({
@@ -36,7 +35,6 @@ export const CommandMenuButton = ({
onClick,
to,
disabled = false,
isPrimaryAction = false,
}: CommandMenuButtonProps) => {
const resolvedLabel = getCommandMenuItemLabel(command.label);
@@ -52,8 +50,8 @@ export const CommandMenuButton = ({
<Button
Icon={command.Icon}
size="small"
variant={isPrimaryAction ? 'primary' : 'secondary'}
accent={isPrimaryAction ? 'blue' : buttonAccent}
variant="secondary"
accent={buttonAccent}
to={to}
onClick={onClick}
disabled={disabled}
@@ -65,8 +63,8 @@ export const CommandMenuButton = ({
<IconButton
Icon={command.Icon}
size="small"
variant={isPrimaryAction ? 'primary' : 'secondary'}
accent={isPrimaryAction ? 'blue' : buttonAccent}
variant="secondary"
accent={buttonAccent}
to={to}
onClick={onClick}
disabled={disabled}
@@ -1,10 +1,13 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type MainContainerLayoutProps = {
type MainContainerLayoutWithSidePanelProps = {
children: ReactNode;
};
@@ -51,15 +54,20 @@ const StyledPageBodyForMobileContainer = styled.div`
}
`;
export const MainContainerLayout = ({ children }: MainContainerLayoutProps) => {
export const MainContainerLayoutWithSidePanel = ({
children,
}: MainContainerLayoutWithSidePanelProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
if (isMobile) {
return (
<StyledMainContainerLayoutForMobile>
<StyledPageBodyForMobileContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForMobileContainer>
<CommandMenuForMobile />
</StyledMainContainerLayoutForMobile>
);
}
@@ -69,6 +77,7 @@ export const MainContainerLayout = ({ children }: MainContainerLayoutProps) => {
<StyledPageBodyForDesktopContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForDesktopContainer>
<SidePanelForDesktop />
</StyledMainContainerLayoutForDesktop>
);
};
@@ -1,16 +1,21 @@
import { styled } from '@linaria/react';
import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown';
import { RecordBoardContainer } from '@/object-record/record-board/components/RecordBoardContainer';
import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { RecordIndexCalendarContainer } from '@/object-record/record-index/components/RecordIndexCalendarContainer';
import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared';
import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect';
import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewType } from '@/views/types/ViewType';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -19,6 +24,7 @@ const StyledContainer = styled.div`
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
@@ -32,43 +38,67 @@ const StyledContainerWithPadding = styled.div`
export const RecordIndexContainer = () => {
const recordIndexViewType = useAtomStateValue(recordIndexViewTypeState);
const { recordIndexId, objectMetadataItem, objectNameSingular } =
useRecordIndexContextOrThrow();
const {
objectNamePlural,
recordIndexId,
objectMetadataItem,
objectNameSingular,
} = useRecordIndexContextOrThrow();
const { hasCurrentViewNonReadableFields, nonReadableViewFieldInfo } =
useHasCurrentViewNonReadableFields(objectMetadataItem);
return (
<StyledContainer>
{hasCurrentViewNonReadableFields ? (
<RecordIndexEmptyStateNotShared
nonReadableViewFieldInfo={nonReadableViewFieldInfo}
/>
) : (
<>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.TABLE && (
<RecordIndexTableContainer recordTableId={recordIndexId} />
)}
{recordIndexViewType === ViewType.KANBAN && (
<StyledContainerWithPadding>
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
<>
<StyledContainer>
<InformationBannerWrapper />
<SpreadsheetImportProvider>
<ViewBar
isReadOnly={hasCurrentViewNonReadableFields}
viewBarId={recordIndexId}
optionsDropdownButton={
<ObjectOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.TABLE}
/>
</StyledContainerWithPadding>
)}
{recordIndexViewType === ViewType.CALENDAR && (
<StyledContainerWithPadding>
<RecordIndexCalendarContainer
recordCalendarInstanceId={recordIndexId}
viewBarInstanceId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
</>
)}
</StyledContainer>
}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{hasCurrentViewNonReadableFields ? (
<RecordIndexEmptyStateNotShared
nonReadableViewFieldInfo={nonReadableViewFieldInfo}
/>
) : (
<>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.TABLE && (
<RecordIndexTableContainer recordTableId={recordIndexId} />
)}
{recordIndexViewType === ViewType.KANBAN && (
<StyledContainerWithPadding>
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
</StyledContainerWithPadding>
)}
{recordIndexViewType === ViewType.CALENDAR && (
<StyledContainerWithPadding>
<RecordIndexCalendarContainer
recordCalendarInstanceId={recordIndexId}
viewBarInstanceId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
</>
)}
</StyledContainer>
</>
);
};
@@ -3,7 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { getCommandMenuIdFromRecordIndexId } from '@/command-menu-item/utils/getCommandMenuIdFromRecordIndexId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { RecordIndexViewBar } from '@/object-record/record-index/components/RecordIndexViewBar';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
@@ -16,7 +16,6 @@ import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hook
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { styled } from '@linaria/react';
@@ -92,12 +91,8 @@ export const RecordIndexContainerGater = () => {
}}
>
<PageTitle title={objectMetadataItem.labelPlural} />
<PageCardLayout
header={<RecordIndexPageHeader />}
secondaryBar={
hasObjectReadPermissions ? <RecordIndexViewBar /> : undefined
}
>
<RecordIndexPageHeader />
<MainContainerLayoutWithSidePanel>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
@@ -110,7 +105,7 @@ export const RecordIndexContainerGater = () => {
<RecordIndexEmptyStateNotShared />
)}
</StyledIndexContainer>
</PageCardLayout>
</MainContainerLayoutWithSidePanel>
</CommandMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
@@ -1,4 +1,5 @@
import { RecordIndexCommandMenu } from '@/command-menu-item/components/RecordIndexCommandMenu';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@@ -6,8 +7,7 @@ import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/st
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { RecordIndexPageHeaderIcon } from '@/object-record/record-index/components/RecordIndexPageHeaderIcon';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -68,19 +68,18 @@ export const RecordIndexPageHeader = () => {
);
return (
<PageCardHeader
icon={
<RecordIndexPageHeaderIcon objectMetadataItem={objectMetadataItem} />
}
<PageHeader
title={pageHeaderTitle}
actionButton={
isDefined(contextStoreCurrentViewId) ? (
<>
<RecordIndexCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</>
) : undefined
}
/>
Icon={() => (
<RecordIndexPageHeaderIcon objectMetadataItem={objectMetadataItem} />
)}
>
{isDefined(contextStoreCurrentViewId) && (
<>
<RecordIndexCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</>
)}
</PageHeader>
);
};
@@ -1,77 +1,8 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledSecondaryBar = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
flex-shrink: 0;
justify-content: space-between;
min-height: ${themeCssVariables.spacing[10]};
padding: 0 ${themeCssVariables.spacing[3]};
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
export const RecordIndexSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
return (
<StyledCard>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<PageCardHeader
icon={<Skeleton width={20} height={20} />}
title={
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
}
/>
<StyledSecondaryBar>
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSecondaryBar>
<StyledBody>
<Skeleton
count={8}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
</StyledBody>
</SkeletonTheme>
</StyledCard>
);
};
export const RecordIndexSkeletonLoader = () => (
<PageContainer>
<PageContentSkeletonLoader />
</PageContainer>
);
@@ -1,39 +0,0 @@
import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewType } from '@/views/types/ViewType';
export const RecordIndexViewBar = () => {
const recordIndexViewType = useAtomStateValue(recordIndexViewTypeState);
const { objectNamePlural, recordIndexId, objectMetadataItem } =
useRecordIndexContextOrThrow();
const { hasCurrentViewNonReadableFields } =
useHasCurrentViewNonReadableFields(objectMetadataItem);
return (
<SpreadsheetImportProvider>
<ViewBar
isReadOnly={hasCurrentViewNonReadableFields}
viewBarId={recordIndexId}
optionsDropdownButton={
<ObjectOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.TABLE}
/>
}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
);
};
@@ -1,17 +1,20 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { SettingsPageHeader } from '@/settings/components/layout/SettingsPageHeader';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRoot = styled.div`
const StyledRoot = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledCard = styled.div`
@@ -28,17 +31,18 @@ const StyledCard = styled.div`
`;
export const SettingsSkeletonLoader = () => {
const isMobile = useIsMobile();
const { theme } = useContext(ThemeContext);
return (
<StyledRoot>
<StyledRoot isMobile={isMobile}>
<StyledCard>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<PageCardHeader
<SettingsPageHeader
links={[
{
children: (
@@ -11,22 +11,23 @@ import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type PageCardHeaderProps = {
links?: BreadcrumbProps['links'];
breadcrumb?: ReactNode;
icon?: ReactNode;
type SettingsPageHeaderProps = {
links: BreadcrumbProps['links'];
title?: ReactNode;
tag?: ReactNode;
actionButton?: ReactNode;
};
// minmax(0, 1fr) side tracks (not 1fr) let a long breadcrumb truncate instead of
// pushing the centered title off its shared axis with the tabs and body.
const StyledHeader = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border-bottom: 1px solid ${themeCssVariables.border.color.medium};
box-sizing: border-box;
display: flex;
display: grid;
gap: ${themeCssVariables.spacing[2]};
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-height: ${SIDE_PANEL_TOP_BAR_HEIGHT}px;
padding: 0 ${themeCssVariables.spacing[3]};
width: 100%;
@@ -35,7 +36,7 @@ const StyledHeader = styled.div`
const StyledLeft = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
overflow: hidden;
`;
@@ -48,25 +49,23 @@ const StyledTitle = styled.div`
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
text-align: center;
`;
const StyledRight = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[2]};
justify-content: flex-end;
min-width: 0;
`;
export const PageCardHeader = ({
export const SettingsPageHeader = ({
links,
breadcrumb,
icon,
title,
tag,
actionButton,
}: PageCardHeaderProps) => {
}: SettingsPageHeaderProps) => {
const isMobile = useIsMobile();
const isNavigationDrawerExpanded = useNavigationDrawerExpanded();
@@ -76,18 +75,12 @@ export const PageCardHeader = ({
{!isNavigationDrawerExpanded && (
<NavigationDrawerCollapseButton direction="right" />
)}
{isDefined(breadcrumb)
? breadcrumb
: isDefined(links) && <Breadcrumb links={links} />}
{!isMobile &&
(isDefined(icon) || isDefined(title) || isDefined(tag)) && (
<StyledTitle>
{icon}
{isDefined(title) && title}
{tag}
</StyledTitle>
)}
<Breadcrumb links={links} />
</StyledLeft>
<StyledTitle>
{!isMobile && isDefined(title) && title}
{!isMobile && tag}
</StyledTitle>
<StyledRight>{actionButton}</StyledRight>
</StyledHeader>
);
@@ -1,9 +1,15 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { SettingsPageHeader } from '@/settings/components/layout/SettingsPageHeader';
import { SettingsSecondaryBar } from '@/settings/components/layout/SettingsSecondaryBar';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { type JSX, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsPageLayoutProps = {
links: BreadcrumbProps['links'];
@@ -14,6 +20,44 @@ type SettingsPageLayoutProps = {
tag?: JSX.Element;
};
const StyledRoot = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledMainCardWrapper = styled.div`
display: flex;
flex: 1 1 0;
min-width: 0;
width: 0;
`;
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledBodyContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
width: 100%;
`;
export const SettingsPageLayout = ({
links,
title,
@@ -21,22 +65,31 @@ export const SettingsPageLayout = ({
secondaryBar,
children,
tag,
}: SettingsPageLayoutProps) => (
<PageCardLayout
header={
<PageCardHeader
links={links}
title={title}
tag={tag}
actionButton={actionButton}
/>
}
secondaryBar={
isDefined(secondaryBar) ? (
<SettingsSecondaryBar>{secondaryBar}</SettingsSecondaryBar>
) : undefined
}
>
{children}
</PageCardLayout>
);
}: SettingsPageLayoutProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRoot isMobile={isMobile}>
<StyledMainCardWrapper>
<StyledCard>
<SettingsPageHeader
links={links}
title={title}
tag={tag}
actionButton={actionButton}
/>
{isDefined(secondaryBar) && (
<SettingsSecondaryBar>{secondaryBar}</SettingsSecondaryBar>
)}
<StyledBodyContent>
<InformationBannerWrapper />
{children}
</StyledBodyContent>
</StyledCard>
</StyledMainCardWrapper>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRoot>
);
};
@@ -145,7 +145,7 @@ export const SidePanelToggleButton = () => {
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
dataTestId="page-header-side-panel-button"
size={isMobile ? 'medium' : 'small'}
variant="tertiary"
variant="secondary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
ariaLabel={ariaLabel}
@@ -1,40 +0,0 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { Outlet } from 'react-router-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRow = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledContent = styled.div`
display: flex;
flex: 1 1 0;
min-height: 0;
min-width: 0;
overflow: hidden;
`;
export const MainAppLayoutWithSidePanel = () => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRow isMobile={isMobile}>
<StyledContent>
<Outlet />
</StyledContent>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRow>
);
};
@@ -1,68 +0,0 @@
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type PageCardLayoutProps = {
header: ReactNode;
secondaryBar?: ReactNode;
children: ReactNode;
};
const StyledRoot = styled.div`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
`;
const StyledMainCardWrapper = styled.div`
display: flex;
flex: 1 1 0;
min-width: 0;
width: 0;
`;
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledBodyContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
width: 100%;
`;
export const PageCardLayout = ({
header,
secondaryBar,
children,
}: PageCardLayoutProps) => {
return (
<StyledRoot>
<StyledMainCardWrapper>
<StyledCard>
{header}
{isDefined(secondaryBar) && secondaryBar}
<StyledBodyContent>
<InformationBannerWrapper />
{children}
</StyledBodyContent>
</StyledCard>
</StyledMainCardWrapper>
</StyledRoot>
);
};
@@ -7,12 +7,13 @@ import { TimelineActivityContext } from '@/activities/timeline-activities/contex
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PageLayoutRecordPageRenderer } from '@/object-record/record-show/components/PageLayoutRecordPageRenderer';
import { RecordShowPageSSESubscribeEffect } from '@/object-record/record-show/components/RecordShowPageSSESubscribeEffect';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader';
import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle';
@@ -45,39 +46,38 @@ export const RecordShowPage = () => {
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: recordShowComponentInstanceId }}
>
<RecordShowPageTitle
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
/>
<PageCardLayout
header={
<RecordShowPageHeader
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
>
<RecordShowCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</RecordShowPageHeader>
}
>
<TimelineActivityContext.Provider
value={{
recordId: objectRecordId,
}}
<PageContainer>
<RecordShowPageTitle
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
/>
<RecordShowPageHeader
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
>
<PageLayoutRecordPageRenderer
targetRecordIdentifier={{
id: objectRecordId,
targetObjectNameSingular: objectNameSingular,
<RecordShowCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</RecordShowPageHeader>
<MainContainerLayoutWithSidePanel>
<TimelineActivityContext.Provider
value={{
recordId: objectRecordId,
}}
isInSidePanel={false}
/>
<RecordShowPageSSESubscribeEffect
objectNameSingular={objectNameSingular}
recordId={objectRecordId}
/>
</TimelineActivityContext.Provider>
</PageCardLayout>
>
<PageLayoutRecordPageRenderer
targetRecordIdentifier={{
id: objectRecordId,
targetObjectNameSingular: objectNameSingular,
}}
isInSidePanel={false}
/>
<RecordShowPageSSESubscribeEffect
objectNameSingular={objectNameSingular}
recordId={objectRecordId}
/>
</TimelineActivityContext.Provider>
</MainContainerLayoutWithSidePanel>
</PageContainer>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
@@ -1,7 +1,7 @@
import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields';
import { ObjectRecordShowPageBreadcrumb } from '@/object-record/record-show/components/ObjectRecordShowPageBreadcrumb';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
export const RecordShowPageHeader = ({
objectNameSingular,
@@ -21,8 +21,8 @@ export const RecordShowPageHeader = ({
getObjectMetadataIdentifierFields({ objectMetadataItem });
return (
<PageCardHeader
breadcrumb={
<PageHeader
title={
<ObjectRecordShowPageBreadcrumb
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
@@ -30,7 +30,8 @@ export const RecordShowPageHeader = ({
labelIdentifierFieldMetadataItem={labelIdentifierFieldMetadataItem}
/>
}
actionButton={children}
/>
>
{children}
</PageHeader>
);
};
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { MainContainerLayout } from '@/object-record/components/MainContainerLayout';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
@@ -34,9 +34,9 @@ export const StandalonePageLayoutPage = () => {
isInSidePanel: false,
}}
>
<MainContainerLayout>
<MainContainerLayoutWithSidePanel>
<PageLayoutRenderer pageLayoutId={pageLayoutId} />
</MainContainerLayout>
</MainContainerLayoutWithSidePanel>
</LayoutRenderingProvider>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { ReassignWorkflowCreatorToEmailAccountOwnerCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-workspace-command-1799100000000-reassign-workflow-creator-to-email-account-owner.command';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
WorkspaceIteratorModule,
],
providers: [ReassignWorkflowCreatorToEmailAccountOwnerCommand],
})
export class V2_11_UpgradeVersionCommandModule {}
@@ -0,0 +1,253 @@
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import {
WorkflowVersionStatus,
type WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const EMAIL_ACTION_TYPES: WorkflowActionType[] = [
WorkflowActionType.SEND_EMAIL,
WorkflowActionType.DRAFT_EMAIL,
];
@RegisteredWorkspaceCommand('2.11.0', 1799100000000)
@Command({
name: 'upgrade:2-11:reassign-workflow-creator-to-email-account-owner',
description:
'Reassign the creator of active workflows whose email steps use a user-visibility connected account owned by another member, so workflow runs can resolve the account through the acting-user access check',
})
export class ReassignWorkflowCreatorToEmailAccountOwnerCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const accountIdsByWorkflowId =
await this.getEmailStepAccountIdsByWorkflowId(workspaceId);
if (accountIdsByWorkflowId.size === 0) {
return;
}
const referencedAccountIds = [
...new Set([...accountIdsByWorkflowId.values()].flat()),
];
const userVisibilityAccounts = await this.connectedAccountRepository.find({
where: {
id: In(referencedAccountIds),
workspaceId,
visibility: 'user',
},
});
if (userVisibilityAccounts.length === 0) {
return;
}
const accountById = new Map(
userVisibilityAccounts.map((account) => [account.id, account]),
);
const workflowRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
workspaceId,
'workflow',
{ shouldBypassPermissionChecks: true },
);
const workflows = await workflowRepository.find({
where: { id: In([...accountIdsByWorkflowId.keys()]) },
});
const memberByUserWorkspaceId = await this.getMemberByUserWorkspaceId({
workspaceId,
userWorkspaceIds: userVisibilityAccounts
.map((account) => account.userWorkspaceId)
.filter(isDefined),
});
let reassignedCount = 0;
for (const workflow of workflows) {
const ownerUserWorkspaceIds = [
...new Set(
(accountIdsByWorkflowId.get(workflow.id) ?? [])
.map((accountId) => accountById.get(accountId)?.userWorkspaceId)
.filter(isDefined),
),
];
if (ownerUserWorkspaceIds.length === 0) {
continue;
}
if (ownerUserWorkspaceIds.length > 1) {
this.logger.warn(
`Workflow ${workflow.id} in workspace ${workspaceId} references user-visibility accounts of ${ownerUserWorkspaceIds.length} different owners; skipping`,
);
continue;
}
const ownerMember = memberByUserWorkspaceId.get(ownerUserWorkspaceIds[0]);
if (!isDefined(ownerMember)) {
this.logger.warn(
`Workflow ${workflow.id} in workspace ${workspaceId} references an account whose owner has no workspace member; skipping`,
);
continue;
}
if (workflow.createdBy?.workspaceMemberId === ownerMember.id) {
continue;
}
const ownerFullName =
`${ownerMember.name?.firstName ?? ''} ${ownerMember.name?.lastName ?? ''}`.trim();
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Reassigning creator of workflow ${workflow.id} in workspace ${workspaceId} to account owner member ${ownerMember.id}`,
);
if (isDryRun) {
continue;
}
await workflowRepository.update(workflow.id, {
createdBy: {
...workflow.createdBy,
workspaceMemberId: ownerMember.id,
name: ownerFullName,
},
});
reassignedCount++;
}
if (reassignedCount > 0) {
this.logger.log(
`Reassigned creator on ${reassignedCount} workflow(s) for workspace ${workspaceId}`,
);
}
}
private async getEmailStepAccountIdsByWorkflowId(
workspaceId: string,
): Promise<Map<string, string[]>> {
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const activeVersions = await workflowVersionRepository.find({
where: { status: WorkflowVersionStatus.ACTIVE },
});
const accountIdsByWorkflowId = new Map<string, string[]>();
for (const version of activeVersions) {
if (!Array.isArray(version.steps)) {
continue;
}
const accountIds = version.steps
.filter((step: WorkflowAction) =>
EMAIL_ACTION_TYPES.includes(step.type),
)
.map(
(step: WorkflowAction) =>
(step.settings?.input as { connectedAccountId?: string })
?.connectedAccountId,
)
.filter(
(accountId): accountId is string =>
isNonEmptyString(accountId) && isValidUuid(accountId),
);
if (accountIds.length === 0) {
continue;
}
accountIdsByWorkflowId.set(version.workflowId, [
...(accountIdsByWorkflowId.get(version.workflowId) ?? []),
...accountIds,
]);
}
return accountIdsByWorkflowId;
}
private async getMemberByUserWorkspaceId({
workspaceId,
userWorkspaceIds,
}: {
workspaceId: string;
userWorkspaceIds: string[];
}): Promise<Map<string, WorkspaceMemberWorkspaceEntity>> {
if (userWorkspaceIds.length === 0) {
return new Map();
}
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { id: In(userWorkspaceIds), workspaceId },
});
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const members = await workspaceMemberRepository.find({
where: { userId: In(userWorkspaces.map((uw) => uw.userId)) },
});
const memberByUserId = new Map(
members.map((member) => [member.userId, member]),
);
return new Map(
userWorkspaces
.map(
(userWorkspace): [string, WorkspaceMemberWorkspaceEntity] | null => {
const member = memberByUserId.get(userWorkspace.userId);
return isDefined(member) ? [userWorkspace.id, member] : null;
},
)
.filter(isDefined),
);
}
}
@@ -13,6 +13,7 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-11/2-11-upgrade-version-command.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_8_UpgradeVersionCommandModule,
V2_9_UpgradeVersionCommandModule,
V2_10_UpgradeVersionCommandModule,
V2_11_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -15,7 +15,7 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -26,7 +26,8 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
imports: [
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity, ConnectedAccountEntity]),
TypeOrmModule.forFeature([FileEntity]),
ConnectedAccountMetadataModule,
ApplicationModule,
FeatureFlagModule,
FileModule,
@@ -0,0 +1,333 @@
import { randomUUID } from 'node:crypto';
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider, FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
const WORKSPACE_ID = randomUUID();
const ALICE_USER_WORKSPACE_ID = randomUUID();
const BOB_USER_WORKSPACE_ID = randomUUID();
const ALICE_ACCOUNT_ID = randomUUID();
const BOB_ACCOUNT_ID = randomUUID();
const SHARED_ACCOUNT_ID = randomUUID();
// In-memory connected accounts that mimic the rows TypeORM would return.
type FakeAccount = Partial<ConnectedAccountEntity> & { id: string };
const aliceUserPrivateAccount: FakeAccount = {
id: ALICE_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
visibility: 'user',
handle: '[email protected]',
provider: ConnectedAccountProvider.GOOGLE,
connectionParameters: null,
messageChannels: [{ id: 'mc-alice', handle: '[email protected]' }] as never,
};
const bobUserPrivateAccount: FakeAccount = {
id: BOB_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
visibility: 'user',
handle: '[email protected]',
provider: ConnectedAccountProvider.GOOGLE,
connectionParameters: null,
messageChannels: [{ id: 'mc-bob', handle: '[email protected]' }] as never,
};
// Workspace-visibility account (owned by Alice but shared with the workspace).
const sharedWorkspaceAccount: FakeAccount = {
id: SHARED_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
visibility: 'workspace',
handle: '[email protected]',
provider: ConnectedAccountProvider.GOOGLE,
connectionParameters: null,
messageChannels: [{ id: 'mc-shared', handle: '[email protected]' }] as never,
};
// Mirrors ConnectedAccountMetadataService's visibility rule: an account is
// usable by a caller when it is workspace-shared, or it belongs to the caller's
// own user workspace. The authoritative scoping is proven directly against the
// repository in connected-account-metadata.service.spec.ts; here we stub the
// finders so these tests focus on the composer's own selection/rejection logic.
const isVisibleToCaller = (
account: FakeAccount,
userWorkspaceId: string | undefined,
): boolean =>
account.visibility === 'workspace' ||
(isDefined(userWorkspaceId) && account.userWorkspaceId === userWorkspaceId);
const buildComposeParams = (
overrides: Partial<ComposeEmailParams> = {},
): ComposeEmailParams => ({
recipients: { to: '[email protected]' },
subject: 'Hello',
body: '<p>Hello</p>',
...overrides,
});
describe('EmailComposerService - connected account authorization', () => {
let service: EmailComposerService;
let accounts: FakeAccount[];
beforeEach(async () => {
accounts = [
aliceUserPrivateAccount,
bobUserPrivateAccount,
sharedWorkspaceAccount,
];
const mockConnectedAccountMetadataService = {
findAccessibleConnectedAccountById: jest.fn(
({ id, userWorkspaceId, workspaceId }) =>
Promise.resolve(
accounts.find(
(account) =>
account.id === id &&
account.workspaceId === workspaceId &&
isVisibleToCaller(account, userWorkspaceId),
) ?? null,
),
),
findById: jest.fn(({ id, workspaceId }) =>
Promise.resolve(
accounts.find(
(account) =>
account.id === id && account.workspaceId === workspaceId,
) ?? null,
),
),
findAccessibleConnectedAccounts: jest.fn(
({ userWorkspaceId, workspaceId }) => {
const accessibleAccounts = accounts.filter(
(account) =>
account.workspaceId === workspaceId &&
isVisibleToCaller(account, userWorkspaceId),
);
return Promise.resolve({
userConnectedAccounts: accessibleAccounts.filter(
(account) => account.userWorkspaceId === userWorkspaceId,
),
workspaceSharedConnectedAccounts: accessibleAccounts.filter(
(account) => account.userWorkspaceId !== userWorkspaceId,
),
});
},
),
};
const mockGlobalWorkspaceOrmManager = {
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => unknown) => fn()),
getRepository: jest.fn(),
};
const mockFileRepository = {
find: jest.fn().mockResolvedValue([]),
};
const mockFileService = {
getFileStreamById: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailComposerService,
{
provide: GlobalWorkspaceOrmManager,
useValue: mockGlobalWorkspaceOrmManager,
},
{
provide: ConnectedAccountMetadataService,
useValue: mockConnectedAccountMetadataService,
},
{
provide: getWorkspaceScopedRepositoryToken(FileEntity),
useValue: mockFileRepository,
},
{
provide: FileService,
useValue: mockFileService,
},
],
}).compile();
service = module.get<EmailComposerService>(EmailComposerService);
});
const compose = (params: ComposeEmailParams, context: ToolExecutionContext) =>
service.composeEmail(params, context, {
attachmentsFileFolder: FileFolder.Workflow,
});
describe('explicit connectedAccountId', () => {
it("should reject sending from another member's user-private account (impersonation)", async () => {
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
// Bob asks to send FROM Alice's private account.
await expect(
compose(
buildComposeParams({
connectedAccountId: aliceUserPrivateAccount.id,
}),
bobContext,
),
).rejects.toThrow(
`Connected Account '${aliceUserPrivateAccount.id}' is private to another workspace member and cannot be used in this context`,
);
});
it('should report not found for an account id that does not exist', async () => {
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
const ghostAccountId = randomUUID();
await expect(
compose(
buildComposeParams({ connectedAccountId: ghostAccountId }),
bobContext,
),
).rejects.toThrow(`Connected Account '${ghostAccountId}' not found`);
});
it('should allow a member to use their own user-private account', async () => {
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
const result = await compose(
buildComposeParams({ connectedAccountId: bobUserPrivateAccount.id }),
bobContext,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(bobUserPrivateAccount.id);
}
});
it('should allow any member to use a workspace-visibility account', async () => {
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
const result = await compose(
buildComposeParams({ connectedAccountId: sharedWorkspaceAccount.id }),
bobContext,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(sharedWorkspaceAccount.id);
}
});
});
describe('omitted connectedAccountId (default selection)', () => {
it("should not silently default to another member's user-private account", async () => {
// Only Alice's user-private account exists; Bob has none of his own.
accounts = [aliceUserPrivateAccount];
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
await expect(compose(buildComposeParams(), bobContext)).rejects.toThrow();
});
it('should prefer the caller own account over a workspace-visibility account', async () => {
const bobContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
};
const result = await compose(buildComposeParams(), bobContext);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.userWorkspaceId).toBe(
BOB_USER_WORKSPACE_ID,
);
}
});
});
describe('system/workflow execution without a user identity', () => {
it('should reject a user-private account when no userWorkspaceId is present', async () => {
const systemContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
};
await expect(
compose(
buildComposeParams({
connectedAccountId: aliceUserPrivateAccount.id,
}),
systemContext,
),
).rejects.toThrow(
`Connected Account '${aliceUserPrivateAccount.id}' is private to another workspace member and cannot be used in this context`,
);
});
it('should allow a user-private account when the acting user owns it', async () => {
const workflowActingAsAliceContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
};
const result = await compose(
buildComposeParams({ connectedAccountId: aliceUserPrivateAccount.id }),
workflowActingAsAliceContext,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(
aliceUserPrivateAccount.id,
);
}
});
it('should allow a workspace-visibility account when no userWorkspaceId is present', async () => {
const systemContext: ToolExecutionContext = {
workspaceId: WORKSPACE_ID,
};
const result = await compose(
buildComposeParams({ connectedAccountId: sharedWorkspaceAccount.id }),
systemContext,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectedAccount.id).toBe(sharedWorkspaceAccount.id);
}
});
});
});
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { toPlainText } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
@@ -11,7 +10,7 @@ import {
FileFolder,
} from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, LessThanOrEqual, type Repository } from 'typeorm';
import { In, LessThanOrEqual } from 'typeorm';
import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -24,7 +23,7 @@ import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/emai
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
@@ -45,17 +44,21 @@ export class EmailComposerService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
@InjectWorkspaceScopedRepository(FileEntity)
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
private readonly fileService: FileService,
) {}
private async getConnectedAccount(
connectedAccountId: string,
workspaceId: string,
) {
private async getConnectedAccount({
connectedAccountId,
workspaceId,
userWorkspaceId,
}: {
connectedAccountId: string;
workspaceId: string;
userWorkspaceId: string | undefined;
}) {
if (!isValidUuid(connectedAccountId)) {
throw new EmailToolException(
`Connected Account ID is not a valid UUID`,
@@ -63,54 +66,76 @@ export class EmailComposerService {
);
}
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccount = await this.connectedAccountRepository.findOne({
where: { id: connectedAccountId, workspaceId },
const connectedAccount =
await this.connectedAccountMetadataService.findAccessibleConnectedAccountById(
{
id: connectedAccountId,
userWorkspaceId,
workspaceId,
relations: {
messageChannels: {
messageFolders: true,
},
},
},
);
if (!isDefined(connectedAccount)) {
const inaccessibleConnectedAccount =
await this.connectedAccountMetadataService.findById({
id: connectedAccountId,
workspaceId,
});
if (!isDefined(connectedAccount)) {
throw new EmailToolException(
`Connected Account '${connectedAccountId}' not found`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
if (isDefined(inaccessibleConnectedAccount)) {
throw new EmailToolException(
`Connected Account '${connectedAccountId}' is private to another workspace member and cannot be used in this context`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_ACCESSIBLE,
);
}
return connectedAccount;
},
authContext,
);
throw new EmailToolException(
`Connected Account '${connectedAccountId}' not found`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
return connectedAccount;
}
private async getOrThrowFirstConnectedAccountId(
workspaceId: string,
): Promise<string> {
const authContext = buildSystemAuthContext(workspaceId);
private async getDefaultConnectedAccountOrThrow({
workspaceId,
userWorkspaceId,
}: {
workspaceId: string;
userWorkspaceId: string | undefined;
}) {
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
await this.connectedAccountMetadataService.findAccessibleConnectedAccounts(
{
userWorkspaceId,
workspaceId,
relations: {
messageChannels: {
messageFolders: true,
},
},
},
);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const allAccounts = await this.connectedAccountRepository.find({
where: { workspaceId },
});
// Prefer the caller's own account; fall back to a workspace-shared one, but
// never silently default to another member's private account.
const connectedAccount =
userConnectedAccounts[0] ?? workspaceSharedConnectedAccounts[0];
if (!allAccounts || allAccounts.length === 0) {
throw new EmailToolException(
'No connected accounts found for this workspace',
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
if (!isDefined(connectedAccount)) {
throw new EmailToolException(
'No connected accounts found for this workspace',
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
return allAccounts[0].id;
},
authContext,
);
return connectedAccount;
}
private normalizeRecipients(parameters: ComposeEmailParams): {
@@ -314,9 +339,8 @@ export class EmailComposerService {
context: ToolExecutionContext,
options: { attachmentsFileFolder: FileFolder },
): Promise<EmailComposerResult> {
const { workspaceId } = context;
const { subject, body, files, inReplyTo } = parameters;
let { connectedAccountId } = parameters;
const { workspaceId, userWorkspaceId } = context;
const { subject, body, files, inReplyTo, connectedAccountId } = parameters;
let recipients: { to: string[]; cc: string[]; bcc: string[] };
@@ -352,15 +376,16 @@ export class EmailComposerService {
const toRecipientsDisplay = recipients.to.join(', ');
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const connectedAccount = isNonEmptyString(connectedAccountId)
? await this.getConnectedAccount({
connectedAccountId,
workspaceId,
userWorkspaceId,
})
: await this.getDefaultConnectedAccountOrThrow({
workspaceId,
userWorkspaceId,
});
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
@@ -375,14 +400,14 @@ export class EmailComposerService {
!isDefined(connectedAccount.connectionParameters?.SMTP)
) {
throw new EmailToolException(
`SMTP is not configured for connected account '${connectedAccountId}'`,
`SMTP is not configured for connected account '${connectedAccount.id}'`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
if (!isSmtpOnlyAccount && !isDefined(messageChannel)) {
throw new EmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
`No message channel found for connected account '${connectedAccount.id}'`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum EmailToolExceptionCode {
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
CONNECTED_ACCOUNT_NOT_ACCESSIBLE = 'CONNECTED_ACCOUNT_NOT_ACCESSIBLE',
INVALID_EMAIL = 'INVALID_EMAIL',
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
@@ -22,6 +23,8 @@ const getEmailToolExceptionUserFriendlyMessage = (
return msg`Invalid connected account ID.`;
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
return msg`Connected account not found.`;
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_ACCESSIBLE:
return msg`This connected account is private to another workspace member. Ask its owner to share it with the workspace, or pick an account you own.`;
case EmailToolExceptionCode.INVALID_EMAIL:
return msg`Invalid email address.`;
case EmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
@@ -0,0 +1,214 @@
import { randomUUID } from 'node:crypto';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
const WORKSPACE_ID = randomUUID();
const ALICE_USER_WORKSPACE_ID = randomUUID();
const BOB_USER_WORKSPACE_ID = randomUUID();
const ALICE_ACCOUNT_ID = randomUUID();
const BOB_ACCOUNT_ID = randomUUID();
const SHARED_ACCOUNT_ID = randomUUID();
type FakeAccount = Partial<ConnectedAccountEntity> & { id: string };
const aliceUserPrivateAccount: FakeAccount = {
id: ALICE_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
visibility: 'user',
};
const bobUserPrivateAccount: FakeAccount = {
id: BOB_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
visibility: 'user',
};
const sharedWorkspaceAccount: FakeAccount = {
id: SHARED_ACCOUNT_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
visibility: 'workspace',
};
const matchesWhere = (
account: FakeAccount,
where: Record<string, unknown> | Record<string, unknown>[],
): boolean => {
const conditions = Array.isArray(where) ? where : [where];
return conditions.some((condition) =>
Object.entries(condition).every(
([key, value]) => account[key as keyof FakeAccount] === value,
),
);
};
describe('ConnectedAccountMetadataService - user-workspace visibility scoping', () => {
let service: ConnectedAccountMetadataService;
let accounts: FakeAccount[];
beforeEach(async () => {
accounts = [
aliceUserPrivateAccount,
bobUserPrivateAccount,
sharedWorkspaceAccount,
];
const mockConnectedAccountRepository = {
findOne: jest.fn(({ where }) =>
Promise.resolve(
accounts.find((account) => matchesWhere(account, where)) ?? null,
),
),
find: jest.fn(({ where }) =>
Promise.resolve(
accounts.filter((account) => matchesWhere(account, where)),
),
),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ConnectedAccountMetadataService,
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: mockConnectedAccountRepository,
},
{
provide: getRepositoryToken(CalendarChannelEntity),
useValue: {},
},
{
provide: getRepositoryToken(MessageChannelEntity),
useValue: {},
},
{
provide: AppOAuthRevokeService,
useValue: {},
},
],
}).compile();
service = module.get<ConnectedAccountMetadataService>(
ConnectedAccountMetadataService,
);
});
describe('findAccessibleConnectedAccountById', () => {
it("should not return another member's user-private account", async () => {
const result = await service.findAccessibleConnectedAccountById({
id: ALICE_ACCOUNT_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
expect(result).toBeNull();
});
it('should return the caller own user-private account', async () => {
const result = await service.findAccessibleConnectedAccountById({
id: BOB_ACCOUNT_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
expect(result?.id).toBe(BOB_ACCOUNT_ID);
});
it('should return a workspace-visibility account for any member', async () => {
const result = await service.findAccessibleConnectedAccountById({
id: SHARED_ACCOUNT_ID,
userWorkspaceId: BOB_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
expect(result?.id).toBe(SHARED_ACCOUNT_ID);
});
it('should reject a user-private account when no userWorkspaceId is present', async () => {
const result = await service.findAccessibleConnectedAccountById({
id: ALICE_ACCOUNT_ID,
userWorkspaceId: undefined,
workspaceId: WORKSPACE_ID,
});
expect(result).toBeNull();
});
it('should allow a workspace-visibility account when no userWorkspaceId is present', async () => {
const result = await service.findAccessibleConnectedAccountById({
id: SHARED_ACCOUNT_ID,
userWorkspaceId: undefined,
workspaceId: WORKSPACE_ID,
});
expect(result?.id).toBe(SHARED_ACCOUNT_ID);
});
});
describe('findAccessibleConnectedAccounts', () => {
it("should split the caller own accounts from workspace-shared ones, never exposing another member's private account", async () => {
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
await service.findAccessibleConnectedAccounts({
userWorkspaceId: BOB_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
expect(userConnectedAccounts.map((account) => account.id)).toEqual([
BOB_ACCOUNT_ID,
]);
expect(
workspaceSharedConnectedAccounts.map((account) => account.id),
).toEqual([SHARED_ACCOUNT_ID]);
});
it('should return only workspace-shared accounts when no userWorkspaceId is present', async () => {
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
await service.findAccessibleConnectedAccounts({
userWorkspaceId: undefined,
workspaceId: WORKSPACE_ID,
});
expect(userConnectedAccounts).toEqual([]);
expect(
workspaceSharedConnectedAccounts.map((account) => account.id),
).toEqual([SHARED_ACCOUNT_ID]);
});
});
describe('findByUserWorkspaceId', () => {
it('should return only the caller own accounts, including their shared ones', async () => {
const result = await service.findByUserWorkspaceId({
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
const ids = result.map((account) => account.id);
expect(ids).toContain(ALICE_ACCOUNT_ID);
expect(ids).toContain(SHARED_ACCOUNT_ID);
expect(ids).not.toContain(BOB_ACCOUNT_ID);
});
it('should not return another member workspace-shared account', async () => {
const result = await service.findByUserWorkspaceId({
userWorkspaceId: BOB_USER_WORKSPACE_ID,
workspaceId: WORKSPACE_ID,
});
const ids = result.map((account) => account.id);
expect(ids).toEqual([BOB_ACCOUNT_ID]);
});
});
});
@@ -1,7 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
type FindOptionsRelations,
type FindOptionsWhere,
Repository,
} from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@@ -34,7 +40,7 @@ export class ConnectedAccountMetadataService {
workspaceId: string;
}): Promise<ConnectedAccountEntity[]> {
return this.repository.find({
where: { userWorkspaceId, workspaceId },
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
});
}
@@ -62,6 +68,105 @@ export class ConnectedAccountMetadataService {
});
}
private getUserConditions({
id,
userWorkspaceId,
workspaceId,
}: {
id?: string;
userWorkspaceId: string;
workspaceId: string;
}): FindOptionsWhere<ConnectedAccountEntity> {
return {
...(isDefined(id) ? { id } : {}),
workspaceId,
userWorkspaceId,
};
}
private getWorkspaceSharedConditions({
id,
workspaceId,
}: {
id?: string;
workspaceId: string;
}): FindOptionsWhere<ConnectedAccountEntity> {
return {
...(isDefined(id) ? { id } : {}),
workspaceId,
visibility: 'workspace',
};
}
private getAccessibleConditions({
id,
userWorkspaceId,
workspaceId,
}: {
id?: string;
userWorkspaceId: string | undefined;
workspaceId: string;
}): FindOptionsWhere<ConnectedAccountEntity>[] {
const workspaceSharedConditions = this.getWorkspaceSharedConditions({
id,
workspaceId,
});
if (!isDefined(userWorkspaceId)) {
return [workspaceSharedConditions];
}
return [
this.getUserConditions({ id, userWorkspaceId, workspaceId }),
workspaceSharedConditions,
];
}
async findAccessibleConnectedAccounts({
userWorkspaceId,
workspaceId,
relations,
}: {
userWorkspaceId: string | undefined;
workspaceId: string;
relations?: FindOptionsRelations<ConnectedAccountEntity>;
}): Promise<{
userConnectedAccounts: ConnectedAccountEntity[];
workspaceSharedConnectedAccounts: ConnectedAccountEntity[];
}> {
const accounts = await this.repository.find({
where: this.getAccessibleConditions({ workspaceId, userWorkspaceId }),
relations,
order: { createdAt: 'ASC' },
});
return {
userConnectedAccounts: accounts.filter(
(account) => account.userWorkspaceId === userWorkspaceId,
),
workspaceSharedConnectedAccounts: accounts.filter(
(account) => account.userWorkspaceId !== userWorkspaceId,
),
};
}
async findAccessibleConnectedAccountById({
id,
userWorkspaceId,
workspaceId,
relations,
}: {
id: string;
userWorkspaceId: string | undefined;
workspaceId: string;
relations?: FindOptionsRelations<ConnectedAccountEntity>;
}): Promise<ConnectedAccountEntity | null> {
return this.repository.findOne({
where: this.getAccessibleConditions({ workspaceId, userWorkspaceId, id }),
relations,
});
}
async verifyOwnership({
id,
userWorkspaceId,
@@ -103,7 +208,7 @@ export class ConnectedAccountMetadataService {
workspaceId: string;
}): Promise<string[]> {
const accounts = await this.repository.find({
where: { userWorkspaceId, workspaceId },
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
select: ['id'],
});
@@ -116,7 +221,7 @@ export class ConnectedAccountMetadataService {
workspaceId: string;
}): Promise<string[]> {
const accounts = await this.repository.find({
where: { workspaceId, visibility: 'workspace' },
where: this.getWorkspaceSharedConditions({ workspaceId }),
select: ['id'],
});
@@ -69,7 +69,7 @@ export class SendEmailResolver {
files: input.files ?? [],
inReplyTo: input.inReplyTo,
},
{ workspaceId: workspace.id },
{ workspaceId: workspace.id, userWorkspaceId },
{ attachmentsFileFolder: FileFolder.EmailAttachment },
);
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -12,22 +12,126 @@ import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/uti
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
import { type WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type';
import { WorkflowRunWorkspaceService as WorkflowRunService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
@Injectable()
// oxlint-disable-next-line twenty/inject-workspace-repository
export class WorkflowExecutionContextService {
private readonly logger = new Logger(WorkflowExecutionContextService.name);
constructor(
private readonly workflowRunService: WorkflowRunService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly userRoleService: UserRoleService,
private readonly applicationService: ApplicationService,
private readonly roleService: RoleService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async getActingUserWorkspaceId(runInfo: {
workflowRunId: string;
workspaceId: string;
}): Promise<string | undefined> {
const workflowRun = await this.workflowRunService.getWorkflowRunOrFail({
workflowRunId: runInfo.workflowRunId,
workspaceId: runInfo.workspaceId,
});
const isManualRun =
workflowRun.createdBy.source === FieldActorSource.MANUAL &&
isDefined(workflowRun.createdBy.workspaceMemberId);
const workspaceMemberId = isManualRun
? workflowRun.createdBy.workspaceMemberId
: await this.getWorkflowCreatorWorkspaceMemberId({
workflowId: workflowRun.workflowId,
workspaceId: runInfo.workspaceId,
});
if (!isDefined(workspaceMemberId)) {
return undefined;
}
return this.resolveUserWorkspaceId({
workspaceMemberId,
workspaceId: runInfo.workspaceId,
});
}
private async getWorkflowCreatorWorkspaceMemberId({
workflowId,
workspaceId,
}: {
workflowId: string;
workspaceId: string;
}): Promise<string | null | undefined> {
const authContext = buildSystemAuthContext(workspaceId);
const workflow =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workflowRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
workspaceId,
'workflow',
{ shouldBypassPermissionChecks: true },
);
return workflowRepository.findOne({ where: { id: workflowId } });
},
authContext,
);
return workflow?.createdBy?.workspaceMemberId;
}
private async resolveUserWorkspaceId({
workspaceMemberId,
workspaceId,
}: {
workspaceMemberId: string;
workspaceId: string;
}): Promise<string | undefined> {
const workspaceMember = await this.userWorkspaceService
.getWorkspaceMemberOrThrow({ workspaceMemberId, workspaceId })
.catch((error) => {
this.logger.warn(
`Could not resolve workspace member ${workspaceMemberId} in workspace ${workspaceId}, falling back to workspace-shared accounts: ${
error instanceof Error ? error.message : String(error)
}`,
);
return undefined;
});
if (!isDefined(workspaceMember)) {
return undefined;
}
const userWorkspace = await this.userWorkspaceService
.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId,
})
.catch((error) => {
this.logger.warn(
`Could not resolve user workspace for user ${workspaceMember.userId} in workspace ${workspaceId}, falling back to workspace-shared accounts: ${
error instanceof Error ? error.message : String(error)
}`,
);
return undefined;
});
return userWorkspace?.id;
}
async getExecutionContext(runInfo: {
workflowRunId: string;
workspaceId: string;
@@ -1,6 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import {
@@ -52,6 +53,14 @@ describe('HttpRequestWorkflowAction', () => {
provide: WorkflowRunStepLogWorkspaceService,
useValue: { setStepLog: mockSetStepLog },
},
{
provide: WorkflowExecutionContextService,
useValue: {
getActingUserWorkspaceId: jest
.fn()
.mockResolvedValue('user-workspace-1'),
},
},
],
}).compile();
@@ -1,12 +1,24 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@Module({
imports: [ToolModule, WorkflowRunModule],
providers: [HttpRequestWorkflowAction],
imports: [
ToolModule,
WorkflowRunModule,
ApplicationModule,
UserWorkspaceModule,
UserRoleModule,
RoleModule,
],
providers: [WorkflowExecutionContextService, HttpRequestWorkflowAction],
exports: [HttpRequestWorkflowAction],
})
export class HttpRequestActionModule {}
@@ -9,6 +9,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { isWorkflowHttpRequestAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard';
import { type WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type';
import { buildHttpRequestStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util';
@@ -21,8 +22,13 @@ export class HttpRequestWorkflowAction extends ToolBackedWorkflowAction<Workflow
constructor(
private readonly httpTool: HttpTool,
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
workflowExecutionContextService: WorkflowExecutionContextService,
) {
super(HttpRequestWorkflowAction.name, workflowRunStepLogService);
super(
HttpRequestWorkflowAction.name,
workflowRunStepLogService,
workflowExecutionContextService,
);
}
protected getTool(): Tool {
@@ -1,6 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import {
@@ -58,6 +59,14 @@ describe('DraftEmailWorkflowAction', () => {
provide: WorkflowRunStepLogWorkspaceService,
useValue: { setStepLog: mockSetStepLog },
},
{
provide: WorkflowExecutionContextService,
useValue: {
getActingUserWorkspaceId: jest
.fn()
.mockResolvedValue('user-workspace-1'),
},
},
],
}).compile();
@@ -1,6 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import {
@@ -47,6 +48,7 @@ const buildSendEmailStep = (input: Record<string, unknown>): WorkflowAction =>
describe('SendEmailWorkflowAction', () => {
let action: SendEmailWorkflowAction;
let mockSendEmailTool: jest.Mocked<Pick<SendEmailTool, 'execute'>>;
let mockGetActingUserWorkspaceId: jest.Mock;
beforeEach(async () => {
jest.clearAllMocks();
@@ -57,6 +59,9 @@ describe('SendEmailWorkflowAction', () => {
error: undefined,
}),
};
mockGetActingUserWorkspaceId = jest
.fn()
.mockResolvedValue('user-workspace-1');
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -66,6 +71,12 @@ describe('SendEmailWorkflowAction', () => {
provide: WorkflowRunStepLogWorkspaceService,
useValue: { setStepLog: jest.fn() },
},
{
provide: WorkflowExecutionContextService,
useValue: {
getActingUserWorkspaceId: mockGetActingUserWorkspaceId,
},
},
],
}).compile();
@@ -173,6 +184,38 @@ describe('SendEmailWorkflowAction', () => {
});
});
describe('acting user identity (regression #21177)', () => {
it('passes the acting user workspace id to the email tool', async () => {
await executeWithBody('hello');
expect(mockGetActingUserWorkspaceId).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
workflowRunId: 'run-1',
});
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
expect.any(Object),
{
workspaceId: 'workspace-1',
userWorkspaceId: 'user-workspace-1',
},
);
});
it('passes undefined user workspace id when no acting user resolves', async () => {
mockGetActingUserWorkspaceId.mockResolvedValue(undefined);
await executeWithBody('hello');
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
expect.any(Object),
{
workspaceId: 'workspace-1',
userWorkspaceId: undefined,
},
);
});
});
describe('step type guard', () => {
it('throws when the current step is not a send-email action', async () => {
await expect(
@@ -6,6 +6,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base';
import { isWorkflowDraftEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard';
import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
@@ -17,8 +18,13 @@ export class DraftEmailWorkflowAction extends EmailWorkflowActionBase {
constructor(
private readonly draftEmailTool: DraftEmailTool,
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
workflowExecutionContextService: WorkflowExecutionContextService,
) {
super(DraftEmailWorkflowAction.name, workflowRunStepLogService);
super(
DraftEmailWorkflowAction.name,
workflowRunStepLogService,
workflowExecutionContextService,
);
}
protected getTool(): Tool {
@@ -1,13 +1,29 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
@Module({
imports: [ToolModule, WorkflowRunModule],
providers: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
imports: [
ToolModule,
WorkflowRunModule,
ApplicationModule,
UserWorkspaceModule,
UserRoleModule,
RoleModule,
],
providers: [
WorkflowExecutionContextService,
SendEmailWorkflowAction,
DraftEmailWorkflowAction,
],
exports: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
})
export class MailSenderActionModule {}
@@ -6,6 +6,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base';
import { isWorkflowSendEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard';
import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
@@ -17,8 +18,13 @@ export class SendEmailWorkflowAction extends EmailWorkflowActionBase {
constructor(
private readonly sendEmailTool: SendEmailTool,
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
workflowExecutionContextService: WorkflowExecutionContextService,
) {
super(SendEmailWorkflowAction.name, workflowRunStepLogService);
super(
SendEmailWorkflowAction.name,
workflowRunStepLogService,
workflowExecutionContextService,
);
}
protected getTool(): Tool {
@@ -7,6 +7,7 @@ import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.ty
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { type WorkflowAction as WorkflowActionContract } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
@@ -27,6 +28,7 @@ export abstract class ToolBackedWorkflowAction<
protected constructor(
loggerName: string,
private readonly workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
) {
this.logger = new Logger(loggerName);
}
@@ -60,9 +62,15 @@ export abstract class ToolBackedWorkflowAction<
const preprocessed = await this.preprocessInput(rawInput, context);
const resolvedInput = resolveInput(preprocessed, context) as TInput;
const userWorkspaceId =
await this.workflowExecutionContextService.getActingUserWorkspaceId(
runInfo,
);
const startedAt = Date.now();
const toolOutput = await this.getTool().execute(resolvedInput, {
workspaceId: runInfo.workspaceId,
userWorkspaceId,
});
const durationMs = Date.now() - startedAt;
@@ -118,4 +118,37 @@ describe('connectedAccountResolver (e2e)', () => {
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
describe('sendEmail', () => {
it("should reject sending from another member's connected account", async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation SendEmail($input: SendEmailInput!) {
sendEmail(input: $input) {
success
error
}
}
`,
variables: {
input: {
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
to: '[email protected]',
subject: 'Should never be sent',
body: '<p>Should never be sent</p>',
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const result = response.body.data.sendEmail;
expect(result.success).toBe(false);
expect(result.error).toMatchInlineSnapshot(
`"Connected account 20202020-0cc8-4d60-a3a4-803245698908 does not belong to user workspace 20202020-1e7c-43d9-a5db-685b5069d816"`,
);
});
});
});