diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index d09443516c..cad178d61b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -323,6 +323,7 @@ type FrontComponent { isHeadless: Boolean! usesSdkClient: Boolean! applicationTokenPair: ApplicationTokenPair + applicationVariables: JSON } type CommandMenuItem { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index f8f4fbc623..fa1453d2ad 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -276,6 +276,7 @@ export interface FrontComponent { isHeadless: Scalars['Boolean'] usesSdkClient: Scalars['Boolean'] applicationTokenPair?: ApplicationTokenPair + applicationVariables?: Scalars['JSON'] __typename: 'FrontComponent' } @@ -3173,6 +3174,7 @@ export interface FrontComponentGenqlSelection{ isHeadless?: boolean | number usesSdkClient?: boolean | number applicationTokenPair?: ApplicationTokenPairGenqlSelection + applicationVariables?: boolean | number __typename?: boolean | number __scalar?: boolean | number } diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2cb30aa50c..56106c99df 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -707,6 +707,9 @@ export default { "applicationTokenPair": [ 33 ], + "applicationVariables": [ + 15 + ], "__typename": [ 1 ] diff --git a/packages/twenty-docs/developers/extend/apps/config/application.mdx b/packages/twenty-docs/developers/extend/apps/config/application.mdx index c5825c51d9..5a29870fbc 100644 --- a/packages/twenty-docs/developers/extend/apps/config/application.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/application.mdx @@ -31,7 +31,7 @@ export default defineApplication({ Notes: - `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs. -- `applicationVariables` become environment variables for your functions and front components (e.g., `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`). +- `applicationVariables` become environment variables for your functions and front components. In logic functions (server-side), they are available as `process.env.VARIABLE_NAME`. In front components, use `getApplicationVariable('VARIABLE_NAME')` from `twenty-sdk/front-component`. Variables marked with `isSecret: true` are only injected into logic functions. Front components receive only non-secret variables. - The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`. - Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`. - Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`. diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx index 1c7ab6fb9a..f42e254c40 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx @@ -239,6 +239,38 @@ Available hooks: | `useFrontComponentId()` | `string` | This component instance's ID | | `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function | +## Application variables + +Application variables defined in [`defineApplication()`](/developers/extend/apps/config/application) with `isSecret: false` are available inside front components via the `getApplicationVariable` utility: + +```tsx src/front-components/greeting.tsx +import { defineFrontComponent } from 'twenty-sdk/define'; +import { getApplicationVariable } from 'twenty-sdk/front-component'; + +const Greeting = () => { + const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World'; + + return

Hello, {recipientName}!

; +}; + +export default defineFrontComponent({ + universalIdentifier: '...', + name: 'greeting', + component: Greeting, +}); +``` + + +Secret variables (`isSecret: true`) are **not** exposed to front components. They are only available in [logic functions](/developers/extend/apps/logic/logic-functions), which run server-side. This prevents sensitive values like API keys from being sent to the browser. + + +The following system variables are always available via `process.env`: + +| Variable | Description | +|----------|-------------| +| `TWENTY_API_URL` | Base URL of the Twenty API | +| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role | + ## Host communication API Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`: diff --git a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx index 63767ba0e4..5707497cc8 100644 --- a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx @@ -23,6 +23,7 @@ type FrontComponentContentProps = { applicationAccessToken?: string; apiUrl?: string; sdkClientUrls?: SdkClientUrls; + applicationVariables?: Record; executionContext: FrontComponentExecutionContext; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; onError: (error?: Error) => void; @@ -34,6 +35,7 @@ export const FrontComponentRenderer = ({ applicationAccessToken, apiUrl, sdkClientUrls, + applicationVariables, executionContext, frontComponentHostCommunicationApi, onError, @@ -55,6 +57,7 @@ export const FrontComponentRenderer = ({ applicationAccessToken={applicationAccessToken} apiUrl={apiUrl} sdkClientUrls={sdkClientUrls} + applicationVariables={applicationVariables} frontComponentId={executionContext.frontComponentId} setReceiver={setReceiver} setThread={setThread} @@ -69,6 +72,7 @@ export const FrontComponentRenderer = ({ applicationAccessToken, apiUrl, sdkClientUrls, + applicationVariables, executionContext.frontComponentId, ]); diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx index 6abcc53f13..c25dbc1799 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx @@ -36,6 +36,7 @@ type FrontComponentWorkerEffectProps = { applicationAccessToken?: string; apiUrl?: string; sdkClientUrls?: SdkClientUrls; + applicationVariables?: Record; frontComponentId: string; setReceiver: React.Dispatch>; setThread: React.Dispatch< @@ -52,6 +53,7 @@ export const FrontComponentWorkerEffect = ({ applicationAccessToken, apiUrl, sdkClientUrls, + applicationVariables, frontComponentId, setReceiver, setThread, @@ -121,6 +123,7 @@ export const FrontComponentWorkerEffect = ({ applicationAccessToken, apiUrl, sdkClientUrls, + applicationVariables, }) .catch((error: Error) => { setError(error); @@ -143,6 +146,7 @@ export const FrontComponentWorkerEffect = ({ applicationAccessToken, apiUrl, sdkClientUrls, + applicationVariables, frontComponentId, setError, setReceiver, diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts index b221fda899..401fd471ea 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts @@ -91,6 +91,13 @@ const render: WorkerExports['render'] = async ( document.body.append(root); installStyleBridge(root); + if (isDefined(renderContext.applicationVariables)) { + setWorkerEnv({ + applicationVariables: JSON.stringify(renderContext.applicationVariables), + }); + } + + // System variables are set after application variables so they cannot be overridden if (isDefined(renderContext.apiUrl)) { setWorkerEnv({ TWENTY_API_URL: renderContext.apiUrl, diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts index 5e470887b5..ab6c8e07a9 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/setWorkerEnv.ts @@ -1,4 +1,4 @@ -export const setWorkerEnv = (environmentVariables: Record) => { +export const setWorkerEnv = (variables: Record) => { const globalObject = globalThis as Record; const processObject = (globalObject['process'] as Record | undefined) ?? {}; @@ -7,7 +7,7 @@ export const setWorkerEnv = (environmentVariables: Record) => { processObject['env'] = { ...processEnvironment, - ...environmentVariables, + ...variables, }; globalObject['process'] = processObject; diff --git a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts index dc4eb9bb9b..83c99e644c 100644 --- a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts +++ b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts @@ -8,4 +8,5 @@ export type HostToWorkerRenderContext = { applicationAccessToken?: string; apiUrl?: string; sdkClientUrls?: SdkClientUrls; + applicationVariables?: Record; }; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d9a141efd8..92b967453e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1841,6 +1841,7 @@ export type FrontComponent = { __typename?: 'FrontComponent'; applicationId: Scalars['UUID']; applicationTokenPair?: Maybe; + applicationVariables?: Maybe; builtComponentChecksum: Scalars['String']; builtComponentPath: Scalars['String']; componentName: Scalars['String']; @@ -6587,7 +6588,7 @@ export type FindOneFrontComponentQueryVariables = Exact<{ }>; -export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, isHeadless: boolean, usesSdkClient: boolean, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null }; +export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, isHeadless: boolean, usesSdkClient: boolean, applicationVariables?: any | null, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null }; export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, toolTriggerSettings?: any | null, workflowActionTriggerSettings?: any | null, applicationId?: string | null, universalIdentifier?: string | null, createdAt: string, updatedAt: string }; @@ -8045,7 +8046,7 @@ export const UploadFilesFieldFileDocument = {"kind":"Document","definitions":[{" export const UploadWorkflowFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkflowFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkflowFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const RenewApplicationTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RenewApplicationToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renewApplicationToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRefreshToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const FindManyFrontComponentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyFrontComponents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentChecksum"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentPath"}},{"kind":"Field","name":{"kind":"Name","value":"componentName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}},{"kind":"Field","name":{"kind":"Name","value":"sourceComponentPath"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"usesSdkClient"}}]}}]}}]} as unknown as DocumentNode; -export const FindOneFrontComponentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneFrontComponent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentChecksum"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}},{"kind":"Field","name":{"kind":"Name","value":"usesSdkClient"}},{"kind":"Field","name":{"kind":"Name","value":"applicationTokenPair"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const FindOneFrontComponentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneFrontComponent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentChecksum"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}},{"kind":"Field","name":{"kind":"Name","value":"usesSdkClient"}},{"kind":"Field","name":{"kind":"Name","value":"applicationVariables"}},{"kind":"Field","name":{"kind":"Name","value":"applicationTokenPair"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateLogicFunctionFromSourceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LogicFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LogicFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"sourceHandlerPath"}},{"kind":"Field","name":{"kind":"Name","value":"handlerName"}},{"kind":"Field","name":{"kind":"Name","value":"cronTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"databaseEventTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"httpRouteTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"toolTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"workflowActionTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const DeleteOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LogicFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LogicFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"sourceHandlerPath"}},{"kind":"Field","name":{"kind":"Name","value":"handlerName"}},{"kind":"Field","name":{"kind":"Name","value":"cronTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"databaseEventTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"httpRouteTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"toolTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"workflowActionTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const ExecuteOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ExecuteOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ExecuteOneLogicFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"executeOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"logs"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index 30698f5df0..650740a5a3 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -100,6 +100,9 @@ export const FrontComponentRenderer = ({ const accessToken = applicationTokenPair.applicationAccessToken.token; + const applicationVariables = + data.frontComponent.applicationVariables ?? undefined; + if (usesSdkClient) { return ( @@ -112,6 +115,7 @@ export const FrontComponentRenderer = ({ frontComponentHostCommunicationApi={ frontComponentHostCommunicationApi } + applicationVariables={applicationVariables} onError={handleError} /> @@ -127,6 +131,7 @@ export const FrontComponentRenderer = ({ apiUrl={REACT_APP_SERVER_BASE_URL} executionContext={executionContext} frontComponentHostCommunicationApi={frontComponentHostCommunicationApi} + applicationVariables={applicationVariables} onError={handleError} /> diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererWithSdkClient.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererWithSdkClient.tsx index 7f16e9013b..0d2402a7cd 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererWithSdkClient.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererWithSdkClient.tsx @@ -16,6 +16,7 @@ type FrontComponentRendererWithSdkClientProps = { applicationId: string; executionContext: FrontComponentExecutionContext; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; + applicationVariables?: Record; onError: (error?: Error) => void; }; @@ -26,6 +27,7 @@ export const FrontComponentRendererWithSdkClient = ({ applicationId, executionContext, frontComponentHostCommunicationApi, + applicationVariables, onError, }: FrontComponentRendererWithSdkClientProps) => { const sdkClientState = useAtomValue( @@ -50,6 +52,7 @@ export const FrontComponentRendererWithSdkClient = ({ frontComponentHostCommunicationApi={ frontComponentHostCommunicationApi } + applicationVariables={applicationVariables} onError={onError} /> )} diff --git a/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts b/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts index f13be24c1c..17c80c18a0 100644 --- a/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts +++ b/packages/twenty-front/src/modules/front-components/graphql/queries/findOneFrontComponent.ts @@ -9,6 +9,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql` builtComponentChecksum isHeadless usesSdkClient + applicationVariables applicationTokenPair { applicationAccessToken { token diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx index 1f08de4064..5ff5871db2 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx @@ -16,6 +16,7 @@ type SettingsLogicFunctionCodeEditorProps = Omit & { currentFilePath: string; files: File[]; onChange: (value: string) => void; + applicationVariableKeys?: string[]; }; export const SettingsLogicFunctionCodeEditor = ({ @@ -24,6 +25,7 @@ export const SettingsLogicFunctionCodeEditor = ({ onChange, height = 450, options = undefined, + applicationVariableKeys, }: SettingsLogicFunctionCodeEditorProps) => { const { logicFunctionId = '' } = useParams(); const { availablePackages } = useGetAvailablePackages({ @@ -63,15 +65,16 @@ export const SettingsLogicFunctionCodeEditor = ({ target: monaco.languages.typescript.ScriptTarget.ESNext, }); - // TODO load that with proper env variables - const environmentVariables = {}; + const applicationVariables = Object.fromEntries( + (applicationVariableKeys ?? []).map((key) => [key, '']), + ); - if (isDefined(environmentVariables)) { - const envTypeDefinitions = Object.keys(environmentVariables) + if (isDefined(applicationVariables)) { + const envTypeDefinitions = Object.keys(applicationVariables) // oxlint-disable-next-line lingui/no-unlocalized-strings .map((key) => `${key}: string;`) .join('\n'); - const environmentDefinition = ` + const applicationVariableDefinition = ` declare namespace NodeJS { interface ProcessEnv { ${envTypeDefinitions} @@ -85,7 +88,7 @@ export const SettingsLogicFunctionCodeEditor = ({ monaco.languages.typescript.typescriptDefaults.setExtraLibs([ { - content: environmentDefinition, + content: applicationVariableDefinition, filePath: 'ts:process-env.d.ts', }, ]); diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx index 9e1c7e12bf..45d03d7460 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx @@ -23,11 +23,13 @@ export const SettingsLogicFunctionCodeEditorTab = ({ handleExecute, onChange, isTesting = false, + applicationVariableKeys, }: { files: File[]; handleExecute: () => void; onChange: (value: string) => void; isTesting?: boolean; + applicationVariableKeys?: string[]; }) => { const activeTabId = useAtomComponentStateValue( activeTabIdComponentState, @@ -68,6 +70,7 @@ export const SettingsLogicFunctionCodeEditorTab = ({ files={files} currentFilePath={activeTabId} onChange={(newCodeValue: string) => onChange(newCodeValue)} + applicationVariableKeys={applicationVariableKeys} /> )} diff --git a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx index 53c48f489a..3bfea560b4 100644 --- a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx +++ b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx @@ -44,6 +44,11 @@ export const SettingsLogicFunctionDetail = () => { const applicationName = data?.findOneApplication?.name; + const applicationVariableKeys = + data?.findOneApplication?.applicationVariables?.map( + (variable) => variable.key, + ) ?? []; + const workspaceCustomApplicationId = currentWorkspace?.workspaceCustomApplication?.id; @@ -152,6 +157,7 @@ export const SettingsLogicFunctionDetail = () => { handleExecute={handleTestFunction} onChange={onChange('sourceHandlerCode')} isTesting={isExecuting} + applicationVariableKeys={applicationVariableKeys} /> )} {isTriggersTab && ( diff --git a/packages/twenty-sdk/src/sdk/front-component/functions/getApplicationVariable.ts b/packages/twenty-sdk/src/sdk/front-component/functions/getApplicationVariable.ts new file mode 100644 index 0000000000..fc971385ab --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/functions/getApplicationVariable.ts @@ -0,0 +1,11 @@ +export const getApplicationVariable = (key: string): string | undefined => { + const raw = process.env.applicationVariables; + + if (!raw) { + return undefined; + } + + const variables = JSON.parse(raw) as Record; + + return variables[key]; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/index.ts b/packages/twenty-sdk/src/sdk/front-component/index.ts index eb7b31d03e..86cfb8ba25 100644 --- a/packages/twenty-sdk/src/sdk/front-component/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component/index.ts @@ -29,6 +29,7 @@ export { objectMetadataItem, } from './conditional-availability/conditional-availability-variables'; export { closeSidePanel } from './functions/closeSidePanel'; +export { getApplicationVariable } from './functions/getApplicationVariable'; export { enqueueSnackbar } from './functions/enqueueSnackbar'; export { navigate } from './functions/navigate'; export { openCommandConfirmationModal } from './functions/openCommandConfirmationModal'; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts index 5b2377aea6..9cc7c93f2c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts @@ -8,6 +8,7 @@ import { IsString, IsUUID, } from 'class-validator'; +import { GraphQLJSON } from 'graphql-type-json'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto'; @@ -74,4 +75,7 @@ export class FrontComponentDTO { @Field(() => ApplicationTokenPairDTO, { nullable: true }) applicationTokenPair?: ApplicationTokenPairDTO; + + @Field(() => GraphQLJSON, { nullable: true }) + applicationVariables?: Record; } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts index e6b4335ec2..1c65111764 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -11,6 +11,7 @@ import { FrontComponentService } from 'src/engine/metadata-modules/front-compone import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -23,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace PermissionsModule, FlatFrontComponentModule, SubscriptionsModule, + WorkspaceCacheModule, ], controllers: [FrontComponentController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts index 8c655f2a42..42376ad8e3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts @@ -2,6 +2,7 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -21,6 +22,8 @@ import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/d import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; +import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; @UseGuards(WorkspaceAuthGuard) @@ -35,6 +38,7 @@ export class FrontComponentResolver { private readonly frontComponentService: FrontComponentService, @Inject(ApplicationTokenService) private readonly applicationTokenService: ApplicationTokenService, + private readonly workspaceCacheService: WorkspaceCacheService, ) {} @Query(() => [FrontComponentDTO]) @@ -67,9 +71,31 @@ export class FrontComponentResolver { userId: user.id, }); + const { applicationVariableMaps } = + await this.workspaceCacheService.getOrRecompute(workspace.id, [ + 'applicationVariableMaps', + ]); + + const variableUniversalIdentifiers = + applicationVariableMaps.universalIdentifiersByApplicationId[ + dto.applicationId + ] ?? []; + + const flatApplicationVariables = variableUniversalIdentifiers + .map( + (universalIdentifier) => + applicationVariableMaps.byUniversalIdentifier[universalIdentifier], + ) + .filter(isDefined); + + const applicationVariables = stripSecretFromApplicationVariables( + flatApplicationVariables, + ); + return { ...dto, applicationTokenPair: tokenPair, + applicationVariables, }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts new file mode 100644 index 0000000000..6b709d633a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts @@ -0,0 +1,104 @@ +import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; +import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables'; + +const makeFlatVariable = ( + overrides: Partial, +): FlatApplicationVariable => ({ + id: '1', + key: 'KEY', + value: 'value', + description: '', + isSecret: false, + applicationId: 'app-1', + workspaceId: '00000000-0000-0000-0000-000000000000', + universalIdentifier: '00000000-0000-0000-0000-000000000000', + applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ...overrides, +}); + +describe('stripSecretFromApplicationVariables', () => { + it('should return empty object for empty array', () => { + expect(stripSecretFromApplicationVariables([])).toEqual({}); + }); + + it('should include non-secret variables', () => { + const variables = [ + makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }), + makeFlatVariable({ id: '2', key: 'DEBUG', value: 'true' }), + ]; + + expect(stripSecretFromApplicationVariables(variables)).toEqual({ + PUBLIC_URL: 'https://example.com', + DEBUG: 'true', + }); + }); + + it('should exclude secret variables', () => { + const variables = [ + makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }), + makeFlatVariable({ + id: '2', + key: 'API_SECRET', + value: 'encrypted_secret', + isSecret: true, + }), + makeFlatVariable({ id: '3', key: 'DEBUG', value: 'true' }), + ]; + + const result = stripSecretFromApplicationVariables(variables); + + expect(result).toEqual({ + PUBLIC_URL: 'https://example.com', + DEBUG: 'true', + }); + expect(result).not.toHaveProperty('API_SECRET'); + }); + + it('should handle null and undefined values', () => { + const variables = [ + makeFlatVariable({ + key: 'NULL_VALUE', + value: null as unknown as string, + }), + makeFlatVariable({ + id: '2', + key: 'UNDEFINED_VALUE', + value: undefined as unknown as string, + }), + ]; + + expect(stripSecretFromApplicationVariables(variables)).toEqual({ + NULL_VALUE: '', + UNDEFINED_VALUE: '', + }); + }); + + it('should convert non-string values to strings', () => { + const variables = [ + makeFlatVariable({ + key: 'NUMBER_VALUE', + value: 123 as unknown as string, + }), + ]; + + expect(stripSecretFromApplicationVariables(variables)).toEqual({ + NUMBER_VALUE: '123', + }); + }); + + it('should return empty object when all variables are secret', () => { + const variables = [ + makeFlatVariable({ key: 'SECRET_1', value: 'val1', isSecret: true }), + makeFlatVariable({ + id: '2', + key: 'SECRET_2', + value: 'val2', + isSecret: true, + }), + ]; + + expect(stripSecretFromApplicationVariables(variables)).toEqual({}); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts new file mode 100644 index 0000000000..0bf9a4c607 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts @@ -0,0 +1,20 @@ +import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type'; + +export const stripSecretFromApplicationVariables = ( + flatApplicationVariables: FlatApplicationVariable[], +): Record => { + return flatApplicationVariables.reduce>( + (acc, flatApplicationVariable) => { + if (flatApplicationVariable.isSecret) { + return acc; + } + + acc[flatApplicationVariable.key] = String( + flatApplicationVariable.value ?? '', + ); + + return acc; + }, + {}, + ); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts index 9d776fbf6b..579e0b0376 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts @@ -110,6 +110,14 @@ export class WorkspaceMigrationRunnerService { ); } + if (flatMapsKeysSet.has('flatApplicationVariableMaps')) { + asyncOperations.push( + this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'applicationVariableMaps', + ]), + ); + } + return asyncOperations; }