Inject none secret env variables into front components (#20511)

## Summary
- Inject non-secret application variables (`isSecret: false`) into front
component `process.env` via the existing Web Worker `setWorkerEnv`
mechanism
- Filter secret variables server-side in the resolver so they never
reach the browser
- Set application variables before system variables (`TWENTY_API_URL`,
`TWENTY_APP_ACCESS_TOKEN`) to prevent override
- Wire up environment variable keys in the logic function code editor
for TypeScript autocomplete

  ## Test plan
  - [x] Unit tests for `buildNonSecretEnvVar` (6 passing)
  - [x] Typecheck passes for `twenty-front` and `twenty-server`
- [x] Install an app with both `isSecret: false` and `isSecret: true`
variables, open a front component, verify only non-secret vars appear in
`process.env`
- [x] Open a logic function editor, verify autocomplete suggests
declared variable keys
This commit is contained in:
martmull
2026-05-13 18:27:56 +02:00
committed by GitHub
parent 59b993bdb3
commit dea1f89904
25 changed files with 263 additions and 11 deletions
@@ -323,6 +323,7 @@ type FrontComponent {
isHeadless: Boolean!
usesSdkClient: Boolean!
applicationTokenPair: ApplicationTokenPair
applicationVariables: JSON
}
type CommandMenuItem {
@@ -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
}
@@ -707,6 +707,9 @@ export default {
"applicationTokenPair": [
33
],
"applicationVariables": [
15
],
"__typename": [
1
]
@@ -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()`.
@@ -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 <p>Hello, {recipientName}!</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'greeting',
component: Greeting,
});
```
<Warning>
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.
</Warning>
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`:
@@ -23,6 +23,7 @@ type FrontComponentContentProps = {
applicationAccessToken?: string;
apiUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
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,
]);
@@ -36,6 +36,7 @@ type FrontComponentWorkerEffectProps = {
applicationAccessToken?: string;
apiUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
frontComponentId: string;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
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,
@@ -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,
@@ -1,4 +1,4 @@
export const setWorkerEnv = (environmentVariables: Record<string, string>) => {
export const setWorkerEnv = (variables: Record<string, string>) => {
const globalObject = globalThis as Record<string, unknown>;
const processObject =
(globalObject['process'] as Record<string, unknown> | undefined) ?? {};
@@ -7,7 +7,7 @@ export const setWorkerEnv = (environmentVariables: Record<string, string>) => {
processObject['env'] = {
...processEnvironment,
...environmentVariables,
...variables,
};
globalObject['process'] = processObject;
@@ -8,4 +8,5 @@ export type HostToWorkerRenderContext = {
applicationAccessToken?: string;
apiUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
};
@@ -1841,6 +1841,7 @@ export type FrontComponent = {
__typename?: 'FrontComponent';
applicationId: Scalars['UUID'];
applicationTokenPair?: Maybe<ApplicationTokenPair>;
applicationVariables?: Maybe<Scalars['JSON']>;
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<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>;
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<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>;
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<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>;
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<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>;
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<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>;
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<CreateOneLogicFunctionMutation, CreateOneLogicFunctionMutationVariables>;
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<DeleteOneLogicFunctionMutation, DeleteOneLogicFunctionMutationVariables>;
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<ExecuteOneLogicFunctionMutation, ExecuteOneLogicFunctionMutationVariables>;
@@ -100,6 +100,9 @@ export const FrontComponentRenderer = ({
const accessToken = applicationTokenPair.applicationAccessToken.token;
const applicationVariables =
data.frontComponent.applicationVariables ?? undefined;
if (usesSdkClient) {
return (
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
@@ -112,6 +115,7 @@ export const FrontComponentRenderer = ({
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
}
applicationVariables={applicationVariables}
onError={handleError}
/>
</FrontComponentRendererProvider>
@@ -127,6 +131,7 @@ export const FrontComponentRenderer = ({
apiUrl={REACT_APP_SERVER_BASE_URL}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
applicationVariables={applicationVariables}
onError={handleError}
/>
</FrontComponentRendererProvider>
@@ -16,6 +16,7 @@ type FrontComponentRendererWithSdkClientProps = {
applicationId: string;
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
applicationVariables?: Record<string, string>;
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}
/>
)}
@@ -9,6 +9,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
builtComponentChecksum
isHeadless
usesSdkClient
applicationVariables
applicationTokenPair {
applicationAccessToken {
token
@@ -16,6 +16,7 @@ type SettingsLogicFunctionCodeEditorProps = Omit<EditorProps, 'onChange'> & {
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',
},
]);
@@ -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}
/>
)}
</Section>
@@ -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 && (
@@ -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<string, string>;
return variables[key];
};
@@ -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';
@@ -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<string, string>;
}
@@ -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: [
@@ -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,
};
}
@@ -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>,
): 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({});
});
});
@@ -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<string, string> => {
return flatApplicationVariables.reduce<Record<string, string>>(
(acc, flatApplicationVariable) => {
if (flatApplicationVariable.isSecret) {
return acc;
}
acc[flatApplicationVariable.key] = String(
flatApplicationVariable.value ?? '',
);
return acc;
},
{},
);
};
@@ -110,6 +110,14 @@ export class WorkspaceMigrationRunnerService {
);
}
if (flatMapsKeysSet.has('flatApplicationVariableMaps')) {
asyncOperations.push(
this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'applicationVariableMaps',
]),
);
}
return asyncOperations;
}