[FRONT COMPONENT] Add Front component token generation (#17855)
closes https://github.com/twentyhq/core-team-issues/issues/2180 https://github.com/user-attachments/assets/a898455d-eb1c-4d22-b585-785e98fc38a7
This commit is contained in:
@@ -28,6 +28,7 @@ module.exports = {
|
||||
'./src/modules/attachments/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/file/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/onboarding/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/front-components/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
|
||||
|
||||
|
||||
@@ -292,6 +292,12 @@ export type Application = {
|
||||
yarnLockFileId?: Maybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
export type ApplicationTokenPair = {
|
||||
__typename?: 'ApplicationTokenPair';
|
||||
applicationAccessToken: AuthToken;
|
||||
applicationRefreshToken: AuthToken;
|
||||
};
|
||||
|
||||
export type ApplicationVariable = {
|
||||
__typename?: 'ApplicationVariable';
|
||||
description: Scalars['String'];
|
||||
@@ -1659,6 +1665,7 @@ export type FindAvailableSsoidpOutput = {
|
||||
export type FrontComponent = {
|
||||
__typename?: 'FrontComponent';
|
||||
applicationId: Scalars['UUID'];
|
||||
applicationTokenPair?: Maybe<ApplicationTokenPair>;
|
||||
builtComponentChecksum: Scalars['String'];
|
||||
builtComponentPath: Scalars['String'];
|
||||
componentName: Scalars['String'];
|
||||
@@ -2242,6 +2249,7 @@ export type Mutation = {
|
||||
installMarketplaceApp: Scalars['Boolean'];
|
||||
removeQueryFromEventStream: Scalars['Boolean'];
|
||||
removeRoleFromAgent: Scalars['Boolean'];
|
||||
renewApplicationToken: ApplicationTokenPair;
|
||||
renewToken: AuthTokens;
|
||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||
@@ -2819,6 +2827,11 @@ export type MutationRemoveRoleFromAgentArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRenewApplicationTokenArgs = {
|
||||
applicationRefreshToken: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationRenewTokenArgs = {
|
||||
appToken: Scalars['String'];
|
||||
};
|
||||
@@ -5712,6 +5725,18 @@ export type UploadFilesFieldFileMutationVariables = Exact<{
|
||||
|
||||
export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'FilesFieldFile', id: string, path: string, size: number, createdAt: string, url: string } };
|
||||
|
||||
export type FindManyFrontComponentsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindManyFrontComponentsQuery = { __typename?: 'Query', frontComponents: Array<{ __typename?: 'FrontComponent', id: string, name: string, applicationId: string }> };
|
||||
|
||||
export type FindOneFrontComponentQueryVariables = Exact<{
|
||||
id: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, 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, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
export type CreateOneLogicFunctionMutationVariables = Exact<{
|
||||
@@ -10399,6 +10424,89 @@ export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHoo
|
||||
export type UploadFilesFieldFileMutationHookResult = ReturnType<typeof useUploadFilesFieldFileMutation>;
|
||||
export type UploadFilesFieldFileMutationResult = Apollo.MutationResult<UploadFilesFieldFileMutation>;
|
||||
export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>;
|
||||
export const FindManyFrontComponentsDocument = gql`
|
||||
query FindManyFrontComponents {
|
||||
frontComponents {
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useFindManyFrontComponentsQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useFindManyFrontComponentsQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useFindManyFrontComponentsQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useFindManyFrontComponentsQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useFindManyFrontComponentsQuery(baseOptions?: Apollo.QueryHookOptions<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>(FindManyFrontComponentsDocument, options);
|
||||
}
|
||||
export function useFindManyFrontComponentsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>(FindManyFrontComponentsDocument, options);
|
||||
}
|
||||
export type FindManyFrontComponentsQueryHookResult = ReturnType<typeof useFindManyFrontComponentsQuery>;
|
||||
export type FindManyFrontComponentsLazyQueryHookResult = ReturnType<typeof useFindManyFrontComponentsLazyQuery>;
|
||||
export type FindManyFrontComponentsQueryResult = Apollo.QueryResult<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>;
|
||||
export const FindOneFrontComponentDocument = gql`
|
||||
query FindOneFrontComponent($id: UUID!) {
|
||||
frontComponent(id: $id) {
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
applicationRefreshToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useFindOneFrontComponentQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useFindOneFrontComponentQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useFindOneFrontComponentQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useFindOneFrontComponentQuery({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useFindOneFrontComponentQuery(baseOptions: Apollo.QueryHookOptions<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>(FindOneFrontComponentDocument, options);
|
||||
}
|
||||
export function useFindOneFrontComponentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>(FindOneFrontComponentDocument, options);
|
||||
}
|
||||
export type FindOneFrontComponentQueryHookResult = ReturnType<typeof useFindOneFrontComponentQuery>;
|
||||
export type FindOneFrontComponentLazyQueryHookResult = ReturnType<typeof useFindOneFrontComponentLazyQuery>;
|
||||
export type FindOneFrontComponentQueryResult = Apollo.QueryResult<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>;
|
||||
export const CreateOneLogicFunctionDocument = gql`
|
||||
mutation CreateOneLogicFunction($input: CreateLogicFunctionFromSourceInput!) {
|
||||
createOneLogicFunction(input: $input) {
|
||||
|
||||
+26
-13
@@ -1,12 +1,13 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-sdk/front-component';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useFindOneFrontComponentQuery } from '~/generated-metadata/graphql';
|
||||
|
||||
type FrontComponentRendererProps = {
|
||||
frontComponentId: string;
|
||||
@@ -16,28 +17,37 @@ export const FrontComponentRenderer = ({
|
||||
frontComponentId,
|
||||
}: FrontComponentRendererProps) => {
|
||||
const theme = useTheme();
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext();
|
||||
|
||||
const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
|
||||
const authToken = getTokenPair()?.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
const handleError = (error?: Error) => {
|
||||
if (isDefined(error)) {
|
||||
const handleError = useCallback(
|
||||
(error?: Error) => {
|
||||
if (!isDefined(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = error.message;
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to load front component: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
setHasError(true);
|
||||
};
|
||||
},
|
||||
[enqueueErrorSnackBar],
|
||||
);
|
||||
|
||||
if (hasError || !isDefined(authToken)) {
|
||||
// TODO: Add an error display component here
|
||||
const { data, loading } = useFindOneFrontComponentQuery({
|
||||
variables: { id: frontComponentId },
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
if (
|
||||
loading ||
|
||||
!isDefined(data?.frontComponent) ||
|
||||
!isDefined(data.frontComponent.applicationTokenPair)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,7 +55,10 @@ export const FrontComponentRenderer = ({
|
||||
<SharedFrontComponentRenderer
|
||||
theme={theme}
|
||||
componentUrl={componentUrl}
|
||||
authToken={authToken}
|
||||
applicationAccessToken={
|
||||
data.frontComponent.applicationTokenPair.applicationAccessToken.token
|
||||
}
|
||||
apiUrl={REACT_APP_SERVER_BASE_URL}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
||||
onError={handleError}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const FIND_ONE_FRONT_COMPONENT = gql`
|
||||
query FindOneFrontComponent($id: UUID!) {
|
||||
frontComponent(id: $id) {
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
applicationRefreshToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
-1
@@ -15,7 +15,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
},
|
||||
args: {
|
||||
onError: errorHandler,
|
||||
authToken: 'fake-token',
|
||||
applicationAccessToken: 'fake-token',
|
||||
},
|
||||
beforeEach: () => {
|
||||
errorHandler.mockClear();
|
||||
|
||||
@@ -19,7 +19,8 @@ import { componentRegistry } from '../generated/host-component-registry';
|
||||
|
||||
type FrontComponentContentProps = {
|
||||
componentUrl: string;
|
||||
authToken: string;
|
||||
applicationAccessToken?: string;
|
||||
apiUrl?: string;
|
||||
executionContext: FrontComponentExecutionContext;
|
||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||
onError: (error?: Error) => void;
|
||||
@@ -28,7 +29,8 @@ type FrontComponentContentProps = {
|
||||
|
||||
export const FrontComponentRenderer = ({
|
||||
componentUrl,
|
||||
authToken,
|
||||
applicationAccessToken,
|
||||
apiUrl,
|
||||
executionContext,
|
||||
frontComponentHostCommunicationApi,
|
||||
onError,
|
||||
@@ -45,7 +47,8 @@ export const FrontComponentRenderer = ({
|
||||
return (
|
||||
<FrontComponentWorkerEffect
|
||||
componentUrl={componentUrl}
|
||||
authToken={authToken}
|
||||
applicationAccessToken={applicationAccessToken}
|
||||
apiUrl={apiUrl}
|
||||
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
||||
setReceiver={setReceiver}
|
||||
setThread={setThread}
|
||||
@@ -54,11 +57,12 @@ export const FrontComponentRenderer = ({
|
||||
);
|
||||
}, [
|
||||
componentUrl,
|
||||
authToken,
|
||||
frontComponentHostCommunicationApi,
|
||||
setError,
|
||||
setReceiver,
|
||||
setThread,
|
||||
applicationAccessToken,
|
||||
apiUrl,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
+17
-4
@@ -7,7 +7,8 @@ import { createRemoteWorker } from '../worker/createRemoteWorker';
|
||||
|
||||
type FrontComponentWorkerEffectProps = {
|
||||
componentUrl: string;
|
||||
authToken: string;
|
||||
applicationAccessToken?: string;
|
||||
apiUrl?: string;
|
||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
|
||||
setThread: React.Dispatch<
|
||||
@@ -21,7 +22,8 @@ type FrontComponentWorkerEffectProps = {
|
||||
|
||||
export const FrontComponentWorkerEffect = ({
|
||||
componentUrl,
|
||||
authToken,
|
||||
applicationAccessToken,
|
||||
apiUrl,
|
||||
frontComponentHostCommunicationApi,
|
||||
setReceiver,
|
||||
setThread,
|
||||
@@ -58,7 +60,11 @@ export const FrontComponentWorkerEffect = ({
|
||||
setThread(thread);
|
||||
|
||||
thread.imports
|
||||
.render(newReceiver.connection, { componentUrl, authToken })
|
||||
.render(newReceiver.connection, {
|
||||
componentUrl,
|
||||
applicationAccessToken,
|
||||
apiUrl,
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setError(error);
|
||||
});
|
||||
@@ -69,7 +75,14 @@ export const FrontComponentWorkerEffect = ({
|
||||
setThread(null);
|
||||
worker.terminate();
|
||||
};
|
||||
}, [componentUrl, authToken, setError, setReceiver, setThread]);
|
||||
}, [
|
||||
componentUrl,
|
||||
applicationAccessToken,
|
||||
apiUrl,
|
||||
setError,
|
||||
setReceiver,
|
||||
setThread,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { jsx, jsxs } from 'react/jsx-runtime';
|
||||
import * as TwentySharedTypes from 'twenty-shared/types';
|
||||
import * as TwentySharedUtils from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import * as TwentySdk from '@/sdk';
|
||||
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
|
||||
@@ -25,6 +26,7 @@ import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderCo
|
||||
import { type WorkerExports } from '../../types/WorkerExports';
|
||||
import * as RemoteComponents from '../generated/remote-components';
|
||||
import { exposeGlobals } from '../utils/exposeGlobals';
|
||||
import { setWorkerEnv } from './utils/setWorkerEnv';
|
||||
|
||||
exposeGlobals({
|
||||
React,
|
||||
@@ -47,8 +49,20 @@ const render: WorkerExports['render'] = async (
|
||||
root.connect(batchedConnection);
|
||||
document.body.append(root);
|
||||
|
||||
if (
|
||||
isDefined(renderContext.applicationAccessToken) &&
|
||||
isDefined(renderContext.apiUrl)
|
||||
) {
|
||||
setWorkerEnv({
|
||||
TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken,
|
||||
TWENTY_API_URL: renderContext.apiUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(renderContext.componentUrl, {
|
||||
headers: { Authorization: `Bearer ${renderContext.authToken}` },
|
||||
headers: isDefined(renderContext.applicationAccessToken)
|
||||
? { Authorization: `Bearer ${renderContext.applicationAccessToken}` }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { setWorkerEnv } from '../setWorkerEnv';
|
||||
|
||||
describe('setWorkerEnv', () => {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as Record<string, unknown>)['process'];
|
||||
});
|
||||
|
||||
it('should set process.env on globalThis', () => {
|
||||
setWorkerEnv({
|
||||
TWENTY_APP_ACCESS_TOKEN: 'test-key',
|
||||
TWENTY_API_URL: 'https://api.example.com',
|
||||
});
|
||||
|
||||
const processObject = (globalThis as Record<string, unknown>)[
|
||||
'process'
|
||||
] as Record<string, unknown>;
|
||||
const processEnvironment = processObject['env'] as Record<string, string>;
|
||||
|
||||
expect(processEnvironment['TWENTY_APP_ACCESS_TOKEN']).toBe('test-key');
|
||||
expect(processEnvironment['TWENTY_API_URL']).toBe(
|
||||
'https://api.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing process properties and environment values', () => {
|
||||
(globalThis as Record<string, unknown>)['process'] = {
|
||||
env: {
|
||||
EXISTING_VALUE: 'existing',
|
||||
},
|
||||
version: 'test-version',
|
||||
};
|
||||
|
||||
setWorkerEnv({
|
||||
TWENTY_APP_ACCESS_TOKEN: 'test-key',
|
||||
});
|
||||
|
||||
const processObject = (globalThis as Record<string, unknown>)[
|
||||
'process'
|
||||
] as Record<string, unknown>;
|
||||
const processEnvironment = processObject['env'] as Record<string, string>;
|
||||
|
||||
expect(processObject['version']).toBe('test-version');
|
||||
expect(processEnvironment['EXISTING_VALUE']).toBe('existing');
|
||||
expect(processEnvironment['TWENTY_APP_ACCESS_TOKEN']).toBe('test-key');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export const setWorkerEnv = (environmentVariables: Record<string, string>) => {
|
||||
const globalObject = globalThis as Record<string, unknown>;
|
||||
const processObject =
|
||||
(globalObject['process'] as Record<string, unknown> | undefined) ?? {};
|
||||
const processEnvironment =
|
||||
(processObject['env'] as Record<string, string> | undefined) ?? {};
|
||||
|
||||
processObject['env'] = {
|
||||
...processEnvironment,
|
||||
...environmentVariables,
|
||||
};
|
||||
|
||||
globalObject['process'] = processObject;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export type HostToWorkerRenderContext = {
|
||||
componentUrl: string;
|
||||
authToken: string;
|
||||
applicationAccessToken?: string;
|
||||
apiUrl?: string;
|
||||
};
|
||||
|
||||
@@ -103,6 +103,12 @@ export class WorkspaceSchemaFactory {
|
||||
applicationIds,
|
||||
);
|
||||
|
||||
flatObjectMetadataMaps =
|
||||
this.reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
if (isDefined(allFlatIndexMaps)) {
|
||||
flatIndexMaps = this.filterFlatEntityMapsByApplicationIds(
|
||||
allFlatIndexMaps,
|
||||
@@ -188,6 +194,35 @@ export class WorkspaceSchemaFactory {
|
||||
return executableSchema;
|
||||
}
|
||||
|
||||
private reconcileObjectFieldIdsWithFilteredFieldMaps(
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): FlatEntityMaps<FlatObjectMetadata> {
|
||||
const filteredFieldIds = new Set(
|
||||
Object.keys(flatFieldMetadataMaps.universalIdentifierById),
|
||||
);
|
||||
|
||||
const reconciledByUniversalIdentifier: Partial<
|
||||
Record<string, FlatObjectMetadata>
|
||||
> = {};
|
||||
|
||||
for (const [universalId, object] of Object.entries(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (!isDefined(object)) continue;
|
||||
|
||||
reconciledByUniversalIdentifier[universalId] = {
|
||||
...object,
|
||||
fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...flatObjectMetadataMaps,
|
||||
byUniversalIdentifier: reconciledByUniversalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
private filterFlatEntityMapsByApplicationIds<
|
||||
T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata,
|
||||
>(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
|
||||
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
|
||||
@@ -38,6 +39,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationDevelopmentResolver,
|
||||
MarketplaceResolver,
|
||||
ApplicationManifestMigrationService,
|
||||
ApplicationSyncService,
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
|
||||
@ObjectType('ApplicationTokenPair')
|
||||
export class ApplicationTokenPairDTO {
|
||||
@Field(() => AuthToken)
|
||||
applicationAccessToken: AuthToken;
|
||||
|
||||
@Field(() => AuthToken)
|
||||
applicationRefreshToken: AuthToken;
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { DevelopmentGuard } from 'src/engine/guards/development.guard';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
DevelopmentGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
export class ApplicationDevelopmentResolver {
|
||||
constructor(
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => AuthToken)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async generateApplicationToken(
|
||||
@Args() { applicationId }: GenerateApplicationTokenInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<AuthToken> {
|
||||
return this.applicationTokenService.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async syncApplication(
|
||||
@Args() { manifest }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => ApplicationDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async createOneApplication(
|
||||
@Args('input') input: CreateApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return await this.applicationService.create({
|
||||
...input,
|
||||
sourceType: 'local',
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async uploadApplicationFile(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
@Args()
|
||||
{
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
filePath,
|
||||
}: UploadApplicationFileInput,
|
||||
): Promise<FileDTO> {
|
||||
const allowedApplicationFileFolders: FileFolder[] = [
|
||||
FileFolder.BuiltLogicFunction,
|
||||
FileFolder.BuiltFrontComponent,
|
||||
FileFolder.PublicAsset,
|
||||
FileFolder.Source,
|
||||
FileFolder.Dependencies,
|
||||
];
|
||||
|
||||
if (!allowedApplicationFileFolders.includes(fileFolder)) {
|
||||
throw new ApplicationException(
|
||||
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
mimeType: mimetype,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: filePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
-108
@@ -6,13 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
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,52 +15,41 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
|
||||
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
|
||||
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
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';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApplicationResolver {
|
||||
constructor(
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ApplicationDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findManyApplications(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@@ -75,6 +58,7 @@ export class ApplicationResolver {
|
||||
}
|
||||
|
||||
@Query(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async checkApplicationExist(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@@ -90,6 +74,7 @@ export class ApplicationResolver {
|
||||
}
|
||||
|
||||
@Query(() => ApplicationDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findOneApplication(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@@ -104,61 +89,32 @@ export class ApplicationResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => AuthToken)
|
||||
@Mutation(() => ApplicationTokenPairDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async generateApplicationToken(
|
||||
@Args() { applicationId }: GenerateApplicationTokenInput,
|
||||
async renewApplicationToken(
|
||||
@Args('applicationRefreshToken') applicationRefreshToken: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<AuthToken> {
|
||||
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
|
||||
): Promise<ApplicationTokenPairDTO> {
|
||||
const applicationRefreshTokenPayload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
applicationRefreshToken,
|
||||
);
|
||||
|
||||
if (
|
||||
nodeEnv !== NodeEnvironment.DEVELOPMENT &&
|
||||
nodeEnv !== NodeEnvironment.TEST
|
||||
) {
|
||||
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
|
||||
throw new ApplicationException(
|
||||
'This endpoint is only available in development mode',
|
||||
'Refresh token workspace does not match authenticated workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const APPLICATION_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
return this.applicationTokenService.generateApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds: APPLICATION_TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ApplicationDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async createOneApplication(
|
||||
@Args('input') input: CreateApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return await this.applicationService.create({
|
||||
...input,
|
||||
sourceType: 'local',
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async syncApplication(
|
||||
@Args() { manifest }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
});
|
||||
|
||||
return true;
|
||||
return this.applicationTokenService.renewApplicationTokens(
|
||||
applicationRefreshTokenPayload,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installApplication(
|
||||
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
|
||||
@@ -198,6 +154,7 @@ export class ApplicationResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async uninstallApplication(
|
||||
@Args() { universalIdentifier }: UninstallApplicationInput,
|
||||
@@ -210,46 +167,4 @@ export class ApplicationResolver {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async uploadApplicationFile(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
@Args()
|
||||
{
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
filePath,
|
||||
}: UploadApplicationFileInput,
|
||||
): Promise<FileDTO> {
|
||||
const allowedApplicationFileFolders: FileFolder[] = [
|
||||
FileFolder.BuiltLogicFunction,
|
||||
FileFolder.BuiltFrontComponent,
|
||||
FileFolder.PublicAsset,
|
||||
FileFolder.Source,
|
||||
FileFolder.Dependencies,
|
||||
];
|
||||
|
||||
if (!allowedApplicationFileFolders.includes(fileFolder)) {
|
||||
throw new ApplicationException(
|
||||
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
mimeType: mimetype,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: filePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -356,14 +356,14 @@ describe('JwtAuthStrategy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('APPLICATION token validation', () => {
|
||||
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
|
||||
describe('APPLICATION_ACCESS token validation', () => {
|
||||
it('should throw AuthExceptionCode if type is APPLICATION_ACCESS, and application not found', async () => {
|
||||
const validApplicationId = randomUUID();
|
||||
const validWorkspaceId = randomUUID();
|
||||
|
||||
const payload = {
|
||||
sub: validApplicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION,
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
applicationId: validApplicationId,
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import {
|
||||
type AccessTokenJwtPayload,
|
||||
type ApiKeyTokenJwtPayload,
|
||||
ApplicationTokenJwtPayload,
|
||||
ApplicationAccessTokenJwtPayload,
|
||||
type AuthContext,
|
||||
type FileTokenJwtPayload,
|
||||
type JwtPayload,
|
||||
@@ -333,7 +333,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApplicationToken(
|
||||
payload: ApplicationTokenJwtPayload,
|
||||
payload: ApplicationAccessTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.workspaceId,
|
||||
@@ -359,6 +359,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Token carries userId/userWorkspaceId but they are unused.
|
||||
// Compute the intersection of user and application permissions instead.
|
||||
return {
|
||||
application,
|
||||
workspace,
|
||||
@@ -388,7 +390,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
return await this.validateAccessToken(payload);
|
||||
}
|
||||
|
||||
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
|
||||
if (payload.type === JwtTokenTypeEnum.APPLICATION_ACCESS) {
|
||||
return await this.validateApplicationToken(payload);
|
||||
}
|
||||
|
||||
|
||||
+139
-11
@@ -3,11 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
|
||||
describe('ApplicationTokenService', () => {
|
||||
@@ -55,8 +57,8 @@ describe('ApplicationTokenService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateApplicationToken', () => {
|
||||
it('should generate an application token successfully', async () => {
|
||||
describe('generateApplicationAccessToken', () => {
|
||||
it('should generate an application access token successfully', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
@@ -71,7 +73,7 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationToken({
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds: 10,
|
||||
@@ -90,9 +92,11 @@ describe('ApplicationTokenService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing userId successfully', async () => {
|
||||
it('should include optional userWorkspaceId and userId in payload', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const userWorkspaceId = 'user-workspace-id';
|
||||
const userId = 'user-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
const mockApplication = { id: applicationId };
|
||||
const mockToken = 'mock-token';
|
||||
@@ -105,9 +109,11 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationToken({
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds: 10,
|
||||
});
|
||||
|
||||
@@ -119,7 +125,9 @@ describe('ApplicationTokenService', () => {
|
||||
expect.objectContaining({
|
||||
sub: applicationId,
|
||||
applicationId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
@@ -137,7 +145,7 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
|
||||
|
||||
await expect(
|
||||
service.generateApplicationToken({
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'non-existent-application',
|
||||
workspaceId: 'workspace-id',
|
||||
expiresInSeconds: 10,
|
||||
@@ -149,11 +157,131 @@ describe('ApplicationTokenService', () => {
|
||||
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generateApplicationToken({
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'non-existent-workspace',
|
||||
expiresInSeconds: 10,
|
||||
}),
|
||||
).rejects.toThrow(WorkspaceException);
|
||||
});
|
||||
|
||||
describe('validateApplicationRefreshToken', () => {
|
||||
it('should validate and return payload for a valid refresh token', () => {
|
||||
const mockToken = 'valid-refresh-token';
|
||||
const mockPayload = {
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'workspace-id',
|
||||
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = service.validateApplicationRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockPayload);
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when token type is not APPLICATION_REFRESH', () => {
|
||||
const mockToken = 'access-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'workspace-id',
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when token verification fails', () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateApplicationTokenPair', () => {
|
||||
it('should generate both access and refresh tokens', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
const mockApplication = { id: applicationId };
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationTokenPair({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
expect(result.applicationAccessToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(result.applicationRefreshToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewApplicationTokens', () => {
|
||||
it('should generate a new token pair from validated payload', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
const mockApplication = { id: applicationId };
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
|
||||
jest
|
||||
.spyOn(applicationRepository, 'findOne')
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.renewApplicationTokens({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
expect(result.applicationAccessToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(result.applicationRefreshToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+143
-13
@@ -1,7 +1,7 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
import ms from 'ms';
|
||||
@@ -9,20 +9,29 @@ import ms from 'ms';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
ApplicationTokenJwtPayload,
|
||||
type ApplicationAccessTokenJwtPayload,
|
||||
type ApplicationRefreshTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
|
||||
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
|
||||
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTokenService {
|
||||
constructor(
|
||||
@Inject(JwtWrapperService)
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@@ -30,17 +39,113 @@ export class ApplicationTokenService {
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async generateApplicationToken({
|
||||
async generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds,
|
||||
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
|
||||
expiresInSeconds: number;
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): Promise<AuthToken> {
|
||||
const expiresIn = `${expiresInSeconds}s`;
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
return this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
async generateApplicationTokenPair({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
applicationAccessToken: AuthToken;
|
||||
applicationRefreshToken: AuthToken;
|
||||
}> {
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
|
||||
[
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
|
||||
validateApplicationRefreshToken(
|
||||
refreshToken: string,
|
||||
): ApplicationRefreshTokenJwtPayload {
|
||||
this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
|
||||
refreshToken,
|
||||
{ json: true },
|
||||
);
|
||||
|
||||
if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) {
|
||||
throw new AuthException(
|
||||
'Expected an application refresh token',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async renewApplicationTokens(payload: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
applicationAccessToken: AuthToken;
|
||||
applicationRefreshToken: AuthToken;
|
||||
}> {
|
||||
return this.generateApplicationTokenPair({
|
||||
workspaceId: payload.workspaceId,
|
||||
applicationId: payload.applicationId,
|
||||
userWorkspaceId: payload.userWorkspaceId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateWorkspaceAndApplication(
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
});
|
||||
@@ -58,18 +163,43 @@ export class ApplicationTokenService {
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const jwtPayload: ApplicationTokenJwtPayload = {
|
||||
private signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType,
|
||||
expiresInSeconds,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
tokenType:
|
||||
| JwtTokenTypeEnum.APPLICATION_ACCESS
|
||||
| JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
expiresInSeconds: number;
|
||||
}): AuthToken {
|
||||
const expiresIn = `${expiresInSeconds}s`;
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const jwtPayload:
|
||||
| ApplicationAccessTokenJwtPayload
|
||||
| ApplicationRefreshTokenJwtPayload = {
|
||||
sub: applicationId,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
type: JwtTokenTypeEnum.APPLICATION,
|
||||
type: tokenType,
|
||||
...(userWorkspaceId ? { userWorkspaceId } : {}),
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.APPLICATION,
|
||||
tokenType,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
|
||||
@@ -40,7 +40,8 @@ export enum JwtTokenTypeEnum {
|
||||
POSTGRES_PROXY = 'POSTGRES_PROXY',
|
||||
REMOTE_SERVER = 'REMOTE_SERVER',
|
||||
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
|
||||
APPLICATION = 'APPLICATION',
|
||||
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
|
||||
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
|
||||
}
|
||||
|
||||
type CommonPropertiesJwtPayload = {
|
||||
@@ -102,10 +103,20 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION;
|
||||
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
@@ -127,7 +138,8 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
export type JwtPayload =
|
||||
| AccessTokenJwtPayload
|
||||
| ApiKeyTokenJwtPayload
|
||||
| ApplicationTokenJwtPayload
|
||||
| ApplicationAccessTokenJwtPayload
|
||||
| ApplicationRefreshTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
| LoginTokenJwtPayload
|
||||
| TransientTokenJwtPayload
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ export class LogicFunctionExecutorService {
|
||||
flatApplicationVariables: FlatApplicationVariable[];
|
||||
}) {
|
||||
const applicationAccessToken =
|
||||
await this.applicationTokenService.generateApplicationToken({
|
||||
await this.applicationTokenService.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId: flatApplication.id,
|
||||
expiresInSeconds: Math.max(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type CanActivate, Injectable } from '@nestjs/common';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class DevelopmentGuard implements CanActivate {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
canActivate(): boolean {
|
||||
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
|
||||
|
||||
if (
|
||||
nodeEnv !== NodeEnvironment.DEVELOPMENT &&
|
||||
nodeEnv !== NodeEnvironment.TEST
|
||||
) {
|
||||
throw new Error(
|
||||
'This endpoint is only available in development or test environments',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+4
@@ -9,6 +9,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
|
||||
@ObjectType('FrontComponent')
|
||||
export class FrontComponentDTO {
|
||||
@@ -61,4 +62,7 @@ export class FrontComponentDTO {
|
||||
@IsDateString()
|
||||
@Field()
|
||||
updatedAt: Date;
|
||||
|
||||
@Field(() => ApplicationTokenPairDTO, { nullable: true })
|
||||
applicationTokenPair?: ApplicationTokenPairDTO;
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module';
|
||||
import { FrontComponentController } from 'src/engine/metadata-modules/front-component/controllers/front-component.controller';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceMigrationModule,
|
||||
ApplicationModule,
|
||||
TokenModule,
|
||||
PermissionsModule,
|
||||
FlatFrontComponentModule,
|
||||
],
|
||||
|
||||
+36
-7
@@ -1,14 +1,19 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Inject, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
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';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { fromFlatFrontComponentToFrontComponentDto } from 'src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util';
|
||||
import { CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input';
|
||||
@@ -25,7 +30,12 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
|
||||
)
|
||||
@MetadataResolver(() => FrontComponentDTO)
|
||||
export class FrontComponentResolver {
|
||||
constructor(private readonly frontComponentService: FrontComponentService) {}
|
||||
constructor(
|
||||
@Inject(FrontComponentService)
|
||||
private readonly frontComponentService: FrontComponentService,
|
||||
@Inject(ApplicationTokenService)
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
) {}
|
||||
|
||||
@Query(() => [FrontComponentDTO])
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@@ -36,12 +46,31 @@ export class FrontComponentResolver {
|
||||
}
|
||||
|
||||
@Query(() => FrontComponentDTO, { nullable: true })
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@UseGuards(UserAuthGuard, NoPermissionGuard)
|
||||
async frontComponent(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUser() user: UserEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<FrontComponentDTO | null> {
|
||||
return await this.frontComponentService.findById(id, workspace.id);
|
||||
const dto = await this.frontComponentService.findById(id, workspace.id);
|
||||
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenPair =
|
||||
await this.applicationTokenService.generateApplicationTokenPair({
|
||||
applicationId: dto.applicationId,
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
...dto,
|
||||
applicationTokenPair: tokenPair,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => FrontComponentDTO)
|
||||
|
||||
Reference in New Issue
Block a user