diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index 2ff08a9e17..50277ab7aa 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -41,10 +41,7 @@ jobs: cp .env.example .env echo "Generating secrets..." echo "# === Randomly generated secrets ===" >>.env - echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env + echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env echo "Starting server..." diff --git a/install.sh b/install.sh index af3f40a46e..34741b89b9 100755 --- a/install.sh +++ b/install.sh @@ -91,10 +91,7 @@ fi # Generate random strings for secrets echo "# === Randomly generated secrets ===" >>.env -echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env +echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env diff --git a/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md b/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md index c6b38619ec..1249bf27de 100644 --- a/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md +++ b/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md @@ -26,4 +26,5 @@ Your turn ๐Ÿ‘‡ ยป 28-October-2024 by [adityadeshlahre](https://oss.gg/adityadeshlahre) video link: [video](https://youtu.be/65sOHce1gjw) +ยป 30-October-2024 by [harshsbhat](https://oss.gg/harshsbhat) video link: [video](https://x.com/HarshBhatX/status/1851481457761370559) --- diff --git a/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md b/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md index b9805af703..89c15938b6 100644 --- a/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md +++ b/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md @@ -59,4 +59,7 @@ Your turn ๐Ÿ‘‡ ยป 23-October-2024 by Rajeev Dewangan ยป Link to Tweet: https://x.com/rajeevdew/status/1849109074685907374 +ยป 30-October-2024 by Atharva Deshmukh +ยป Link to Tweet: https://x.com/0x_atharva/status/1851503532840566919 + diff --git a/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md b/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md index 598dff8362..be8015fc7f 100644 --- a/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md +++ b/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md @@ -37,3 +37,6 @@ Your turn ๐Ÿ‘‡ ยป 23-October-2024 by Rajeev Dewangan ยป Link to Tweet: https://x.com/rajeevdew/status/1849110473272442991 + +ยป 30-October-2024 byAtharva Deshmukh +ยป Link to Tweet: https://x.com/0x_atharva/status/1851501634662039582 diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index c1a7a9d3ba..b10e09876e 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -8,10 +8,7 @@ REDIS_URL=redis://redis:6379 SERVER_URL=http://localhost:3000 # Use openssl rand -base64 32 for each secret -# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -# LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -# REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh -# FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh +# APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true diff --git a/packages/twenty-docker/docker-compose.yml b/packages/twenty-docker/docker-compose.yml index 8800f4f3f3..41d80dabc3 100644 --- a/packages/twenty-docker/docker-compose.yml +++ b/packages/twenty-docker/docker-compose.yml @@ -35,10 +35,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: change-vol-ownership: condition: service_completed_successfully @@ -67,10 +64,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: db: condition: service_healthy diff --git a/packages/twenty-docker/k8s/manifests/deployment-server.yaml b/packages/twenty-docker/k8s/manifests/deployment-server.yaml index 99e5c60132..8577297886 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-server.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-server.yaml @@ -55,26 +55,11 @@ spec: value: "7d" - name: "LOGIN_TOKEN_EXPIRES_IN" value: "1h" - - name: ACCESS_TOKEN_SECRET + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken ports: - containerPort: 3000 name: http-tcp diff --git a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml index 92d0322e59..eb1938ba6d 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml @@ -42,26 +42,11 @@ spec: value: "redis" - name: "REDIS_URL" value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379" - - name: ACCESS_TOKEN_SECRET + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken command: - yarn - worker:prod diff --git a/packages/twenty-docker/k8s/terraform/deployment-server.tf b/packages/twenty-docker/k8s/terraform/deployment-server.tf index 0f643f5c6d..5276d57431 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-server.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-server.tf @@ -91,7 +91,7 @@ resource "kubernetes_deployment" "twentycrm_server" { value = "1h" } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -100,36 +100,6 @@ resource "kubernetes_deployment" "twentycrm_server" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - port { container_port = 3000 protocol = "TCP" diff --git a/packages/twenty-docker/k8s/terraform/deployment-worker.tf b/packages/twenty-docker/k8s/terraform/deployment-worker.tf index 163f02c497..aa68fd3af2 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-worker.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-worker.tf @@ -78,7 +78,7 @@ resource "kubernetes_deployment" "twentycrm_worker" { } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -87,36 +87,6 @@ resource "kubernetes_deployment" "twentycrm_worker" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - resources { requests = { cpu = "250m" diff --git a/packages/twenty-front/src/modules/activities/files/types/Attachment.ts b/packages/twenty-front/src/modules/activities/files/types/Attachment.ts index e37bcf8f92..20cb73984c 100644 --- a/packages/twenty-front/src/modules/activities/files/types/Attachment.ts +++ b/packages/twenty-front/src/modules/activities/files/types/Attachment.ts @@ -5,7 +5,6 @@ export type Attachment = { type: AttachmentType; companyId: string; personId: string; - activityId: string; authorId: string; createdAt: string; __typename: string; diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx index 23229f5816..10e2351fd5 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx @@ -18,7 +18,6 @@ const mockActivityTarget = { updatedAt: '2021-08-03T19:20:06.000Z', createdAt: '2021-08-03T19:20:06.000Z', personId: '1', - activityId: '234', companyId: '1', id: '123', }; diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx index baddb1029b..8907d6db06 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx @@ -37,7 +37,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx index 814b72fb6c..4f1efa5b01 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx @@ -51,7 +51,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt @@ -138,6 +137,9 @@ const mocks: MockedResponse[] = [ rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts index 2cf9a03c54..9a9a17fd20 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts @@ -317,6 +317,219 @@ export const mocks = [ workspaceMemberId } } + companyId + createdAt + deletedAt + id + note { + __typename + body + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + position + title + updatedAt + } + noteId + opportunity { + __typename + amount { + amountMicros + currencyCode + } + closeDate + companyId + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + name + pointOfContactId + position + stage + updatedAt + } + opportunityId + person { + __typename + avatarUrl + city + companyId + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + emails { + primaryEmail + additionalEmails + } + id + intro + jobTitle + linkedinLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + name { + firstName + lastName + } + performanceRating + phones { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + } + position + updatedAt + whatsapp { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + } + workPreference + xLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + } + personId + position + rocket { + __typename + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + name + position + updatedAt + } + rocketId + task { + __typename + assigneeId + body + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + dueAt + id + position + status + title + updatedAt + } + taskId + updatedAt + view { + __typename + createdAt + deletedAt + icon + id + isCompact + kanbanFieldMetadataId + key + name + objectMetadataId + position + type + updatedAt + } + viewId + workflow { + __typename + createdAt + deletedAt + id + lastPublishedVersionId + name + position + statuses + updatedAt + } + workflowId + workflowRun { + __typename + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + endedAt + id + name + output + position + startedAt + status + updatedAt + workflowId + workflowVersionId + } + workflowRunId + workflowVersion { + __typename + createdAt + deletedAt + id + name + position + status + steps + trigger + updatedAt + workflowId + } + workflowVersionId + workspaceMember { + __typename + avatarUrl + colorScheme + createdAt + dateFormat + deletedAt + id + locale + name { + firstName + lastName + } + timeFormat + timeZone + updatedAt + userEmail + userId + } + workspaceMemberId + } + } `, variables: { input: { @@ -575,6 +788,41 @@ export const mocks = [ updatedAt } workflowId + workflowRun { + __typename + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + endedAt + id + name + output + position + startedAt + status + updatedAt + workflowId + workflowVersionId + } + workflowRunId + workflowVersion { + __typename + createdAt + deletedAt + id + name + position + status + steps + trigger + updatedAt + workflowId + } + workflowVersionId workspaceMember { __typename avatarUrl diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapSoftDeleteFieldsToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapSoftDeleteFieldsToGraphQLQuery.ts index 701e524b56..09c23b5b15 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapSoftDeleteFieldsToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapSoftDeleteFieldsToGraphQLQuery.ts @@ -3,7 +3,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; export const mapSoftDeleteFieldsToGraphQLQuery = ( objectMetadataItem: Pick, ): string => { - const softDeleteFields = ['id', 'deletedAt']; + const softDeleteFields = ['deletedAt', 'id']; const fieldsThatShouldBeQueried = objectMetadataItem.fields.filter( (field) => field.isActive && softDeleteFields.includes(field.name), diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts index 2ca0063fe2..e9193143e1 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts @@ -17,7 +17,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` id intro jobTitle - linkedinLink{ + linkedinLink { primaryLinkUrl primaryLinkLabel secondaryLinks @@ -49,27 +49,10 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` __typename - activityTargets { - edges { - node { - __typename - activityId - companyId - createdAt - deletedAt - id - opportunityId - personId - rocketId - updatedAt - } - } - } attachments { edges { node { __typename - activityId authorId companyId createdAt @@ -190,6 +173,8 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` updatedAt viewId workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -308,6 +293,9 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts index 2e7ce9bc53..b045131236 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts @@ -15,5 +15,7 @@ export const variables = { }; export const responseData = { + __typename: 'Person', + deletedAt: '2024-02-14T09:45:00Z', id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9', }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts index 1ed7b456fc..d250110737 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts @@ -99,20 +99,6 @@ export const query = gql` } city email - activityTargets { - edges { - node { - __typename - id - updatedAt - createdAt - personId - activityId - companyId - id - } - } - } jobTitle favorites { edges { @@ -137,7 +123,6 @@ export const query = gql` createdAt name personId - activityId companyId id authorId diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteOneRecordMutation.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteOneRecordMutation.test.tsx index 859355818a..86ca29d5ab 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteOneRecordMutation.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteOneRecordMutation.test.tsx @@ -8,8 +8,8 @@ const expectedQueryTemplate = ` mutation DeleteOnePerson($idToDelete: ID!) { deletePerson(id: $idToDelete) { __typename - deletedAt id + deletedAt } } `.replace(/\s/g, ''); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx index 8bc95f320a..f33bbf38d7 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx @@ -19,6 +19,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; @@ -139,10 +140,15 @@ export const ObjectFilterDropdownFilterSelect = ({ const { currentViewId, currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + const isAdvancedFiltersEnabled = useIsFeatureEnabled( + 'IS_ADVANCED_FILTERS_ENABLED', + ); + const shouldShowAdvancedFilterButton = isDefined(currentViewId) && isDefined(currentViewWithCombinedFiltersAndSorts?.objectMetadataId) && - isAdvancedFilterButtonVisible; + isAdvancedFilterButtonVisible && + isAdvancedFiltersEnabled; return ( <> diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx index 5ec442483d..7f3695a35d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx @@ -47,22 +47,6 @@ const mocks: MockedResponse[] = [ userId } accountOwnerId - activityTargets { - edges { - node { - __typename - activityId - companyId - createdAt - deletedAt - id - opportunityId - personId - rocketId - updatedAt - } - } - } address { addressStreet1 addressStreet2 @@ -81,7 +65,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt @@ -129,6 +112,8 @@ const mocks: MockedResponse[] = [ updatedAt viewId workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -278,6 +263,9 @@ const mocks: MockedResponse[] = [ rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts index 0eacdbb3d5..25660a9f98 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts @@ -46,22 +46,6 @@ const companyMocks = [ userId } accountOwnerId - activityTargets { - edges { - node { - __typename - activityId - companyId - createdAt - deletedAt - id - opportunityId - personId - rocketId - updatedAt - } - } - } address { addressStreet1 addressStreet2 @@ -80,7 +64,6 @@ const companyMocks = [ edges { node { __typename - activityId authorId companyId createdAt diff --git a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts index 28c49a9dda..6f25feba82 100644 --- a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts @@ -88,19 +88,6 @@ export const query = gql` } city email - activityTargets { - edges { - node { - id - updatedAt - createdAt - personId - activityId - companyId - id - } - } - } jobTitle favorites { edges { @@ -124,7 +111,6 @@ export const query = gql` createdAt name personId - activityId companyId id authorId diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts index 085e9a829d..2023b4d0e1 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts +++ b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts @@ -88,35 +88,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: null, __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [ - { - __typename: 'ActivityTargetEdge', - node: { - __typename: 'ActivityTarget', - id: '97114d7e-2a80-4401-af58-36c88e13e852', - activityId: '737a6c31-610a-457b-b087-791ac700fa46', - createdAt: '2023-11-24T13:15:03.523Z', - updatedAt: '2023-11-24T13:15:03.523Z', - companyId: '04b2e9f5-0713-40a5-8216-82802401d33e', - personId: null, - }, - }, - { - __typename: 'ActivityTargetEdge', - node: { - __typename: 'ActivityTarget', - id: 'cb29d37a-3d5e-4efb-afa3-38f4bff69912', - activityId: '3c6ea4a3-f71d-4c31-9dfa-f868a5de4091', - createdAt: '2023-11-24T13:14:57.628Z', - updatedAt: '2023-11-24T13:14:57.628Z', - companyId: '04b2e9f5-0713-40a5-8216-82802401d33e', - personId: null, - }, - }, - ], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -339,10 +310,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -489,10 +456,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -554,10 +517,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -755,10 +714,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -820,10 +775,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -885,10 +836,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -950,10 +897,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1048,10 +991,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1146,10 +1085,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1211,10 +1146,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1276,10 +1207,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1426,10 +1353,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 5573956fdd..60c7579ac4 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -15,4 +15,5 @@ export type FeatureFlagKey = | 'IS_ANALYTICS_V2_ENABLED' | 'IS_SSO_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' - | 'IS_ARRAY_AND_JSON_FILTER_ENABLED'; + | 'IS_ARRAY_AND_JSON_FILTER_ENABLED' + | 'IS_ADVANCED_FILTERS_ENABLED'; diff --git a/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx b/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx index 31d04e0c1e..063a1cca4c 100644 --- a/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx +++ b/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx @@ -40,21 +40,6 @@ const meta: Meta = { }, }); }), - graphql.query('FindManyActivityTargets', () => { - return HttpResponse.json({ - data: { - activityTargets: { - edges: [], - pageInfo: { - hasNextPage: false, - startCursor: '', - endCursor: '', - }, - totalCount: 0, - }, - }, - }); - }), graphql.query('FindOneworkspaceMember', () => { return HttpResponse.json({ data: { diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index d634890a5f..dfa741f9e1 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -259,9 +259,13 @@ export const graphqlMocks = { edges: [], __typename: 'OpportunityConnection', }, - activityTargets: { + taskTargets: { edges: [], - __typename: 'ActivityTargetConnection', + __typename: 'TaskTargetConnection', + }, + noteTargets: { + edges: [], + __typename: 'NoteTargetConnection', }, }, cursor: null, @@ -301,9 +305,13 @@ export const graphqlMocks = { edges: [], __typename: 'OpportunityConnection', }, - activityTargets: { + taskTargets: { edges: [], - __typename: 'ActivityTargetConnection', + __typename: 'TaskTargetConnection', + }, + noteTargets: { + edges: [], + __typename: 'NoteTargetConnection', }, }, cursor: null, diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 0ea9a607a9..423239557e 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -1,14 +1,11 @@ # Use this for local setup PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default +REDIS_URL=redis://localhost:6379 FRONT_BASE_URL=http://localhost:3001 -ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh -FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh +APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true -REDIS_URL=redis://localhost:6379 # โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€” Optional โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€” diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index e768984fbd..659411dc21 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -1,11 +1,10 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test +REDIS_URL=redis://localhost:6379 DEBUG_MODE=true DEBUG_PORT=9000 FRONT_BASE_URL=http://localhost:3001 -ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh +APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true EXCEPTION_HANDLER_DRIVER=console SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944 @@ -13,7 +12,6 @@ DEMO_WORKSPACE_IDS=63db4589-590f-42b3-bdf1-85268b3da02f,8de58f3f-7e86-4a0b-998d- MUTATION_MAXIMUM_RECORD_AFFECTED=100 MESSAGE_QUEUE_TYPE=bull-mq CACHE_STORAGE_TYPE=redis -REDIS_URL=redis://localhost:6379 AUTH_GOOGLE_ENABLED=false MESSAGING_PROVIDER_GMAIL_ENABLED=false diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index deb2ba3ee5..9dc26ba518 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -30,7 +30,7 @@ const jestConfig: JestConfigWithTsJest = { globals: { APP_PORT: 4000, ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ.zM6TbfeOqYVH5Sgryc2zf02hd9uqUOSL1-iJlMgwzsI', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ._ISjY_dlVWskeQ6wkE0-kOn641G_mee5GiqoZTQFIfE', }, }; diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index b3d984156e..0313ed5dc9 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -75,6 +75,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IsAdvancedFiltersEnabled, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 73466982d0..70138d055c 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -9,11 +9,11 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; -import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 5725313081..5449caba7a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -14,7 +14,6 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -36,7 +35,6 @@ export class GraphQLConfigService implements GqlOptionsFactory> { constructor( - private readonly tokenService: TokenService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly environmentService: EnvironmentService, private readonly moduleRef: ModuleRef, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts index aa6da51f4e..f8dceb0921 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts @@ -35,8 +35,8 @@ export class ActivityQueryResultGetterHandler imageUrl.searchParams.delete('token'); const signedPayload = await this.fileService.encodeFileToken({ - note_block_id: block.id, - workspace_id: workspaceId, + noteBlockId: block.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts index d6e642ba66..794657c7d2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class AttachmentQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - attachment_id: attachment.id, - workspace_id: workspaceId, + attachmentId: attachment.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts index 4b6dfb114a..50bedd2bbc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class PersonQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - person_id: person.id, - workspace_id: workspaceId, + personId: person.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts index 34a94f833b..46808713fc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class WorkspaceMemberQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: workspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index 43c9b0198b..5f0d63966b 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { Query } from 'src/engine/api/rest/core/types/query.type'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -39,7 +39,7 @@ export class CoreQueryBuilderFactory { private readonly getVariablesFactory: GetVariablesFactory, private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory, private readonly objectMetadataService: ObjectMetadataService, - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly environmentService: EnvironmentService, ) {} @@ -50,7 +50,7 @@ export class CoreQueryBuilderFactory { objectMetadataItems: ObjectMetadataEntity[]; objectMetadataItem: ObjectMetadataEntity; }> { - const { workspace } = await this.tokenService.validateToken(request); + const { workspace } = await this.accessTokenService.validateToken(request); const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index 29f1178948..c53f827838 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -7,18 +7,18 @@ import { GraphqlApiType, RestApiService, } from 'src/engine/api/rest/rest-api.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; @Injectable() export class RestApiMetadataService { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, private readonly restApiService: RestApiService, ) {} async get(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.get(request); return await this.restApiService.call( @@ -29,7 +29,7 @@ export class RestApiMetadataService { } async create(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.create(request); return await this.restApiService.call( @@ -40,7 +40,7 @@ export class RestApiMetadataService { } async update(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.update(request); return await this.restApiService.call( @@ -51,7 +51,7 @@ export class RestApiMetadataService { } async delete(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.delete(request); return await this.restApiService.call( diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 6a102e8b36..9386c26690 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -9,31 +9,37 @@ import { AppTokenService } from 'src/engine/core-modules/app-token/services/app- import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller'; import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; +import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; +import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; +import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; +import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; +import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; -import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; -import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; -import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; -import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; -import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { AuthResolver } from './auth.resolver'; @@ -83,10 +89,16 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; JwtAuthStrategy, SamlAuthStrategy, AuthResolver, - TokenService, GoogleAPIsService, AppTokenService, + AccessTokenService, + LoginTokenService, + ResetPasswordService, + SwitchWorkspaceService, + TransientTokenService, + ApiKeyService, + OAuthService, ], - exports: [TokenService], + exports: [AccessTokenService, LoginTokenService], }) export class AuthModule {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 2d08a6b4ea..877cb8c049 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -10,8 +10,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthResolver } from './auth.resolver'; +import { ApiKeyService } from './services/api-key.service'; import { AuthService } from './services/auth.service'; -import { TokenService } from './token/services/token.service'; +import { OAuthService } from './services/oauth.service'; +import { ResetPasswordService } from './services/reset-password.service'; +import { SwitchWorkspaceService } from './services/switch-workspace.service'; +import { LoginTokenService } from './token/services/login-token.service'; +import { RenewTokenService } from './token/services/renew-token.service'; +import { TransientTokenService } from './token/services/transient-token.service'; describe('AuthResolver', () => { let resolver: AuthResolver; @@ -33,10 +39,6 @@ describe('AuthResolver', () => { provide: AuthService, useValue: {}, }, - { - provide: TokenService, - useValue: {}, - }, { provide: UserService, useValue: {}, @@ -45,6 +47,34 @@ describe('AuthResolver', () => { provide: UserWorkspaceService, useValue: {}, }, + { + provide: RenewTokenService, + useValue: {}, + }, + { + provide: ApiKeyService, + useValue: {}, + }, + { + provide: ResetPasswordService, + useValue: {}, + }, + { + provide: LoginTokenService, + useValue: {}, + }, + { + provide: SwitchWorkspaceService, + useValue: {}, + }, + { + provide: TransientTokenService, + useValue: {}, + }, + { + provide: OAuthService, + useValue: {}, + }, ], }) .overrideGuard(CaptchaGuard) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 2e470589ef..d819bc84c5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -10,12 +10,24 @@ import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/em import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; +import { + GenerateJWTOutput, + GenerateJWTOutputWithAuthTokens, + GenerateJWTOutputWithSSOAUTH, +} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; +import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; +import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; +import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -24,11 +36,6 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { - GenerateJWTOutput, - GenerateJWTOutputWithAuthTokens, - GenerateJWTOutputWithSSOAUTH, -} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -42,15 +49,20 @@ import { VerifyInput } from './dto/verify.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; -import { TokenService } from './token/services/token.service'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter) export class AuthResolver { constructor( private authService: AuthService, - private tokenService: TokenService, + private renewTokenService: RenewTokenService, private userService: UserService, + private apiKeyService: ApiKeyService, + private resetPasswordService: ResetPasswordService, + private loginTokenService: LoginTokenService, + private switchWorkspaceService: SwitchWorkspaceService, + private transientTokenService: TransientTokenService, + private oauthService: OAuthService, ) {} @UseGuards(CaptchaGuard) @@ -87,7 +99,9 @@ export class AuthResolver { @Mutation(() => LoginToken) async challenge(@Args() challengeInput: ChallengeInput): Promise { const user = await this.authService.challenge(challengeInput); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return { loginToken }; } @@ -100,7 +114,9 @@ export class AuthResolver { fromSSO: false, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return { loginToken }; } @@ -109,7 +125,7 @@ export class AuthResolver { async exchangeAuthorizationCode( @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, ) { - const tokens = await this.tokenService.verifyAuthorizationCode( + const tokens = await this.oauthService.verifyAuthorizationCode( exchangeAuthCodeInput, ); @@ -130,18 +146,19 @@ export class AuthResolver { if (!workspaceMember) { return; } - const transientToken = await this.tokenService.generateTransientToken( - workspaceMember.id, - user.id, - user.defaultWorkspaceId, - ); + const transientToken = + await this.transientTokenService.generateTransientToken( + workspaceMember.id, + user.id, + user.defaultWorkspaceId, + ); return { transientToken }; } @Mutation(() => Verify) async verify(@Args() verifyInput: VerifyInput): Promise { - const email = await this.tokenService.verifyLoginToken( + const email = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); @@ -170,7 +187,7 @@ export class AuthResolver { @AuthUser() user: User, @Args() args: GenerateJwtInput, ): Promise { - const result = await this.tokenService.switchWorkspace( + const result = await this.switchWorkspaceService.switchWorkspace( user, args.workspaceId, ); @@ -194,16 +211,17 @@ export class AuthResolver { return { success: true, reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH', - authTokens: await this.tokenService.generateSwitchWorkspaceToken( - user, - result.workspace, - ), + authTokens: + await this.switchWorkspaceService.generateSwitchWorkspaceToken( + user, + result.workspace, + ), }; } @Mutation(() => AuthTokens) async renewToken(@Args() args: AppTokenInput): Promise { - const tokens = await this.tokenService.generateTokensFromRefreshToken( + const tokens = await this.renewTokenService.generateTokensFromRefreshToken( args.appToken, ); @@ -225,7 +243,7 @@ export class AuthResolver { @Args() args: ApiKeyTokenInput, @AuthWorkspace() { id: workspaceId }: Workspace, ): Promise { - return await this.tokenService.generateApiKeyToken( + return await this.apiKeyService.generateApiKeyToken( workspaceId, args.apiKeyId, args.expiresAt, @@ -236,11 +254,12 @@ export class AuthResolver { async emailPasswordResetLink( @Args() emailPasswordResetInput: EmailPasswordResetLinkInput, ): Promise { - const resetToken = await this.tokenService.generatePasswordResetToken( - emailPasswordResetInput.email, - ); + const resetToken = + await this.resetPasswordService.generatePasswordResetToken( + emailPasswordResetInput.email, + ); - return await this.tokenService.sendEmailPasswordResetLink( + return await this.resetPasswordService.sendEmailPasswordResetLink( resetToken, emailPasswordResetInput.email, ); @@ -252,18 +271,20 @@ export class AuthResolver { { passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput, ): Promise { const { id } = - await this.tokenService.validatePasswordResetToken(passwordResetToken); + await this.resetPasswordService.validatePasswordResetToken( + passwordResetToken, + ); await this.authService.updatePassword(id, newPassword); - return await this.tokenService.invalidatePasswordResetToken(id); + return await this.resetPasswordService.invalidatePasswordResetToken(id); } @Query(() => ValidatePasswordResetToken) async validatePasswordResetToken( @Args() args: ValidatePasswordResetTokenInput, ): Promise { - return this.tokenService.validatePasswordResetToken( + return this.resetPasswordService.validatePasswordResetToken( args.passwordResetToken, ); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 615d4c6071..13d6d83dc6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -17,7 +17,7 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard'; import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -27,7 +27,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding export class GoogleAPIsAuthController { constructor( private readonly googleAPIsService: GoogleAPIsService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, private readonly onboardingService: OnboardingService, ) {} @@ -58,7 +58,7 @@ export class GoogleAPIsAuthController { } = user; const { workspaceMemberId, userId, workspaceId } = - await this.tokenService.verifyTransientToken(transientToken); + await this.transientTokenService.verifyTransientToken(transientToken); const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS'); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index c674569d43..f12e282373 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -15,13 +15,13 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) export class GoogleAuthController { constructor( - private readonly tokenService: TokenService, + private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, ) {} @@ -55,8 +55,10 @@ export class GoogleAuthController { fromSSO: true, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); - return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); + return res.redirect(this.authService.computeRedirectURI(loginToken.token)); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 49fa5384b3..fdfd319fff 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -9,20 +9,18 @@ import { import { Response } from 'express'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard'; import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) export class MicrosoftAuthController { constructor( - private readonly tokenService: TokenService, - private readonly typeORMService: TypeORMService, + private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, ) {} @@ -58,8 +56,10 @@ export class MicrosoftAuthController { fromSSO: true, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); - return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); + return res.redirect(this.authService.computeRedirectURI(loginToken.token)); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 18b9dbb4d6..0224defaab 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -24,7 +24,7 @@ import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.gua import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard'; import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { @@ -38,7 +38,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in @UseFilters(AuthRestApiExceptionFilter) export class SSOAuthController { constructor( - private readonly tokenService: TokenService, + private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly environmentService: EnvironmentService, @@ -84,7 +84,7 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.tokenService.computeRedirectURI(loginToken.token), + this.authService.computeRedirectURI(loginToken.token), ); } catch (err) { // TODO: improve error management @@ -99,7 +99,7 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.tokenService.computeRedirectURI(loginToken.token), + this.authService.computeRedirectURI(loginToken.token), ); } catch (err) { // TODO: improve error management @@ -156,6 +156,6 @@ export class SSOAuthController { ); } - return this.tokenService.generateLoginToken(user.email); + return this.loginTokenService.generateLoginToken(user.email); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts index a73754fbdd..11dfd40b6d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { VerifyAuthController } from './verify-auth.controller'; @@ -17,7 +17,7 @@ describe('VerifyAuthController', () => { useValue: {}, }, { - provide: TokenService, + provide: LoginTokenService, useValue: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts index 25a52dc3b2..9fcfbb0cf9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts @@ -4,19 +4,19 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @Controller('auth/verify') @UseFilters(AuthRestApiExceptionFilter) export class VerifyAuthController { constructor( private readonly authService: AuthService, - private readonly tokenService: TokenService, + private readonly loginTokenService: LoginTokenService, ) {} @Post() async verify(@Body() verifyInput: VerifyInput): Promise { - const email = await this.tokenService.verifyLoginToken( + const email = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); const result = await this.authService.verify(email); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts index 08baa4ff5a..8766d7d174 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -6,11 +6,11 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( @@ -19,7 +19,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( constructor( private readonly environmentService: EnvironmentService, private readonly featureFlagService: FeatureFlagService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, ) { super(); } @@ -27,9 +27,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const state = JSON.parse(request.query.state); - const { workspaceId } = await this.tokenService.verifyTransientToken( - state.transientToken, - ); + const { workspaceId } = + await this.transientTokenService.verifyTransientToken( + state.transientToken, + ); const isGmailSendEmailScopeEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IsGmailSendEmailScopeEnabled, diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts index 9b0e8f2606..5ba00c3d9b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -6,18 +6,18 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { constructor( private readonly environmentService: EnvironmentService, private readonly featureFlagService: FeatureFlagService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, ) { super({ prompt: 'select_account', @@ -27,9 +27,10 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); - const { workspaceId } = await this.tokenService.verifyTransientToken( - request.query.transientToken, - ); + const { workspaceId } = + await this.transientTokenService.verifyTransientToken( + request.query.transientToken, + ); const isGmailSendEmailScopeEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IsGmailSendEmailScopeEnabled, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts new file mode 100644 index 0000000000..2028da34e7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { ApiKeyService } from './api-key.service'; + +describe('ApiKeyService', () => { + let service: ApiKeyService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ApiKeyService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateApiKeyToken', () => { + it('should return undefined if apiKeyId is not provided', async () => { + const result = await service.generateApiKeyToken('workspace-id'); + + expect(result).toBeUndefined(); + }); + + it('should generate an API key token successfully', async () => { + const workspaceId = 'workspace-id'; + const apiKeyId = 'api-key-id'; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mocked-secret'); + + const result = await service.generateApiKeyToken(workspaceId, apiKeyId); + + expect(result).toEqual({ token: mockToken }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: workspaceId }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: '1h', + jwtid: apiKeyId, + }), + ); + }); + + it('should use custom expiration time if provided', async () => { + const workspaceId = 'workspace-id'; + const apiKeyId = 'api-key-id'; + const expiresAt = new Date(Date.now() + 3600000); // 1 hour from now + const mockToken = 'mock-token'; + + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mocked-secret'); + + await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt); + + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: workspaceId }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: expect.any(Number), + jwtid: apiKeyId, + }), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts new file mode 100644 index 0000000000..288a6aa052 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; + +import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class ApiKeyService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateApiKeyToken( + workspaceId: string, + apiKeyId?: string, + expiresAt?: Date | string, + ): Promise | undefined> { + if (!apiKeyId) { + return; + } + const jwtPayload = { + sub: workspaceId, + }; + const secret = this.jwtWrapperService.generateAppSecret( + 'ACCESS', + workspaceId, + ); + let expiresIn: string | number; + + if (expiresAt) { + expiresIn = Math.floor( + (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, + ); + } else { + expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN'); + } + const token = this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + jwtid: apiKeyId, + }); + + return { token }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index f52023891f..5d7ebc9a4e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -3,13 +3,12 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { AuthService } from './auth.service'; @@ -20,22 +19,6 @@ describe('AuthService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { - provide: TokenService, - useValue: {}, - }, - { - provide: UserService, - useValue: {}, - }, - { - provide: SignInUpService, - useValue: {}, - }, - { - provide: WorkspaceManagerService, - useValue: {}, - }, { provide: getRepositoryToken(Workspace, 'core'), useValue: {}, @@ -48,6 +31,10 @@ describe('AuthService', () => { provide: getRepositoryToken(AppToken, 'core'), useValue: {}, }, + { + provide: SignInUpService, + useValue: {}, + }, { provide: EnvironmentService, useValue: {}, @@ -56,6 +43,14 @@ describe('AuthService', () => { provide: EmailService, useValue: {}, }, + { + provide: AccessTokenService, + useValue: {}, + }, + { + provide: RefreshTokenService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 0092499d2b..8a99656954 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -32,7 +32,8 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity' import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -41,7 +42,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class AuthService { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -150,8 +152,14 @@ export class AuthService { // passwordHash is hidden for security reasons user.passwordHash = ''; - const accessToken = await this.tokenService.generateAccessToken(user.id); - const refreshToken = await this.tokenService.generateRefreshToken(user.id); + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); return { user, @@ -209,8 +217,14 @@ export class AuthService { ); } - const accessToken = await this.tokenService.generateAccessToken(user.id); - const refreshToken = await this.tokenService.generateRefreshToken(user.id); + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); return { user, @@ -384,4 +398,10 @@ export class AuthService { return workspace; } + + computeRedirectURI(loginToken: string): string { + return `${this.environmentService.get( + 'FRONT_BASE_URL', + )}/verify?loginToken=${loginToken}`; + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts new file mode 100644 index 0000000000..83f7b0ff7b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; +import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class OAuthService { + constructor( + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + private readonly loginTokenService: LoginTokenService, + ) {} + + async verifyAuthorizationCode( + exchangeAuthCodeInput: ExchangeAuthCodeInput, + ): Promise { + const { authorizationCode, codeVerifier } = exchangeAuthCodeInput; + + if (!authorizationCode) { + throw new AuthException( + 'Authorization code not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + let userId = ''; + + if (codeVerifier) { + const authorizationCodeAppToken = await this.appTokenRepository.findOne({ + where: { + value: authorizationCode, + }, + }); + + if (!authorizationCodeAppToken) { + throw new AuthException( + 'Authorization code does not exist', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) { + throw new AuthException( + 'Authorization code expired.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest() + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + const codeChallengeAppToken = await this.appTokenRepository.findOne({ + where: { + value: codeChallenge, + }, + }); + + if (!codeChallengeAppToken || !codeChallengeAppToken.userId) { + throw new AuthException( + 'code verifier doesnt match the challenge', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) { + throw new AuthException( + 'code challenge expired.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) { + throw new AuthException( + 'authorization code / code verifier was not created by same client', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (codeChallengeAppToken.revokedAt) { + throw new AuthException( + 'Token has been revoked.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + await this.appTokenRepository.save({ + id: codeChallengeAppToken.id, + revokedAt: new Date(), + }); + + userId = codeChallengeAppToken.userId; + } + + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['defaultWorkspace'], + }); + + if (!user) { + throw new AuthException( + 'User who generated the token does not exist', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!user.defaultWorkspace) { + throw new AuthException( + 'User does not have a default workspace', + AuthExceptionCode.INVALID_DATA, + ); + } + + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); + + return { + accessToken, + refreshToken, + loginToken, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts new file mode 100644 index 0000000000..e0b81f69d4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -0,0 +1,217 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import { Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { ResetPasswordService } from './reset-password.service'; + +describe('ResetPasswordService', () => { + let service: ResetPasswordService; + let userRepository: Repository; + let appTokenRepository: Repository; + let emailService: EmailService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResetPasswordService, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: EmailService, + useValue: { + send: jest.fn().mockResolvedValue({ success: true }), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ResetPasswordService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + emailService = module.get(EmailService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generatePasswordResetToken', () => { + it('should generate a password reset token for a valid user', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken); + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + + const result = + await service.generatePasswordResetToken('test@example.com'); + + expect(result.passwordResetToken).toBeDefined(); + expect(result.passwordResetTokenExpiresAt).toBeDefined(); + expect(appTokenRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: '1', + type: AppTokenType.PasswordResetToken, + }), + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.generatePasswordResetToken('nonexistent@example.com'), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if a token already exists', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockExistingToken = { + userId: '1', + type: AppTokenType.PasswordResetToken, + expiresAt: addMilliseconds(new Date(), 3600000), + }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest + .spyOn(appTokenRepository, 'findOne') + .mockResolvedValue(mockExistingToken as AppToken); + + await expect( + service.generatePasswordResetToken('test@example.com'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('sendEmailPasswordResetLink', () => { + it('should send a password reset email', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockToken = { + passwordResetToken: 'token123', + passwordResetTokenExpiresAt: new Date(), + }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest + .spyOn(environmentService, 'get') + .mockReturnValue('http://localhost:3000'); + + const result = await service.sendEmailPasswordResetLink( + mockToken, + 'test@example.com', + ); + + expect(result.success).toBe(true); + expect(emailService.send).toHaveBeenCalled(); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.sendEmailPasswordResetLink( + {} as any, + 'nonexistent@example.com', + ), + ).rejects.toThrow(AuthException); + }); + }); + + describe('validatePasswordResetToken', () => { + it('should validate a correct password reset token', async () => { + const mockToken = { + userId: '1', + type: AppTokenType.PasswordResetToken, + expiresAt: addMilliseconds(new Date(), 3600000), + }; + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(appTokenRepository, 'findOne') + .mockResolvedValue(mockToken as AppToken); + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + + const result = await service.validatePasswordResetToken('validToken'); + + expect(result).toEqual({ id: '1', email: 'test@example.com' }); + }); + + it('should throw an error for an invalid token', async () => { + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.validatePasswordResetToken('invalidToken'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('invalidatePasswordResetToken', () => { + it('should invalidate an existing password reset token', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); + + const result = await service.invalidatePasswordResetToken('1'); + + expect(result.success).toBe(true); + expect(appTokenRepository.update).toHaveBeenCalledWith( + { userId: '1', type: AppTokenType.PasswordResetToken }, + { revokedAt: expect.any(Date) }, + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.invalidatePasswordResetToken('nonexistent'), + ).rejects.toThrow(AuthException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts new file mode 100644 index 0000000000..f07c45d7d6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -0,0 +1,224 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { render } from '@react-email/render'; +import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { PasswordResetLinkEmail } from 'twenty-emails'; +import { IsNull, MoreThan, Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; +import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; +import { PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class ResetPasswordService { + constructor( + private readonly environmentService: EnvironmentService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + private readonly emailService: EmailService, + ) {} + + async generatePasswordResetToken(email: string): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const expiresIn = this.environmentService.get( + 'PASSWORD_RESET_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const existingToken = await this.appTokenRepository.findOne({ + where: { + userId: user.id, + type: AppTokenType.PasswordResetToken, + expiresAt: MoreThan(new Date()), + revokedAt: IsNull(), + }, + }); + + if (existingToken) { + const timeToWait = ms( + differenceInMilliseconds(existingToken.expiresAt, new Date()), + { long: true }, + ); + + throw new AuthException( + `Token has already been generated. Please wait for ${timeToWait} to generate again.`, + AuthExceptionCode.INVALID_INPUT, + ); + } + + const plainResetToken = crypto.randomBytes(32).toString('hex'); + const hashedResetToken = crypto + .createHash('sha256') + .update(plainResetToken) + .digest('hex'); + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + await this.appTokenRepository.save({ + userId: user.id, + value: hashedResetToken, + expiresAt, + type: AppTokenType.PasswordResetToken, + }); + + return { + passwordResetToken: plainResetToken, + passwordResetTokenExpiresAt: expiresAt, + }; + } + + async sendEmailPasswordResetLink( + resetToken: PasswordResetToken, + email: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); + const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; + + const emailData = { + link: resetLink, + duration: ms( + differenceInMilliseconds( + resetToken.passwordResetTokenExpiresAt, + new Date(), + ), + { + long: true, + }, + ), + }; + + const emailTemplate = PasswordResetLinkEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + + const text = render(emailTemplate, { + plainText: true, + }); + + this.emailService.send({ + from: `${this.environmentService.get( + 'EMAIL_FROM_NAME', + )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to: email, + subject: 'Action Needed to Reset Password', + text, + html, + }); + + return { success: true }; + } + + async validatePasswordResetToken( + resetToken: string, + ): Promise { + const hashedResetToken = crypto + .createHash('sha256') + .update(resetToken) + .digest('hex'); + + const token = await this.appTokenRepository.findOne({ + where: { + value: hashedResetToken, + type: AppTokenType.PasswordResetToken, + expiresAt: MoreThan(new Date()), + revokedAt: IsNull(), + }, + }); + + if (!token || !token.userId) { + throw new AuthException( + 'Token is invalid', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const user = await this.userRepository.findOneBy({ + id: token.userId, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + return { + id: user.id, + email: user.email, + }; + } + + async invalidatePasswordResetToken( + userId: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + id: userId, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + await this.appTokenRepository.update( + { + userId, + type: AppTokenType.PasswordResetToken, + }, + { + revokedAt: new Date(), + }, + ); + + return { success: true }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts new file mode 100644 index 0000000000..e2981b2f28 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts @@ -0,0 +1,217 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { SwitchWorkspaceService } from './switch-workspace.service'; + +describe('SwitchWorkspaceService', () => { + let service: SwitchWorkspaceService; + let userRepository: Repository; + let workspaceRepository: Repository; + let ssoService: SSOService; + let accessTokenService: AccessTokenService; + let refreshTokenService: RefreshTokenService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SwitchWorkspaceService, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: SSOService, + useValue: { + listSSOIdentityProvidersByWorkspaceId: jest.fn(), + }, + }, + { + provide: AccessTokenService, + useValue: { + generateAccessToken: jest.fn(), + }, + }, + { + provide: RefreshTokenService, + useValue: { + generateRefreshToken: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SwitchWorkspaceService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + workspaceRepository = module.get>( + getRepositoryToken(Workspace, 'core'), + ); + ssoService = module.get(SSOService); + accessTokenService = module.get(AccessTokenService); + refreshTokenService = module.get(RefreshTokenService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('switchWorkspace', () => { + it('should throw an error if user does not exist', async () => { + jest.spyOn(userRepository, 'findBy').mockResolvedValue([]); + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.switchWorkspace( + { id: 'non-existent-user' } as User, + 'workspace-id', + ), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if workspace does not exist', async () => { + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([{ id: 'user-id' } as User]); + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.switchWorkspace( + { id: 'user-id' } as User, + 'non-existent-workspace', + ), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if user does not belong to workspace', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'other-user-id' }], + workspaceSSOIdentityProviders: [], + }; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + + await expect( + service.switchWorkspace(mockUser as User, 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + + it('should return SSO auth info if workspace has SSO providers', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'user-id' }], + workspaceSSOIdentityProviders: [{}], + }; + const mockSSOProviders = [{ id: 'sso-provider-id' }]; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + jest + .spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId') + .mockResolvedValue(mockSSOProviders as any); + + const result = await service.switchWorkspace( + mockUser as User, + 'workspace-id', + ); + + expect(result).toEqual({ + useSSOAuth: true, + workspace: mockWorkspace, + availableSSOIdentityProviders: mockSSOProviders, + }); + }); + + it('should return workspace info if workspace does not have SSO providers', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'user-id' }], + workspaceSSOIdentityProviders: [], + }; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + + const result = await service.switchWorkspace( + mockUser as User, + 'workspace-id', + ); + + expect(result).toEqual({ + useSSOAuth: false, + workspace: mockWorkspace, + }); + }); + }); + + describe('generateSwitchWorkspaceToken', () => { + it('should generate and return auth tokens', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { id: 'workspace-id' }; + const mockAccessToken = { token: 'access-token', expiresAt: new Date() }; + const mockRefreshToken = 'refresh-token'; + + jest.spyOn(userRepository, 'save').mockResolvedValue({} as User); + jest + .spyOn(accessTokenService, 'generateAccessToken') + .mockResolvedValue(mockAccessToken); + jest + .spyOn(refreshTokenService, 'generateRefreshToken') + .mockResolvedValue(mockRefreshToken as any); + + const result = await service.generateSwitchWorkspaceToken( + mockUser as User, + mockWorkspace as Workspace, + ); + + expect(result).toEqual({ + tokens: { + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + }, + }); + expect(userRepository.save).toHaveBeenCalledWith({ + id: mockUser.id, + defaultWorkspace: mockWorkspace, + }); + expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspace.id, + ); + expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspace.id, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts new file mode 100644 index 0000000000..88dce7ce76 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Injectable() +export class SwitchWorkspaceService { + constructor( + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly ssoService: SSOService, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + async switchWorkspace(user: User, workspaceId: string) { + const userExists = await this.userRepository.findBy({ id: user.id }); + + if (!userExists) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], + }); + + if (!workspace) { + throw new AuthException( + 'workspace doesnt exist', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if ( + !workspace.workspaceUsers + .map((userWorkspace) => userWorkspace.userId) + .includes(user.id) + ) { + throw new AuthException( + 'user does not belong to workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (workspace.workspaceSSOIdentityProviders.length > 0) { + return { + useSSOAuth: true, + workspace, + availableSSOIdentityProviders: + await this.ssoService.listSSOIdentityProvidersByWorkspaceId( + workspaceId, + ), + } as { + useSSOAuth: true; + workspace: Workspace; + availableSSOIdentityProviders: Awaited< + ReturnType< + typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId + > + >; + }; + } + + return { + useSSOAuth: false, + workspace, + } as { + useSSOAuth: false; + workspace: Workspace; + }; + } + + async generateSwitchWorkspaceToken( + user: User, + workspace: Workspace, + ): Promise { + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); + + const token = await this.accessTokenService.generateAccessToken( + user.id, + workspace.id, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + workspace.id, + ); + + return { + tokens: { + accessToken: token, + refreshToken, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 64eeac4a78..f301acd3c6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -12,6 +12,7 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -28,6 +29,7 @@ export type JwtPayload = { export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( private readonly environmentService: EnvironmentService, + private readonly jwtWrapperService: JwtWrapperService, private readonly typeORMService: TypeORMService, private readonly dataSourceService: DataSourceService, @InjectRepository(Workspace, 'core') @@ -38,7 +40,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: environmentService.get('ACCESS_TOKEN_SECRET'), + secretOrKeyProvider: async (request, rawJwtToken, done) => { + try { + const decodedToken = this.jwtWrapperService.decode( + rawJwtToken, + ) as JwtPayload; + const workspaceId = decodedToken.workspaceId; + const secret = this.jwtWrapperService.generateAppSecret( + 'ACCESS', + workspaceId, + ); + + done(null, secret); + } catch (error) { + done(error, null); + } + }, }); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts new file mode 100644 index 0000000000..92e987d25f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Request } from 'express'; +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +import { AccessTokenService } from './access-token.service'; + +describe('AccessTokenService', () => { + let service: AccessTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + let userRepository: Repository; + let twentyORMGlobalManager: TwentyORMGlobalManager; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AccessTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn(), + }, + }, + { + provide: JwtAuthStrategy, + useValue: { + validate: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: EmailService, + useValue: {}, + }, + { + provide: SSOService, + useValue: {}, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AccessTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + twentyORMGlobalManager = module.get( + TwentyORMGlobalManager, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateAccessToken', () => { + it('should generate an access token successfully', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockUser = { + id: userId, + defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' }, + defaultWorkspaceId: workspaceId, + }; + const mockWorkspaceMember = { id: 'workspace-member-id' }; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue({ + findOne: jest.fn().mockResolvedValue(mockWorkspaceMember), + } as any); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateAccessToken(userId, workspaceId); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: userId, + workspaceId: workspaceId, + workspaceMemberId: mockWorkspaceMember.id, + }), + expect.any(Object), + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.generateAccessToken('non-existent-user', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('validateToken', () => { + it('should validate a token successfully', async () => { + const mockToken = 'valid-token'; + const mockRequest = { + headers: { + authorization: `Bearer ${mockToken}`, + }, + } as Request; + const mockDecodedToken = { sub: 'user-id', workspaceId: 'workspace-id' }; + const mockAuthContext = { + user: { id: 'user-id' }, + apiKey: null, + workspace: { id: 'workspace-id' }, + workspaceMemberId: 'workspace-member-id', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest + .spyOn(jwtWrapperService, 'decode') + .mockReturnValue(mockDecodedToken as any); + jest + .spyOn(service['jwtStrategy'], 'validate') + .mockReturnValue(mockAuthContext as any); + + const result = await service.validateToken(mockRequest); + + expect(result).toEqual(mockAuthContext); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'ACCESS', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); + expect(service['jwtStrategy'].validate).toHaveBeenCalledWith( + mockDecodedToken, + ); + }); + + it('should throw an error if token is missing', async () => { + const mockRequest = { + headers: {}, + } as Request; + + await expect(service.validateToken(mockRequest)).rejects.toThrow( + AuthException, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts new file mode 100644 index 0000000000..20baf3742c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import { Request } from 'express'; +import ms from 'ms'; +import { ExtractJwt } from 'passport-jwt'; +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { + JwtAuthStrategy, + JwtPayload, +} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +export class AccessTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly jwtStrategy: JwtAuthStrategy, + private readonly environmentService: EnvironmentService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async generateAccessToken( + userId: string, + workspaceId: string, + ): Promise { + const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['defaultWorkspace'], + }); + + if (!user) { + throw new AuthException( + 'User is not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!user.defaultWorkspace) { + throw new AuthException( + 'User does not have a default workspace', + AuthExceptionCode.INVALID_DATA, + ); + } + + const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId; + let tokenWorkspaceMemberId: string | undefined; + + if ( + user.defaultWorkspace.activationStatus === + WorkspaceActivationStatus.ACTIVE + ) { + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + tokenWorkspaceId, + 'workspaceMember', + ); + + const workspaceMember = await workspaceMemberRepository.findOne({ + where: { + userId: user.id, + }, + }); + + if (!workspaceMember) { + throw new AuthException( + 'User is not a member of the workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + tokenWorkspaceMemberId = workspaceMember.id; + } + + const jwtPayload: JwtPayload = { + sub: user.id, + workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId, + workspaceMemberId: tokenWorkspaceMemberId, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), + }), + expiresAt, + }; + } + + async validateToken(request: Request): Promise { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + if (!token) { + throw new AuthException( + 'missing authentication token', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + + const decoded = await this.jwtWrapperService.decode(token); + + const { user, apiKey, workspace, workspaceMemberId } = + await this.jwtStrategy.validate(decoded as JwtPayload); + + return { user, apiKey, workspace, workspaceMemberId }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts new file mode 100644 index 0000000000..62d21a673d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { LoginTokenService } from './login-token.service'; + +describe('LoginTokenService', () => { + let service: LoginTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginTokenService, + { + provide: JwtWrapperService, + useValue: { + generateAppSecret: jest.fn(), + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(LoginTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateLoginToken', () => { + it('should generate a login token successfully', async () => { + const email = 'test@example.com'; + const mockSecret = 'mock-secret'; + const mockExpiresIn = '1h'; + const mockToken = 'mock-token'; + + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue(mockSecret); + jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateLoginToken(email); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN'); + expect(environmentService.get).toHaveBeenCalledWith( + 'LOGIN_TOKEN_EXPIRES_IN', + ); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: email }, + { secret: mockSecret, expiresIn: mockExpiresIn }, + ); + }); + + it('should throw an error if LOGIN_TOKEN_EXPIRES_IN is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateLoginToken('test@example.com'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('verifyLoginToken', () => { + it('should verify a login token successfully', async () => { + const mockToken = 'valid-token'; + const mockEmail = 'test@example.com'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest + .spyOn(jwtWrapperService, 'decode') + .mockReturnValue({ sub: mockEmail }); + + const result = await service.verifyLoginToken(mockToken); + + expect(result).toEqual(mockEmail); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'LOGIN', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, { + json: true, + }); + }); + + it('should throw an error if token verification fails', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockRejectedValue(new Error('Invalid token')); + + await expect(service.verifyLoginToken(mockToken)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts new file mode 100644 index 0000000000..24c96b4e42 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class LoginTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateLoginToken(email: string): Promise { + const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); + const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + const jwtPayload = { + sub: email, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + }), + expiresAt, + }; + } + + async verifyLoginToken(loginToken: string): Promise { + await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); + + return this.jwtWrapperService.decode(loginToken, { + json: true, + }).sub; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts new file mode 100644 index 0000000000..5a254c7621 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +import { RefreshTokenService } from './refresh-token.service'; + +describe('RefreshTokenService', () => { + let service: RefreshTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + let appTokenRepository: Repository; + let userRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RefreshTokenService, + { + provide: JwtWrapperService, + useValue: { + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + sign: jest.fn(), + generateAppSecret: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(RefreshTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('verifyRefreshToken', () => { + it('should verify a refresh token successfully', async () => { + const mockToken = 'valid-refresh-token'; + const mockJwtPayload = { jti: 'token-id', sub: 'user-id' }; + const mockAppToken = { id: 'token-id', revokedAt: null }; + const mockUser: Partial = { + id: 'some-id', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + defaultAvatarUrl: '', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload); + jest + .spyOn(appTokenRepository, 'findOneBy') + .mockResolvedValue(mockAppToken as AppToken); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + + const result = await service.verifyRefreshToken(mockToken); + + expect(result).toEqual({ user: mockUser, token: mockAppToken }); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'REFRESH', + ); + }); + + it('should throw an error if the token is malformed', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({}); + + await expect(service.verifyRefreshToken(mockToken)).rejects.toThrow( + AuthException, + ); + }); + }); + + describe('generateRefreshToken', () => { + it('should generate a refresh token successfully', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockToken = 'mock-refresh-token'; + const mockExpiresIn = '7d'; + + jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mock-secret'); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(appTokenRepository, 'create') + .mockReturnValue({ id: 'new-token-id' } as AppToken); + jest + .spyOn(appTokenRepository, 'save') + .mockResolvedValue({ id: 'new-token-id' } as AppToken); + + const result = await service.generateRefreshToken(userId, workspaceId); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(appTokenRepository.save).toHaveBeenCalled(); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: userId }, + expect.objectContaining({ + secret: 'mock-secret', + expiresIn: mockExpiresIn, + jwtid: 'new-token-id', + }), + ); + }); + + it('should throw an error if expiration time is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateRefreshToken('user-id', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts new file mode 100644 index 0000000000..7dfe5d68ec --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class RefreshTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + ) {} + + async verifyRefreshToken(refreshToken: string) { + const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); + + await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH'); + const jwtPayload = await this.jwtWrapperService.decode(refreshToken); + + if (!(jwtPayload.jti && jwtPayload.sub)) { + throw new AuthException( + 'This refresh token is malformed', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const token = await this.appTokenRepository.findOneBy({ + id: jwtPayload.jti, + }); + + if (!token) { + throw new AuthException( + "This refresh token doesn't exist", + AuthExceptionCode.INVALID_INPUT, + ); + } + + const user = await this.userRepository.findOne({ + where: { id: jwtPayload.sub }, + relations: ['appTokens'], + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + // Check if revokedAt is less than coolDown + if ( + token.revokedAt && + token.revokedAt.getTime() <= Date.now() - ms(coolDown) + ) { + // Revoke all user refresh tokens + await Promise.all( + user.appTokens.map(async ({ id, type }) => { + if (type === AppTokenType.RefreshToken) { + await this.appTokenRepository.update( + { id }, + { + revokedAt: new Date(), + }, + ); + } + }), + ); + + throw new AuthException( + 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return { user, token }; + } + + async generateRefreshToken( + userId: string, + workspaceId: string, + ): Promise { + const secret = this.jwtWrapperService.generateAppSecret( + 'REFRESH', + workspaceId, + ); + const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const refreshTokenPayload = { + userId, + expiresAt, + type: AppTokenType.RefreshToken, + }; + const jwtPayload = { + sub: userId, + }; + + const refreshToken = this.appTokenRepository.create(refreshTokenPayload); + + await this.appTokenRepository.save(refreshToken); + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + // Jwtid will be used to link RefreshToken entity to this token + jwtid: refreshToken.id, + }), + expiresAt, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts new file mode 100644 index 0000000000..d44fb5473d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +import { RenewTokenService } from './renew-token.service'; + +describe('RenewTokenService', () => { + let service: RenewTokenService; + let appTokenRepository: Repository; + let accessTokenService: AccessTokenService; + let refreshTokenService: RefreshTokenService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RenewTokenService, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: AccessTokenService, + useValue: { + generateAccessToken: jest.fn(), + }, + }, + { + provide: RefreshTokenService, + useValue: { + verifyRefreshToken: jest.fn(), + generateRefreshToken: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RenewTokenService); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + accessTokenService = module.get(AccessTokenService); + refreshTokenService = module.get(RefreshTokenService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateTokensFromRefreshToken', () => { + it('should generate new access and refresh tokens', async () => { + const mockRefreshToken = 'valid-refresh-token'; + const mockUser = { id: 'user-id' } as User; + const mockWorkspaceId = 'workspace-id'; + const mockTokenId = 'token-id'; + const mockAccessToken = { + token: 'new-access-token', + expiresAt: new Date(), + }; + const mockNewRefreshToken = { + token: 'new-refresh-token', + expiresAt: new Date(), + }; + const mockAppToken: Partial = { + id: mockTokenId, + workspaceId: mockWorkspaceId, + user: mockUser, + userId: mockUser.id, + }; + + jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({ + user: mockUser, + token: mockAppToken as AppToken, + }); + jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); + jest + .spyOn(accessTokenService, 'generateAccessToken') + .mockResolvedValue(mockAccessToken); + jest + .spyOn(refreshTokenService, 'generateRefreshToken') + .mockResolvedValue(mockNewRefreshToken); + + const result = + await service.generateTokensFromRefreshToken(mockRefreshToken); + + expect(result).toEqual({ + accessToken: mockAccessToken, + refreshToken: mockNewRefreshToken, + }); + expect(refreshTokenService.verifyRefreshToken).toHaveBeenCalledWith( + mockRefreshToken, + ); + expect(appTokenRepository.update).toHaveBeenCalledWith( + { id: mockTokenId }, + { revokedAt: expect.any(Date) }, + ); + expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspaceId, + ); + expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspaceId, + ); + }); + + it('should throw an error if refresh token is not provided', async () => { + await expect(service.generateTokensFromRefreshToken('')).rejects.toThrow( + AuthException, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts new file mode 100644 index 0000000000..a06e4790fb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; + +@Injectable() +export class RenewTokenService { + constructor( + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + async generateTokensFromRefreshToken(token: string): Promise<{ + accessToken: AuthToken; + refreshToken: AuthToken; + }> { + if (!token) { + throw new AuthException( + 'Refresh token not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const { + user, + token: { id, workspaceId }, + } = await this.refreshTokenService.verifyRefreshToken(token); + + // Revoke old refresh token + await this.appTokenRepository.update( + { + id, + }, + { + revokedAt: new Date(), + }, + ); + + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + workspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + workspaceId, + ); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts deleted file mode 100644 index 7bc44ffd00..0000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import crypto from 'crypto'; - -import { IsNull, MoreThan, Repository } from 'typeorm'; - -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; -import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; -import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; - -import { TokenService } from './token.service'; - -describe('TokenService', () => { - let service: TokenService; - let environmentService: EnvironmentService; - let userRepository: Repository; - let appTokenRepository: Repository; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TokenService, - { - provide: JwtWrapperService, - useValue: {}, - }, - { - provide: JwtAuthStrategy, - useValue: {}, - }, - { - provide: EnvironmentService, - useValue: { - get: jest.fn().mockReturnValue('some-value'), - }, - }, - { - provide: EmailService, - useValue: { - send: jest.fn(), - }, - }, - { - provide: SSOService, - useValue: { - send: jest.fn(), - }, - }, - { - provide: getRepositoryToken(User, 'core'), - useValue: { - findOneBy: jest.fn(), - }, - }, - { - provide: getRepositoryToken(AppToken, 'core'), - useValue: { - findOne: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: getRepositoryToken(Workspace, 'core'), - useValue: {}, - }, - { - provide: TwentyORMGlobalManager, - useValue: {}, - }, - ], - }).compile(); - - service = module.get(TokenService); - environmentService = module.get(EnvironmentService); - userRepository = module.get(getRepositoryToken(User, 'core')); - appTokenRepository = module.get(getRepositoryToken(AppToken, 'core')); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('generatePasswordResetToken', () => { - it('should generate a new password reset token when no existing token is found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - const expiresIn = '3600000'; // 1 hour in ms - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(environmentService, 'get').mockReturnValue(expiresIn); - jest - .spyOn(appTokenRepository, 'save') - .mockImplementation(async (token) => token as AppToken); - - const result = await service.generatePasswordResetToken(mockUser.email); - - expect(userRepository.findOneBy).toHaveBeenCalledWith({ - email: mockUser.email, - }); - expect(appTokenRepository.findOne).toHaveBeenCalled(); - expect(appTokenRepository.save).toHaveBeenCalled(); - expect(result.passwordResetToken).toBeDefined(); - expect(result.passwordResetTokenExpiresAt).toBeDefined(); - }); - - it('should throw AuthException if an existing valid token is found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - const mockToken = { - userId: '1', - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // expires 10 seconds in the future - } as AppToken; - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(mockToken); - jest.spyOn(environmentService, 'get').mockReturnValue('3600000'); - - await expect( - service.generatePasswordResetToken(mockUser.email), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if no user is found', async () => { - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); - - await expect( - service.generatePasswordResetToken('nonexistent@example.com'), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if environment variable is not found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(environmentService, 'get').mockReturnValue(''); // No environment variable set - - await expect( - service.generatePasswordResetToken(mockUser.email), - ).rejects.toThrow(AuthException); - }); - }); - - describe('validatePasswordResetToken', () => { - it('should return user data for a valid and active token', async () => { - const resetToken = 'valid-reset-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: '1', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // Valid future date - }; - const mockUser = { id: '1', email: 'user@example.com' }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - jest - .spyOn(userRepository, 'findOneBy') - .mockResolvedValue(mockUser as User); - - const result = await service.validatePasswordResetToken(resetToken); - - expect(appTokenRepository.findOne).toHaveBeenCalledWith({ - where: { - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - expect(userRepository.findOneBy).toHaveBeenCalledWith({ - id: mockToken.userId, - }); - expect(result).toEqual({ id: mockUser.id, email: mockUser.email }); - }); - - it('should throw AuthException if token is invalid or expired', async () => { - const resetToken = 'invalid-reset-token'; - - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if user does not exist for a valid token', async () => { - const resetToken = 'orphan-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: 'nonexistent-user', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // Valid future date - revokedAt: null, - }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); - - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if token is revoked', async () => { - const resetToken = 'revoked-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: '1', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), - revokedAt: new Date(), - }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts deleted file mode 100644 index d323ba8316..0000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ /dev/null @@ -1,861 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import crypto from 'crypto'; - -import { render } from '@react-email/render'; -import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; -import { Request } from 'express'; -import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; -import ms from 'ms'; -import { ExtractJwt } from 'passport-jwt'; -import { PasswordResetLinkEmail } from 'twenty-emails'; -import { IsNull, MoreThan, Repository } from 'typeorm'; - -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; -import { - AuthException, - AuthExceptionCode, -} from 'src/engine/core-modules/auth/auth.exception'; -import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; -import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; -import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; -import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; -import { - ApiKeyToken, - AuthToken, - AuthTokens, - PasswordResetToken, -} from 'src/engine/core-modules/auth/dto/token.entity'; -import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; -import { - JwtAuthStrategy, - JwtPayload, -} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { - Workspace, - WorkspaceActivationStatus, -} from 'src/engine/core-modules/workspace/workspace.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; - -@Injectable() -export class TokenService { - constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly jwtStrategy: JwtAuthStrategy, - private readonly environmentService: EnvironmentService, - @InjectRepository(User, 'core') - private readonly userRepository: Repository, - @InjectRepository(AppToken, 'core') - private readonly appTokenRepository: Repository, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, - private readonly emailService: EmailService, - private readonly sSSOService: SSOService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - ) {} - - async generateAccessToken( - userId: string, - workspaceId?: string, - ): Promise { - const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const user = await this.userRepository.findOne({ - where: { id: userId }, - relations: ['defaultWorkspace'], - }); - - if (!user) { - throw new AuthException( - 'User is not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if (!user.defaultWorkspace) { - throw new AuthException( - 'User does not have a default workspace', - AuthExceptionCode.INVALID_DATA, - ); - } - - const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId; - let tokenWorkspaceMemberId: string | undefined; - - if ( - user.defaultWorkspace.activationStatus === - WorkspaceActivationStatus.ACTIVE - ) { - const workspaceMemberRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - tokenWorkspaceId, - 'workspaceMember', - ); - - const workspaceMember = await workspaceMemberRepository.findOne({ - where: { - userId: user.id, - }, - }); - - if (!workspaceMember) { - throw new AuthException( - 'User is not a member of the workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - tokenWorkspaceMemberId = workspaceMember.id; - } - - const jwtPayload: JwtPayload = { - sub: user.id, - workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId, - workspaceMemberId: tokenWorkspaceMemberId, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload), - expiresAt, - }; - } - - async generateRefreshToken(userId: string): Promise { - const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); - const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const refreshTokenPayload = { - userId, - expiresAt, - type: AppTokenType.RefreshToken, - }; - const jwtPayload = { - sub: userId, - }; - - const refreshToken = this.appTokenRepository.create(refreshTokenPayload); - - await this.appTokenRepository.save(refreshToken); - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - // Jwtid will be used to link RefreshToken entity to this token - jwtid: refreshToken.id, - }), - expiresAt, - }; - } - - async generateInvitationToken(workspaceId: string, email: string) { - const expiresIn = this.environmentService.get( - 'INVITATION_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for invitation token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const invitationToken = this.appTokenRepository.create({ - workspaceId, - expiresAt, - type: AppTokenType.InvitationToken, - value: crypto.randomBytes(32).toString('hex'), - context: { - email, - }, - }); - - return this.appTokenRepository.save(invitationToken); - } - - async generateLoginToken(email: string): Promise { - const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); - const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: email, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - }), - expiresAt, - }; - } - - async generateTransientToken( - workspaceMemberId: string, - userId: string, - workspaceId: string, - ): Promise { - const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); - const expiresIn = this.environmentService.get( - 'SHORT_TERM_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: workspaceMemberId, - userId, - workspaceId, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - }), - expiresAt, - }; - } - - async generateApiKeyToken( - workspaceId: string, - apiKeyId?: string, - expiresAt?: Date | string, - ): Promise | undefined> { - if (!apiKeyId) { - return; - } - const jwtPayload = { - sub: workspaceId, - }; - const secret = this.environmentService.get('ACCESS_TOKEN_SECRET'); - let expiresIn: string | number; - - if (expiresAt) { - expiresIn = Math.floor( - (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, - ); - } else { - expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN'); - } - const token = this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - jwtid: apiKeyId, - }); - - return { token }; - } - - isTokenPresent(request: Request): boolean { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - return !!token; - } - - async validateToken(request: Request): Promise { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - if (!token) { - throw new AuthException( - 'missing authentication token', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - const decoded = await this.verifyJwt( - token, - this.environmentService.get('ACCESS_TOKEN_SECRET'), - ); - - const { user, apiKey, workspace, workspaceMemberId } = - await this.jwtStrategy.validate(decoded as JwtPayload); - - return { user, apiKey, workspace, workspaceMemberId }; - } - - async verifyLoginToken(loginToken: string): Promise { - const loginTokenSecret = this.environmentService.get('LOGIN_TOKEN_SECRET'); - - const payload = await this.verifyJwt(loginToken, loginTokenSecret); - - return payload.sub; - } - - async verifyTransientToken(transientToken: string): Promise<{ - workspaceMemberId: string; - userId: string; - workspaceId: string; - }> { - const transientTokenSecret = - this.environmentService.get('LOGIN_TOKEN_SECRET'); - - const payload = await this.verifyJwt(transientToken, transientTokenSecret); - - return { - workspaceMemberId: payload.sub, - userId: payload.userId, - workspaceId: payload.workspaceId, - }; - } - - async switchWorkspace(user: User, workspaceId: string) { - const userExists = await this.userRepository.findBy({ id: user.id }); - - if (!userExists) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const workspace = await this.workspaceRepository.findOne({ - where: { id: workspaceId }, - relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], - }); - - if (!workspace) { - throw new AuthException( - 'workspace doesnt exist', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if ( - !workspace.workspaceUsers - .map((userWorkspace) => userWorkspace.userId) - .includes(user.id) - ) { - throw new AuthException( - 'user does not belong to workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (workspace.workspaceSSOIdentityProviders.length > 0) { - return { - useSSOAuth: true, - workspace, - availableSSOIdentityProviders: - await this.sSSOService.listSSOIdentityProvidersByWorkspaceId( - workspaceId, - ), - } as { - useSSOAuth: true; - workspace: Workspace; - availableSSOIdentityProviders: Awaited< - ReturnType< - typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId - > - >; - }; - } - - return { - useSSOAuth: false, - workspace, - } as { - useSSOAuth: false; - workspace: Workspace; - }; - } - - async generateSwitchWorkspaceToken( - user: User, - workspace: Workspace, - ): Promise { - await this.userRepository.save({ - id: user.id, - defaultWorkspace: workspace, - }); - - const token = await this.generateAccessToken(user.id, workspace.id); - const refreshToken = await this.generateRefreshToken(user.id); - - return { - tokens: { - accessToken: token, - refreshToken, - }, - }; - } - - async verifyAuthorizationCode( - exchangeAuthCodeInput: ExchangeAuthCodeInput, - ): Promise { - const { authorizationCode, codeVerifier } = exchangeAuthCodeInput; - - if (!authorizationCode) { - throw new AuthException( - 'Authorization code not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - let userId = ''; - - if (codeVerifier) { - const authorizationCodeAppToken = await this.appTokenRepository.findOne({ - where: { - value: authorizationCode, - }, - }); - - if (!authorizationCodeAppToken) { - throw new AuthException( - 'Authorization code does not exist', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) { - throw new AuthException( - 'Authorization code expired.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const codeChallenge = crypto - .createHash('sha256') - .update(codeVerifier) - .digest() - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); - - const codeChallengeAppToken = await this.appTokenRepository.findOne({ - where: { - value: codeChallenge, - }, - }); - - if (!codeChallengeAppToken || !codeChallengeAppToken.userId) { - throw new AuthException( - 'code verifier doesnt match the challenge', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) { - throw new AuthException( - 'code challenge expired.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) { - throw new AuthException( - 'authorization code / code verifier was not created by same client', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (codeChallengeAppToken.revokedAt) { - throw new AuthException( - 'Token has been revoked.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - await this.appTokenRepository.save({ - id: codeChallengeAppToken.id, - revokedAt: new Date(), - }); - - userId = codeChallengeAppToken.userId; - } - - const user = await this.userRepository.findOne({ - where: { id: userId }, - relations: ['defaultWorkspace'], - }); - - if (!user) { - throw new AuthException( - 'User who generated the token does not exist', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if (!user.defaultWorkspace) { - throw new AuthException( - 'User does not have a default workspace', - AuthExceptionCode.INVALID_DATA, - ); - } - - const accessToken = await this.generateAccessToken( - user.id, - user.defaultWorkspaceId, - ); - const refreshToken = await this.generateRefreshToken(user.id); - const loginToken = await this.generateLoginToken(user.email); - - return { - accessToken, - refreshToken, - loginToken, - }; - } - - async verifyRefreshToken(refreshToken: string) { - const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); - const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); - const jwtPayload = await this.verifyJwt(refreshToken, secret); - - if (!(jwtPayload.jti && jwtPayload.sub)) { - throw new AuthException( - 'This refresh token is malformed', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const token = await this.appTokenRepository.findOneBy({ - id: jwtPayload.jti, - }); - - if (!token) { - throw new AuthException( - "This refresh token doesn't exist", - AuthExceptionCode.INVALID_INPUT, - ); - } - - const user = await this.userRepository.findOne({ - where: { id: jwtPayload.sub }, - relations: ['appTokens'], - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - // Check if revokedAt is less than coolDown - if ( - token.revokedAt && - token.revokedAt.getTime() <= Date.now() - ms(coolDown) - ) { - // Revoke all user refresh tokens - await Promise.all( - user.appTokens.map(async ({ id, type }) => { - if (type === AppTokenType.RefreshToken) { - await this.appTokenRepository.update( - { id }, - { - revokedAt: new Date(), - }, - ); - } - }), - ); - - throw new AuthException( - 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - return { user, token }; - } - - async generateTokensFromRefreshToken(token: string): Promise<{ - accessToken: AuthToken; - refreshToken: AuthToken; - }> { - if (!token) { - throw new AuthException( - 'Refresh token not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const { - user, - token: { id }, - } = await this.verifyRefreshToken(token); - - // Revoke old refresh token - await this.appTokenRepository.update( - { - id, - }, - { - revokedAt: new Date(), - }, - ); - - const accessToken = await this.generateAccessToken(user.id); - const refreshToken = await this.generateRefreshToken(user.id); - - return { - accessToken, - refreshToken, - }; - } - - computeRedirectURI(loginToken: string): string { - return `${this.environmentService.get( - 'FRONT_BASE_URL', - )}/verify?loginToken=${loginToken}`; - } - - async verifyJwt(token: string, secret?: string) { - try { - return this.jwtWrapperService.verify( - token, - secret ? { secret } : undefined, - ); - } catch (error) { - if (error instanceof TokenExpiredError) { - throw new AuthException( - 'Token has expired.', - AuthExceptionCode.UNAUTHENTICATED, - ); - } else if (error instanceof JsonWebTokenError) { - throw new AuthException( - 'Token invalid.', - AuthExceptionCode.UNAUTHENTICATED, - ); - } else { - throw new AuthException( - 'Unknown token error.', - AuthExceptionCode.INVALID_INPUT, - ); - } - } - } - - async generatePasswordResetToken(email: string): Promise { - const user = await this.userRepository.findOneBy({ - email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const expiresIn = this.environmentService.get( - 'PASSWORD_RESET_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const existingToken = await this.appTokenRepository.findOne({ - where: { - userId: user.id, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - - if (existingToken) { - const timeToWait = ms( - differenceInMilliseconds(existingToken.expiresAt, new Date()), - { long: true }, - ); - - throw new AuthException( - `Token has already been generated. Please wait for ${timeToWait} to generate again.`, - AuthExceptionCode.INVALID_INPUT, - ); - } - - const plainResetToken = crypto.randomBytes(32).toString('hex'); - const hashedResetToken = crypto - .createHash('sha256') - .update(plainResetToken) - .digest('hex'); - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - await this.appTokenRepository.save({ - userId: user.id, - value: hashedResetToken, - expiresAt, - type: AppTokenType.PasswordResetToken, - }); - - return { - passwordResetToken: plainResetToken, - passwordResetTokenExpiresAt: expiresAt, - }; - } - - async sendEmailPasswordResetLink( - resetToken: PasswordResetToken, - email: string, - ): Promise { - const user = await this.userRepository.findOneBy({ - email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; - - const emailData = { - link: resetLink, - duration: ms( - differenceInMilliseconds( - resetToken.passwordResetTokenExpiresAt, - new Date(), - ), - { - long: true, - }, - ), - }; - - const emailTemplate = PasswordResetLinkEmail(emailData); - const html = render(emailTemplate, { - pretty: true, - }); - - const text = render(emailTemplate, { - plainText: true, - }); - - this.emailService.send({ - from: `${this.environmentService.get( - 'EMAIL_FROM_NAME', - )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to: email, - subject: 'Action Needed to Reset Password', - text, - html, - }); - - return { success: true }; - } - - async validatePasswordResetToken( - resetToken: string, - ): Promise { - const hashedResetToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - - const token = await this.appTokenRepository.findOne({ - where: { - value: hashedResetToken, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - - if (!token || !token.userId) { - throw new AuthException( - 'Token is invalid', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const user = await this.userRepository.findOneBy({ - id: token.userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - return { - id: user.id, - email: user.email, - }; - } - - async invalidatePasswordResetToken( - userId: string, - ): Promise { - const user = await this.userRepository.findOneBy({ - id: userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - await this.appTokenRepository.update( - { - userId, - type: AppTokenType.PasswordResetToken, - }, - { - revokedAt: new Date(), - }, - ); - - return { success: true }; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts new file mode 100644 index 0000000000..adf31855ac --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { TransientTokenService } from './transient-token.service'; + +describe('TransientTokenService', () => { + let service: TransientTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransientTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(TransientTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateTransientToken', () => { + it('should generate a transient token successfully', async () => { + const workspaceMemberId = 'workspace-member-id'; + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockExpiresIn = '15m'; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockImplementation((key) => { + if (key === 'SHORT_TERM_TOKEN_EXPIRES_IN') return mockExpiresIn; + + return undefined; + }); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateTransientToken( + workspaceMemberId, + userId, + workspaceId, + ); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(environmentService.get).toHaveBeenCalledWith( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { + sub: workspaceMemberId, + userId, + workspaceId, + }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: mockExpiresIn, + }), + ); + }); + + it('should throw an error if SHORT_TERM_TOKEN_EXPIRES_IN is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateTransientToken('member-id', 'user-id', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('verifyTransientToken', () => { + it('should verify a transient token successfully', async () => { + const mockToken = 'valid-token'; + const mockPayload = { + sub: 'workspace-member-id', + userId: 'user-id', + workspaceId: 'workspace-id', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); + + const result = await service.verifyTransientToken(mockToken); + + expect(result).toEqual({ + workspaceMemberId: mockPayload.sub, + userId: mockPayload.userId, + workspaceId: mockPayload.workspaceId, + }); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'LOGIN', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); + }); + + it('should throw an error if token verification fails', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockRejectedValue(new Error('Invalid token')); + + await expect(service.verifyTransientToken(mockToken)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts new file mode 100644 index 0000000000..a9cad6c97f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class TransientTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateTransientToken( + workspaceMemberId: string, + userId: string, + workspaceId: string, + ): Promise { + const secret = this.jwtWrapperService.generateAppSecret( + 'LOGIN', + workspaceId, + ); + const expiresIn = this.environmentService.get( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + const jwtPayload = { + sub: workspaceMemberId, + userId, + workspaceId, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + }), + expiresAt, + }; + } + + async verifyTransientToken(transientToken: string): Promise<{ + workspaceMemberId: string; + userId: string; + workspaceId: string; + }> { + await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN'); + + const payload = await this.jwtWrapperService.decode(transientToken); + + return { + workspaceMemberId: payload.sub, + userId: payload.userId, + workspaceId: payload.workspaceId, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 42d65621e5..c39ac8aa57 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -5,13 +5,16 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; @Module({ imports: [ @@ -22,7 +25,18 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; EmailModule, WorkspaceSSOModule, ], - providers: [TokenService, JwtAuthStrategy], - exports: [TokenService], + providers: [ + RenewTokenService, + JwtAuthStrategy, + AccessTokenService, + LoginTokenService, + RefreshTokenService, + ], + exports: [ + RenewTokenService, + AccessTokenService, + LoginTokenService, + RefreshTokenService, + ], }) export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 51a225faed..3fdd075074 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -134,18 +134,13 @@ export class EnvironmentVariables { @IsOptional() SERVER_URL: string; - // Json Web Token @IsString() - ACCESS_TOKEN_SECRET: string; + APP_SECRET: string; @IsDuration() @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; - @IsString() - REFRESH_TOKEN_SECRET: string; - - @IsDuration() @IsOptional() REFRESH_TOKEN_EXPIRES_IN = '60d'; @@ -153,17 +148,10 @@ export class EnvironmentVariables { @IsOptional() REFRESH_TOKEN_COOL_DOWN = '1m'; - @IsString() - LOGIN_TOKEN_SECRET = '30m'; - @IsDuration() @IsOptional() LOGIN_TOKEN_EXPIRES_IN = '15m'; - @IsString() - @IsOptional() - FILE_TOKEN_SECRET = 'random_string'; - @IsDuration() @IsOptional() FILE_TOKEN_EXPIRES_IN = '1d'; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index c78a1bf066..2b21305483 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -14,4 +14,5 @@ export enum FeatureFlagKey { IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED', IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED', IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED', + IsAdvancedFiltersEnabled = 'IS_ADVANCED_FILTERS_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index 80cd2ffd9d..15fadc8b17 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -8,8 +8,8 @@ import { v4 as uuidV4 } from 'uuid'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { settings } from 'src/engine/constants/settings'; -import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { getCropSize } from 'src/utils/image'; @Injectable() @@ -83,7 +83,7 @@ export class FileUploadService { }); const signedPayload = await this.fileService.encodeFileToken({ - workspace_id: workspaceId, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 890d060dd8..3296a57cf7 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -7,40 +7,43 @@ import { } from '@nestjs/common'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Injectable() export class FilePathGuard implements CanActivate { - constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly environmentService: EnvironmentService, - ) {} + constructor(private readonly jwtWrapperService: JwtWrapperService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const query = request.query; - if (query && query['token']) { - const payloadToDecode = query['token']; - const decodedPayload = await this.jwtWrapperService.decode( - payloadToDecode, - { - secret: this.environmentService.get('FILE_TOKEN_SECRET'), - } as any, - ); - - const expirationDate = decodedPayload?.['expiration_date']; - const workspaceId = decodedPayload?.['workspace_id']; - - const isExpired = await this.isExpired(expirationDate); - - if (isExpired) { - return false; - } - - request.workspaceId = workspaceId; + if (!query || !query['token']) { + return false; } + const payload = await this.jwtWrapperService.verifyWorkspaceToken( + query['token'], + 'FILE', + ); + + if (!payload.workspaceId) { + return false; + } + + const decodedPayload = await this.jwtWrapperService.decode(query['token'], { + json: true, + }); + + const expirationDate = decodedPayload?.['expirationDate']; + const workspaceId = decodedPayload?.['workspaceId']; + + const isExpired = await this.isExpired(expirationDate); + + if (isExpired) { + return false; + } + + request.workspaceId = workspaceId; + return true; } diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index a1c59e7008..c4c240f210 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -5,9 +5,9 @@ import { Stream } from 'stream'; import { addMilliseconds } from 'date-fns'; import ms from 'ms'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @Injectable() export class FileService { @@ -34,13 +34,16 @@ export class FileService { const fileTokenExpiresIn = this.environmentService.get( 'FILE_TOKEN_EXPIRES_IN', ); - const secret = this.environmentService.get('FILE_TOKEN_SECRET'); + const secret = this.jwtWrapperService.generateAppSecret( + 'FILE', + payloadToEncode.workspaceId, + ); const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn)); const signedPayload = this.jwtWrapperService.sign( { - expiration_date: expirationDate, + expirationDate: expirationDate, ...payloadToEncode, }, { diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts index 306f1640e0..8a689819cb 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts @@ -2,14 +2,14 @@ import { Module } from '@nestjs/common'; import { JwtModule as NestJwtModule } from '@nestjs/jwt'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; const InternalJwtModule = NestJwtModule.registerAsync({ useFactory: async (environmentService: EnvironmentService) => { return { - secret: environmentService.get('ACCESS_TOKEN_SECRET'), + secret: environmentService.get('APP_SECRET'), signOptions: { expiresIn: environmentService.get('ACCESS_TOKEN_EXPIRES_IN'), }, diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 79ebee2c86..d78ba1b4e0 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -1,11 +1,30 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; +import { createHash } from 'crypto'; + import * as jwt from 'jsonwebtoken'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +type WorkspaceTokenType = + | 'ACCESS' + | 'LOGIN' + | 'REFRESH' + | 'FILE' + | 'POSTGRES_PROXY' + | 'REMOTE_SERVER'; + @Injectable() export class JwtWrapperService { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private readonly environmentService: EnvironmentService, + ) {} sign(payload: string | object, options?: JwtSignOptions): string { // Typescript does not handle well the overloads of the sign method, helping it a little bit @@ -20,7 +39,58 @@ export class JwtWrapperService { return this.jwtService.verify(token, options); } - decode(payload: string, options: jwt.DecodeOptions): T { + decode(payload: string, options?: jwt.DecodeOptions): T { return this.jwtService.decode(payload, options); } + + verifyWorkspaceToken( + token: string, + type: WorkspaceTokenType, + options?: JwtVerifyOptions, + ) { + const payload = this.decode(token, { + json: true, + }); + + // TODO: check if this is really needed + if (type !== 'FILE' && !payload.sub) { + throw new UnauthorizedException('No payload sub'); + } + + try { + return this.jwtService.verify(token, { + ...options, + secret: this.generateAppSecret(type, payload.workspaceId), + }); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new AuthException( + 'Token has expired.', + AuthExceptionCode.UNAUTHENTICATED, + ); + } else if (error instanceof jwt.JsonWebTokenError) { + throw new AuthException( + 'Token invalid.', + AuthExceptionCode.UNAUTHENTICATED, + ); + } else { + throw new AuthException( + 'Unknown token error.', + AuthExceptionCode.INVALID_INPUT, + ); + } + } + } + + generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string { + const appSecret = this.environmentService.get('APP_SECRET'); + + if (!appSecret) { + throw new Error('APP_SECRET is not set'); + } + + return createHash('sha256') + .update(`${appSecret}${workspaceId}${type}`) + .digest('hex'); + } } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts index d4dce437eb..9ac93d6045 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -13,7 +13,7 @@ describe('OpenApiService', () => { providers: [ OpenApiService, { - provide: TokenService, + provide: AccessTokenService, useValue: {}, }, { diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 4628fef4f2..d7168bca81 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { Request } from 'express'; import { OpenAPIV3_1 } from 'openapi-types'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils'; import { @@ -41,7 +41,7 @@ import { getServerUrl } from 'src/utils/get-server-url'; @Injectable() export class OpenApiService { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly environmentService: EnvironmentService, private readonly objectMetadataService: ObjectMetadataService, ) {} @@ -57,7 +57,8 @@ export class OpenApiService { let objectMetadataItems; try { - const { workspace } = await this.tokenService.validateToken(request); + const { workspace } = + await this.accessTokenService.validateToken(request); objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts index 9034a9a1bf..609322454c 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts @@ -1,16 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver'; import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service'; -import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([PostgresCredentials], 'core'), - EnvironmentModule, - ], + imports: [JwtModule, TypeOrmModule.forFeature([PostgresCredentials], 'core')], providers: [ PostgresCredentialsResolver, PostgresCredentialsService, diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts index de8e78244d..6ae8d5a3d1 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts @@ -10,15 +10,15 @@ import { encryptText, } from 'src/engine/core-modules/auth/auth.util'; import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; export class PostgresCredentialsService { constructor( @InjectRepository(PostgresCredentials, 'core') private readonly postgresCredentialsRepository: Repository, - private readonly environmentService: EnvironmentService, + private readonly jwtWrapperService: JwtWrapperService, ) {} async enablePostgresProxy( @@ -27,7 +27,10 @@ export class PostgresCredentialsService { const user = `user_${randomBytes(4).toString('hex')}`; const password = randomBytes(16).toString('hex'); - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); const passwordHash = encryptText(password, key); const existingCredentials = @@ -81,7 +84,10 @@ export class PostgresCredentialsService { id: postgresCredentials.id, }); - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); return { id: postgresCredentials.id, @@ -105,7 +111,10 @@ export class PostgresCredentialsService { return null; } - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); return { id: postgresCredentials.id, diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 6c01623a00..b7692671ee 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -111,8 +111,8 @@ export class UserResolver { if (workspaceMember && workspaceMember.avatarUrl) { const avatarUrlToken = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: user.defaultWorkspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: user.defaultWorkspaceId, }); workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; @@ -133,8 +133,8 @@ export class UserResolver { for (const workspaceMember of workspaceMembers) { if (workspaceMember.avatarUrl) { const avatarUrlToken = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: user.defaultWorkspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: user.defaultWorkspaceId, }); workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; @@ -190,7 +190,7 @@ export class UserResolver { }); const fileToken = await this.fileService.encodeFileToken({ - workspace_id: workspaceId, + workspaceId: workspaceId, }); return `${paths[0]}?token=${fileToken}`; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 3fce16c4c3..5b05d09702 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -1,17 +1,29 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceInvitationService } from './workspace-invitation.service'; describe('WorkspaceInvitationService', () => { let service: WorkspaceInvitationService; + let appTokenRepository: Repository; + let userWorkspaceRepository: Repository; + let environmentService: EnvironmentService; + let emailService: EmailService; + let onboardingService: OnboardingService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,27 +31,29 @@ describe('WorkspaceInvitationService', () => { WorkspaceInvitationService, { provide: getRepositoryToken(AppToken, 'core'), - useValue: {}, - }, - { - provide: EnvironmentService, - useValue: {}, - }, - { - provide: EmailService, - useValue: {}, - }, - { - provide: TokenService, - useValue: {}, + useClass: Repository, }, { provide: getRepositoryToken(UserWorkspace, 'core'), - useValue: {}, + useClass: Repository, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: EmailService, + useValue: { + send: jest.fn(), + }, }, { provide: OnboardingService, - useValue: {}, + useValue: { + setOnboardingInviteTeamPending: jest.fn(), + }, }, ], }).compile(); @@ -47,9 +61,96 @@ describe('WorkspaceInvitationService', () => { service = module.get( WorkspaceInvitationService, ); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + userWorkspaceRepository = module.get>( + getRepositoryToken(UserWorkspace, 'core'), + ); + environmentService = module.get(EnvironmentService); + emailService = module.get(EmailService); + onboardingService = module.get(OnboardingService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('createWorkspaceInvitation', () => { + it('should create a workspace invitation successfully', async () => { + const email = 'test@example.com'; + const workspace = { id: 'workspace-id' } as Workspace; + + jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + } as any); + + jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false); + jest + .spyOn(service, 'generateInvitationToken') + .mockResolvedValue({} as AppToken); + + await expect( + service.createWorkspaceInvitation(email, workspace), + ).resolves.not.toThrow(); + }); + + it('should throw an exception if invitation already exists', async () => { + const email = 'test@example.com'; + const workspace = { id: 'workspace-id' } as Workspace; + + jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({}), + } as any); + + await expect( + service.createWorkspaceInvitation(email, workspace), + ).rejects.toThrow(WorkspaceInvitationException); + }); + }); + + describe('sendInvitations', () => { + it('should send invitations successfully', async () => { + const emails = ['test1@example.com', 'test2@example.com']; + const workspace = { + id: 'workspace-id', + inviteHash: 'invite-hash', + displayName: 'Test Workspace', + } as Workspace; + const sender = { email: 'sender@example.com', firstName: 'Sender' }; + + jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({ + context: { email: 'test@example.com' }, + value: 'token-value', + type: AppTokenType.InvitationToken, + } as AppToken); + jest + .spyOn(environmentService, 'get') + .mockReturnValue('http://localhost:3000'); + jest.spyOn(emailService, 'send').mockResolvedValue({} as any); + jest + .spyOn(onboardingService, 'setOnboardingInviteTeamPending') + .mockResolvedValue({} as any); + + const result = await service.sendInvitations( + emails, + workspace, + sender as User, + ); + + expect(result.success).toBe(true); + expect(result.result.length).toBe(2); + expect(emailService.send).toHaveBeenCalledTimes(2); + expect( + onboardingService.setOnboardingInviteTeamPending, + ).toHaveBeenCalledWith({ + workspaceId: workspace.id, + value: false, + }); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 7e7f8cf1f7..0e1025e8e1 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import crypto from 'crypto'; + import { render } from '@react-email/render'; +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; import { SendInviteLinkEmail } from 'twenty-emails'; import { IsNull, Repository } from 'typeorm'; @@ -9,7 +13,10 @@ import { AppToken, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -30,7 +37,6 @@ export class WorkspaceInvitationService { private readonly appTokenRepository: Repository, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, - private readonly tokenService: TokenService, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, private readonly onboardingService: OnboardingService, @@ -103,7 +109,7 @@ export class WorkspaceInvitationService { ); } - return this.tokenService.generateInvitationToken(workspace.id, email); + return this.generateInvitationToken(workspace.id, email); } async loadWorkspaceInvitations(workspace: Workspace) { @@ -290,4 +296,31 @@ export class WorkspaceInvitationService { ...result, }; } + + async generateInvitationToken(workspaceId: string, email: string) { + const expiresIn = this.environmentService.get( + 'INVITATION_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for invitation token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const invitationToken = this.appTokenRepository.create({ + workspaceId, + expiresAt, + type: AppTokenType.InvitationToken, + value: crypto.randomBytes(32).toString('hex'), + context: { + email, + }, + }); + + return this.appTokenRepository.save(invitationToken); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index d1e6a1c9ad..ae3798c20c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -95,7 +95,7 @@ export class WorkspaceResolver { }); const workspaceLogoToken = await this.fileService.encodeFileToken({ - workspace_id: id, + workspaceId: id, }); return `${paths[0]}?token=${workspaceLogoToken}`; @@ -128,7 +128,7 @@ export class WorkspaceResolver { if (workspace.logo) { try { const workspaceLogoToken = await this.fileService.encodeFileToken({ - workspace_id: workspace.id, + workspaceId: workspace.id, }); return `${workspace.logo}?token=${workspaceLogoToken}`; diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index d1f9208487..cd173f03c5 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -1,12 +1,12 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, ) {} @@ -14,7 +14,7 @@ export class JwtAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); try { - const data = await this.tokenService.validateToken(request); + const data = await this.accessTokenService.validateToken(request); const metadataVersion = await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 46964cebbd..a1e823a2bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -386,7 +386,6 @@ export class ObjectMetadataService extends TypeOrmQueryService { @@ -37,7 +37,7 @@ export class RemoteServerService { >, @InjectDataSource('metadata') private readonly metadataDataSource: DataSource, - private readonly environmentService: EnvironmentService, + private readonly jwtWrapperService: JwtWrapperService, private readonly foreignDataWrapperServerQueryFactory: ForeignDataWrapperServerQueryFactory, private readonly remoteTableService: RemoteTableService, private readonly workspaceDataSourceService: WorkspaceDataSourceService, @@ -72,6 +72,7 @@ export class RemoteServerService { ...remoteServerInput.userMappingOptions, password: this.encryptPassword( remoteServerInput.userMappingOptions.password, + workspaceId, ), }, }; @@ -156,6 +157,7 @@ export class RemoteServerService { ...partialRemoteServerWithUpdates.userMappingOptions, password: this.encryptPassword( partialRemoteServerWithUpdates.userMappingOptions.password, + workspaceId, ), }, }; @@ -252,8 +254,11 @@ export class RemoteServerService { }); } - private encryptPassword(password: string) { - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + private encryptPassword(password: string, workspaceId: string) { + const key = this.jwtWrapperService.generateAppSecret( + 'REMOTE_SERVER', + workspaceId, + ); return encryptText(password, key); } diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service.ts index 96fc92d53d..614cbb891c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service.ts @@ -1,28 +1,27 @@ -import { InjectRepository } from '@nestjs/typeorm'; import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; -import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; import { FieldMetadataEntity, FieldMetadataType, } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { createForeignKeyDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { createRelationForeignKeyFieldMetadataName } from 'src/engine/metadata-modules/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util'; +import { buildMigrationsToCreateRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-create-remote-table-relations.util'; +import { buildMigrationsToRemoveRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util'; +import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util'; +import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; +import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; import { - ACTIVITY_TARGET_STANDARD_FIELD_IDS, ATTACHMENT_STANDARD_FIELD_IDS, FAVORITE_STANDARD_FIELD_IDS, TIMELINE_ACTIVITY_STANDARD_FIELD_IDS, } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { buildMigrationsToCreateRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-create-remote-table-relations.util'; -import { buildMigrationsToRemoveRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util'; -import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util'; -import { createRelationForeignKeyFieldMetadataName } from 'src/engine/metadata-modules/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util'; +import { createForeignKeyDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util'; @Injectable() export class RemoteTableRelationsService { @@ -54,14 +53,6 @@ export class RemoteTableRelationsService { objectPrimaryKeyFieldSettings, ); - const activityTargetObjectMetadata = - await this.createActivityTargetRelation( - workspaceId, - remoteObjectMetadata, - objectPrimaryKeyFieldType, - objectPrimaryKeyFieldSettings, - ); - const attachmentObjectMetadata = await this.createAttachmentRelation( workspaceId, remoteObjectMetadata, @@ -87,7 +78,6 @@ export class RemoteTableRelationsService { remoteObjectMetadata.nameSingular, [ favoriteObjectMetadata, - activityTargetObjectMetadata, attachmentObjectMetadata, timelineActivityObjectMetadata, ], @@ -107,12 +97,6 @@ export class RemoteTableRelationsService { workspaceId: workspaceId, }); - const activityTargetObjectMetadata = - await this.objectMetadataRepository.findOneByOrFail({ - nameSingular: 'activityTarget', - workspaceId: workspaceId, - }); - const attachmentObjectMetadata = await this.objectMetadataRepository.findOneByOrFail({ nameSingular: 'attachment', @@ -136,7 +120,6 @@ export class RemoteTableRelationsService { name: targetColumnName, objectMetadataId: In([ favoriteObjectMetadata.id, - activityTargetObjectMetadata.id, attachmentObjectMetadata.id, timelineActivityObjectMetadata.id, ]), @@ -158,53 +141,12 @@ export class RemoteTableRelationsService { workspaceId, buildMigrationsToRemoveRemoteTableRelations(targetColumnName, [ favoriteObjectMetadata, - activityTargetObjectMetadata, attachmentObjectMetadata, timelineActivityObjectMetadata, ]), ); } - private async createActivityTargetRelation( - workspaceId: string, - createdObjectMetadata: ObjectMetadataEntity, - objectPrimaryKeyType: FieldMetadataType, - objectPrimaryKeyFieldSettings: - | FieldMetadataSettings - | undefined, - ) { - const activityTargetObjectMetadata = - await this.objectMetadataRepository.findOneByOrFail({ - nameSingular: 'activityTarget', - workspaceId: workspaceId, - }); - - await this.fieldMetadataRepository.save( - // Foreign key - { - standardId: createForeignKeyDeterministicUuid({ - objectId: createdObjectMetadata.id, - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.custom, - }), - objectMetadataId: activityTargetObjectMetadata.id, - workspaceId: workspaceId, - isCustom: false, - isActive: true, - type: objectPrimaryKeyType, - name: `${createdObjectMetadata.nameSingular}Id`, - label: `${createdObjectMetadata.labelSingular} ID (foreign key)`, - description: `ActivityTarget ${createdObjectMetadata.labelSingular} id foreign key`, - icon: undefined, - isNullable: true, - isSystem: true, - defaultValue: undefined, - settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true }, - }, - ); - - return activityTargetObjectMetadata; - } - private async createAttachmentRelation( workspaceId: string, createdObjectMetadata: ObjectMetadataEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util.ts index a928570d77..0084b3dbb7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util.ts @@ -1,9 +1,9 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { - WorkspaceMigrationTableAction, - WorkspaceMigrationTableActionType, WorkspaceMigrationColumnActionType, WorkspaceMigrationColumnDrop, + WorkspaceMigrationTableAction, + WorkspaceMigrationTableActionType, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { computeTableName } from 'src/engine/utils/compute-table-name.util'; diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 4ea5ba05ff..1b6b19e7ab 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -1,23 +1,24 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; +import { ExtractJwt } from 'passport-jwt'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; class GraphqlTokenValidationProxy { - private tokenService: TokenService; + private accessTokenService: AccessTokenService; - constructor(tokenService: TokenService) { - this.tokenService = tokenService; + constructor(accessTokenService: AccessTokenService) { + this.accessTokenService = accessTokenService; } async validateToken(req: Request) { try { - return await this.tokenService.validateToken(req); + return await this.accessTokenService.validateToken(req); } catch (error) { const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter(); @@ -31,7 +32,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware implements NestMiddleware { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, private readonly exceptionHandlerService: ExceptionHandlerService, ) {} @@ -59,7 +60,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware ]; if ( - !this.tokenService.isTokenPresent(req) && + !this.isTokenPresent(req) && (!body?.operationName || excludedOperations.includes(body.operationName)) ) { return next(); @@ -69,7 +70,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware try { const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy( - this.tokenService, + this.accessTokenService, ); data = await graphqlTokenValidationProxy.validateToken(req); @@ -103,4 +104,10 @@ export class GraphQLHydrateRequestFromTokenMiddleware next(); } + + isTokenPresent(request: Request): boolean { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + return !!token; + } } diff --git a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts index 993441f4d0..15ace6a4f5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts +++ b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts @@ -22,7 +22,6 @@ import { FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity'; @@ -71,19 +70,6 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity { }) createdBy: ActorMetadata; - @WorkspaceRelation({ - standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.activityTargets, - label: 'Activities', - type: RelationMetadataType.ONE_TO_MANY, - description: (objectMetadata) => - `Activities tied to the ${objectMetadata.labelSingular}`, - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityTargetWorkspaceEntity, - onDelete: RelationOnDeleteAction.CASCADE, - }) - @WorkspaceIsNullable() - activityTargets: ActivityTargetWorkspaceEntity[]; - @WorkspaceRelation({ standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.noteTargets, label: 'Notes', diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view.ts index fec9f63b0e..5ae78e3fd3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view.ts @@ -67,7 +67,7 @@ export const notesAllView = ( TODO: Add later, since we don't have real-time it probably doesn't work well? { fieldMetadataId: - objectMetadataMap[STANDARD_OBJECT_IDS.activity].fields[ + objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[ BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt ], position: 0, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory.ts index 3102956c1b..6085de1a0c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory.ts @@ -4,15 +4,16 @@ import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/wo import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { WorkspaceMigrationColumnActionType, WorkspaceMigrationEntity, WorkspaceMigrationTableAction, WorkspaceMigrationTableActionType, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; -import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; -import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; export interface ObjectMetadataUpdate { current: ObjectMetadataEntity; @@ -27,9 +28,7 @@ export class WorkspaceMigrationObjectFactory { async create( objectMetadataCollection: ObjectMetadataEntity[], - action: - | WorkspaceMigrationBuilderAction.CREATE - | WorkspaceMigrationBuilderAction.DELETE, + action: WorkspaceMigrationBuilderAction.CREATE, ): Promise[]>; async create( @@ -37,11 +36,24 @@ export class WorkspaceMigrationObjectFactory { action: WorkspaceMigrationBuilderAction.UPDATE, ): Promise[]>; + async create( + objectMetadataCollection: ObjectMetadataEntity[], + action: WorkspaceMigrationBuilderAction.DELETE, + relationMetadataByFromObjectMetadataId: Record< + string, + RelationMetadataEntity[] + >, + ): Promise[]>; + async create( objectMetadataCollectionOrObjectMetadataUpdateCollection: | ObjectMetadataEntity[] | ObjectMetadataUpdate[], action: WorkspaceMigrationBuilderAction, + relationMetadataByFromObjectMetadataId?: Record< + string, + RelationMetadataEntity[] + >, ): Promise[]> { switch (action) { case WorkspaceMigrationBuilderAction.CREATE: @@ -55,6 +67,10 @@ export class WorkspaceMigrationObjectFactory { case WorkspaceMigrationBuilderAction.DELETE: return this.deleteObjectMigration( objectMetadataCollectionOrObjectMetadataUpdateCollection as ObjectMetadataEntity[], + relationMetadataByFromObjectMetadataId as Record< + string, + RelationMetadataEntity[] + >, ); default: return []; @@ -136,22 +152,43 @@ export class WorkspaceMigrationObjectFactory { private async deleteObjectMigration( objectMetadataCollection: ObjectMetadataEntity[], + relationMetadataByFromObjectMetadataId: Record< + string, + RelationMetadataEntity[] + >, ): Promise[]> { const workspaceMigrations: Partial[] = []; for (const objectMetadata of objectMetadataCollection) { - const migrations: WorkspaceMigrationTableAction[] = [ - { - name: computeObjectTargetTable(objectMetadata), - action: WorkspaceMigrationTableActionType.DROP, - }, - ]; + const relationMetadataCollection = + relationMetadataByFromObjectMetadataId[objectMetadata.id]; workspaceMigrations.push({ workspaceId: objectMetadata.workspaceId, name: generateMigrationName(`delete-${objectMetadata.nameSingular}`), isCustom: false, - migrations, + migrations: [ + ...(relationMetadataCollection ?? []).map( + (relationMetadata) => + ({ + name: computeObjectTargetTable( + relationMetadata.toObjectMetadata, + ), + action: WorkspaceMigrationTableActionType.ALTER, + columns: [ + { + action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY, + columnName: `${relationMetadata.toFieldMetadata.name}Id`, + }, + ], + }) satisfies WorkspaceMigrationTableAction, + ), + { + name: computeObjectTargetTable(objectMetadata), + action: WorkspaceMigrationTableActionType.DROP, + columns: [], + } satisfies WorkspaceMigrationTableAction, + ], }); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 9855da668d..999ba41e3e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -5,6 +5,7 @@ * For readability keys can be edited but the values should not be changed. */ +// TODO: check if this can be deleted export const ACTIVITY_TARGET_STANDARD_FIELD_IDS = { activity: '20202020-ca58-478c-a4f5-ae825671c30e', person: '20202020-4afd-4ae7-99c2-de57d795a93f', @@ -13,6 +14,7 @@ export const ACTIVITY_TARGET_STANDARD_FIELD_IDS = { custom: '20202020-7f21-442f-94be-32462281b1ca', }; +// TODO: check if this can be deleted export const ACTIVITY_STANDARD_FIELD_IDS = { title: '20202020-24a1-4d94-a071-617f3eeed7b0', body: '20202020-209b-440a-b2a8-043fa36a7d37', @@ -109,6 +111,7 @@ export const CALENDAR_EVENT_STANDARD_FIELD_IDS = { calendarEventParticipants: '20202020-e07e-4ccb-88f5-6f3d00458eec', }; +// TODO: check if this can be deleted export const COMMENT_STANDARD_FIELD_IDS = { body: '20202020-d5eb-49d2-b3e0-1ed04145ebb7', author: '20202020-2ab1-427e-a981-cf089de3a9bd', @@ -129,6 +132,7 @@ export const COMPANY_STANDARD_FIELD_IDS = { createdBy: '20202020-fabc-451d-ab7d-412170916baa', people: '20202020-3213-4ddf-9494-6422bcff8d7c', accountOwner: '20202020-95b8-4e10-9881-edb5d4765f9d', + // TODO: check if activityTargets field can be deleted activityTargets: '20202020-c2a5-4c9b-9d9a-582bcd57fbc8', taskTargets: '20202020-cb17-4a61-8f8f-3be6730480de', noteTargets: '20202020-bae0-4556-a74a-a9c686f77a88', @@ -304,6 +308,7 @@ export const OPPORTUNITY_STANDARD_FIELD_IDS = { pointOfContact: '20202020-8dfb-42fc-92b6-01afb759ed16', company: '20202020-cbac-457e-b565-adece5fc815f', favorites: '20202020-a1c2-4500-aaae-83ba8a0e827a', + // TODO: check if activityTargets field can be deleted activityTargets: '20202020-220a-42d6-8261-b2102d6eab35', taskTargets: '20202020-59c0-4179-a208-4a255f04a5be', noteTargets: '20202020-dd3f-42d5-a382-db58aabf43d3', @@ -327,6 +332,7 @@ export const PERSON_STANDARD_FIELD_IDS = { createdBy: '20202020-f6ab-4d98-af24-a3d5b664148a', company: '20202020-e2f3-448e-b34c-2d625f0025fd', pointOfContactForOpportunities: '20202020-911b-4a7d-b67b-918aa9a5b33a', + // TODO: check if activityTargets field can be deleted activityTargets: '20202020-dee7-4b7f-b50a-1f50bd3be452', taskTargets: '20202020-584b-4d3e-88b6-53ab1fa03c3a', noteTargets: '20202020-c8fc-4258-8250-15905d3fcfec', @@ -499,6 +505,7 @@ export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = { name: '20202020-ba07-4ffd-ba63-009491f5749c', position: '20202020-c2bd-4e16-bb9a-c8b0411bf49d', createdBy: '20202020-be0e-4971-865b-32ca87cbb315', + // TODO: check if activityTargets field can be deleted activityTargets: '20202020-7f42-40ae-b96c-c8a61acc83bf', noteTargets: '20202020-01fd-4f37-99dc-9427a444018a', taskTargets: '20202020-0860-4566-b865-bff3c626c303', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts index 401119bf55..d57ee13e34 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts @@ -6,6 +6,7 @@ */ export const STANDARD_OBJECT_IDS = { + // TODO: check if activity, activityTarget and comment can be deleted activityTarget: '20202020-2945-440e-8d1a-f84672d33d5e', activity: '20202020-39aa-4a89-843b-eb5f2a8b677f', apiKey: '20202020-4c00-401d-8cda-ec6a4c41cd7d', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service.ts index 8f3df062af..5c9261e440 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service.ts @@ -6,6 +6,7 @@ import { FindOptionsWhere, In, ObjectLiteral, + Repository, } from 'typeorm'; import { DeepPartial } from 'typeorm/common/DeepPartial'; import { v4 as uuidV4 } from 'uuid'; @@ -127,6 +128,11 @@ export class WorkspaceMetadataUpdaterService { updatedFieldMetadataCollection: FieldMetadataUpdate[]; }> { const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity); + const indexFieldMetadataRepository = manager.getRepository( + IndexFieldMetadataEntity, + ); + const indexMetadataRepository = manager.getRepository(IndexMetadataEntity); + /** * Update field metadata */ @@ -157,6 +163,12 @@ export class WorkspaceMetadataUpdaterService { ); if (fieldMetadataDeleteCollectionWithoutRelationType.length > 0) { + await this.deleteIndexFieldMetadata( + fieldMetadataDeleteCollectionWithoutRelationType, + indexFieldMetadataRepository, + indexMetadataRepository, + ); + await fieldMetadataRepository.delete( fieldMetadataDeleteCollectionWithoutRelationType.map( (field) => field.id, @@ -171,6 +183,33 @@ export class WorkspaceMetadataUpdaterService { }; } + async deleteIndexFieldMetadata( + fieldMetadataDeleteCollectionWithoutRelationType: Partial[], + indexFieldMetadataRepository: Repository, + indexMetadataRepository: Repository, + ) { + const indexFieldMetadatas = await indexFieldMetadataRepository.find({ + where: { + fieldMetadataId: In( + fieldMetadataDeleteCollectionWithoutRelationType.map( + (field) => field.id, + ), + ), + }, + relations: { + indexMetadata: true, + }, + }); + + const uniqueIndexMetadataIds = [ + ...new Set(indexFieldMetadatas.map((field) => field.indexMetadataId)), + ]; + + if (uniqueIndexMetadataIds.length > 0) { + await indexMetadataRepository.delete(uniqueIndexMetadataIds); + } + } + async updateRelationMetadata( manager: EntityManager, storage: WorkspaceSyncStorage, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts index f93b495924..1c056e5d1c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts @@ -8,6 +8,7 @@ import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-me import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationObjectFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory'; import { WorkspaceObjectComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator'; @@ -37,6 +38,10 @@ export class WorkspaceSyncObjectMetadataService { const objectMetadataRepository = manager.getRepository(ObjectMetadataEntity); + const relationMetadataRepository = manager.getRepository( + RelationMetadataEntity, + ); + // Retrieve object metadata collection from DB const originalObjectMetadataCollection = await objectMetadataRepository.find({ @@ -47,6 +52,33 @@ export class WorkspaceSyncObjectMetadataService { relations: ['dataSource', 'fields'], }); + // Retrieve relation metadata collection from DB + const originalRelationMetadataCollection = + await relationMetadataRepository.find({ + where: { + workspaceId: context.workspaceId, + }, + relations: ['toObjectMetadata', 'toFieldMetadata'], + }); + + const relationMetadataByFromObjectMetadataId: Record< + string, + RelationMetadataEntity[] + > = originalRelationMetadataCollection.reduce( + (acc, relationMetadata) => { + const fromObjectMetadataId = relationMetadata.fromObjectMetadataId; + + if (!acc[fromObjectMetadataId]) { + acc[fromObjectMetadataId] = []; + } + + acc[fromObjectMetadataId].push(relationMetadata); + + return acc; + }, + {} as Record, + ); + // Create standard object metadata collection const standardObjectMetadataCollection = this.standardObjectFactory.create( standardObjectMetadataDefinitions, @@ -129,6 +161,7 @@ export class WorkspaceSyncObjectMetadataService { await this.workspaceMigrationObjectFactory.create( storage.objectMetadataDeleteCollection, WorkspaceMigrationBuilderAction.DELETE, + relationMetadataByFromObjectMetadataId, ); this.logger.log('Saving migrations'); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index 4e3142504e..f19ff8adca 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -1,6 +1,3 @@ -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; -import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/activity.workspace-entity'; -import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; @@ -41,8 +38,6 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta // TODO: Maybe we should automate this with the DiscoverService of Nest.JS export const standardObjectMetadataDefinitions = [ - ActivityTargetWorkspaceEntity, - ActivityWorkspaceEntity, ApiKeyWorkspaceEntity, AuditLogWorkspaceEntity, AttachmentWorkspaceEntity, @@ -52,7 +47,6 @@ export const standardObjectMetadataDefinitions = [ CalendarChannelWorkspaceEntity, CalendarChannelEventAssociationWorkspaceEntity, CalendarEventParticipantWorkspaceEntity, - CommentWorkspaceEntity, CompanyWorkspaceEntity, ConnectedAccountWorkspaceEntity, FavoriteWorkspaceEntity, diff --git a/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts deleted file mode 100644 index 13d3e7b563..0000000000 --- a/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; - -import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; -import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; -import { WorkspaceDynamicRelation } from 'src/engine/twenty-orm/decorators/workspace-dynamic-relation.decorator'; -import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; -import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; -import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; -import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; -import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; -import { ACTIVITY_TARGET_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; -import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/activity.workspace-entity'; -import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; -import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; -import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; - -@WorkspaceEntity({ - standardId: STANDARD_OBJECT_IDS.activityTarget, - namePlural: 'activityTargets', - labelSingular: 'Activity Target', - labelPlural: 'Activity Targets', - description: 'An activity target', - icon: STANDARD_OBJECT_ICONS.activityTarget, -}) -@WorkspaceIsSystem() -export class ActivityTargetWorkspaceEntity extends BaseWorkspaceEntity { - @WorkspaceRelation({ - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.activity, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Activity', - description: 'ActivityTarget activity', - icon: 'IconNotes', - inverseSideTarget: () => ActivityWorkspaceEntity, - inverseSideFieldKey: 'activityTargets', - }) - @WorkspaceIsNullable() - activity: Relation | null; - - @WorkspaceJoinColumn('activity') - activityId: string | null; - - @WorkspaceRelation({ - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.person, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Person', - description: 'ActivityTarget person', - icon: 'IconUser', - inverseSideTarget: () => PersonWorkspaceEntity, - inverseSideFieldKey: 'activityTargets', - }) - @WorkspaceIsNullable() - person: Relation | null; - - @WorkspaceJoinColumn('person') - personId: string | null; - - @WorkspaceRelation({ - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.company, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Company', - description: 'ActivityTarget company', - icon: 'IconBuildingSkyscraper', - inverseSideTarget: () => CompanyWorkspaceEntity, - inverseSideFieldKey: 'activityTargets', - }) - @WorkspaceIsNullable() - company: Relation | null; - - @WorkspaceJoinColumn('company') - companyId: string | null; - - @WorkspaceRelation({ - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.opportunity, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Opportunity', - description: 'ActivityTarget opportunity', - icon: 'IconTargetArrow', - inverseSideTarget: () => OpportunityWorkspaceEntity, - inverseSideFieldKey: 'activityTargets', - }) - @WorkspaceIsNullable() - opportunity: Relation | null; - - @WorkspaceJoinColumn('opportunity') - opportunityId: string | null; - - @WorkspaceDynamicRelation({ - type: RelationMetadataType.MANY_TO_ONE, - argsFactory: (oppositeObjectMetadata) => ({ - standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.custom, - name: oppositeObjectMetadata.nameSingular, - label: oppositeObjectMetadata.labelSingular, - description: `ActivityTarget ${oppositeObjectMetadata.labelSingular}`, - joinColumn: `${oppositeObjectMetadata.nameSingular}Id`, - icon: 'IconBuildingSkyscraper', - }), - inverseSideTarget: () => CustomWorkspaceEntity, - inverseSideFieldKey: 'activityTargets', - }) - custom: Relation; -} diff --git a/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts deleted file mode 100644 index e77828f257..0000000000 --- a/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; - -import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { - RelationMetadataType, - RelationOnDeleteAction, -} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; -import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; -import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; -import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; -import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; -import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; -import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; -import { ACTIVITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; -import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; -import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity'; -import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; - -@WorkspaceEntity({ - standardId: STANDARD_OBJECT_IDS.activity, - namePlural: 'activities', - labelSingular: 'Activity', - labelPlural: 'Activities', - description: 'An activity', - icon: STANDARD_OBJECT_ICONS.activity, - labelIdentifierStandardId: ACTIVITY_STANDARD_FIELD_IDS.title, -}) -@WorkspaceIsSystem() -export class ActivityWorkspaceEntity extends BaseWorkspaceEntity { - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.title, - type: FieldMetadataType.TEXT, - label: 'Title', - description: 'Activity title', - icon: 'IconNotes', - }) - title: string; - - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.body, - type: FieldMetadataType.TEXT, - label: 'Body', - description: 'Activity body', - icon: 'IconList', - }) - body: string; - - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.type, - type: FieldMetadataType.TEXT, - label: 'Type', - description: 'Activity type', - icon: 'IconCheckbox', - defaultValue: "'Note'", - }) - type: string; - - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.reminderAt, - type: FieldMetadataType.DATE_TIME, - label: 'Reminder Date', - description: 'Activity reminder date', - icon: 'IconCalendarEvent', - }) - @WorkspaceIsNullable() - reminderAt: Date | null; - - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.dueAt, - type: FieldMetadataType.DATE_TIME, - label: 'Due Date', - description: 'Activity due date', - icon: 'IconCalendarEvent', - }) - @WorkspaceIsNullable() - dueAt: Date | null; - - @WorkspaceField({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.completedAt, - type: FieldMetadataType.DATE_TIME, - label: 'Completion Date', - description: 'Activity completion date', - icon: 'IconCheck', - }) - @WorkspaceIsNullable() - completedAt: Date | null; - - @WorkspaceRelation({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.activityTargets, - label: 'Targets', - description: 'Activity targets', - icon: 'IconCheckbox', - type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => ActivityTargetWorkspaceEntity, - onDelete: RelationOnDeleteAction.SET_NULL, - }) - @WorkspaceIsNullable() - activityTargets: Relation; - - @WorkspaceRelation({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.attachments, - label: 'Attachments', - description: 'Activity attachments', - icon: 'IconFileImport', - type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => AttachmentWorkspaceEntity, - onDelete: RelationOnDeleteAction.SET_NULL, - }) - @WorkspaceIsNullable() - attachments: Relation; - - @WorkspaceRelation({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.comments, - label: 'Comments', - description: 'Activity comments', - icon: 'IconComment', - type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => CommentWorkspaceEntity, - onDelete: RelationOnDeleteAction.CASCADE, - }) - @WorkspaceIsNullable() - comments: Relation; - - @WorkspaceRelation({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.author, - label: 'Author', - description: 'Activity author', - icon: 'IconUserCircle', - type: RelationMetadataType.MANY_TO_ONE, - inverseSideTarget: () => WorkspaceMemberWorkspaceEntity, - inverseSideFieldKey: 'authoredActivities', - onDelete: RelationOnDeleteAction.SET_NULL, - }) - @WorkspaceIsNullable() - author: Relation | null; - - @WorkspaceJoinColumn('author') - authorId: string | null; - - @WorkspaceRelation({ - standardId: ACTIVITY_STANDARD_FIELD_IDS.assignee, - label: 'Assignee', - description: 'Activity assignee', - icon: 'IconUserCircle', - type: RelationMetadataType.MANY_TO_ONE, - inverseSideTarget: () => WorkspaceMemberWorkspaceEntity, - inverseSideFieldKey: 'assignedActivities', - onDelete: RelationOnDeleteAction.SET_NULL, - }) - @WorkspaceIsNullable() - assignee: Relation | null; - - @WorkspaceJoinColumn('assignee') - assigneeId: string | null; -} diff --git a/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts deleted file mode 100644 index bb849854e1..0000000000 --- a/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; - -import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; -import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; -import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; -import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; -import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; -import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; -import { COMMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; -import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/activity.workspace-entity'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; - -@WorkspaceEntity({ - standardId: STANDARD_OBJECT_IDS.comment, - namePlural: 'comments', - labelSingular: 'Comment', - labelPlural: 'Comments', - description: 'A comment', - icon: STANDARD_OBJECT_ICONS.comment, -}) -@WorkspaceIsSystem() -export class CommentWorkspaceEntity extends BaseWorkspaceEntity { - @WorkspaceField({ - standardId: COMMENT_STANDARD_FIELD_IDS.body, - type: FieldMetadataType.TEXT, - label: 'Body', - description: 'Comment body', - icon: 'IconLink', - }) - body: string; - - @WorkspaceRelation({ - standardId: COMMENT_STANDARD_FIELD_IDS.author, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Author', - description: 'Comment author', - icon: 'IconCircleUser', - inverseSideTarget: () => WorkspaceMemberWorkspaceEntity, - inverseSideFieldKey: 'authoredComments', - }) - author: Relation; - - @WorkspaceJoinColumn('author') - authorId: string; - - @WorkspaceRelation({ - standardId: COMMENT_STANDARD_FIELD_IDS.activity, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Activity', - description: 'Comment activity', - icon: 'IconNotes', - inverseSideTarget: () => ActivityWorkspaceEntity, - inverseSideFieldKey: 'comments', - }) - activity: Relation; - - @WorkspaceJoinColumn('activity') - activityId: string; -} diff --git a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts index d6bcc67359..b47716028f 100644 --- a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts +++ b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts @@ -15,7 +15,6 @@ import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-re import { ATTACHMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/activity.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; @@ -76,21 +75,6 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceJoinColumn('author') authorId: string; - @WorkspaceRelation({ - standardId: ATTACHMENT_STANDARD_FIELD_IDS.activity, - type: RelationMetadataType.MANY_TO_ONE, - label: 'Activity', - description: 'Attachment activity', - icon: 'IconNotes', - inverseSideTarget: () => ActivityWorkspaceEntity, - inverseSideFieldKey: 'attachments', - }) - @WorkspaceIsNullable() - activity: Relation | null; - - @WorkspaceJoinColumn('activity') - activityId: string | null; - @WorkspaceRelation({ standardId: ATTACHMENT_STANDARD_FIELD_IDS.task, type: RelationMetadataType.MANY_TO_ONE, diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index ccf9c6be73..898010b9b7 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -30,7 +30,6 @@ import { FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity'; @@ -198,19 +197,6 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceJoinColumn('accountOwner') accountOwnerId: string | null; - @WorkspaceRelation({ - standardId: COMPANY_STANDARD_FIELD_IDS.activityTargets, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Activities', - description: 'Activities tied to the company', - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityTargetWorkspaceEntity, - onDelete: RelationOnDeleteAction.CASCADE, - }) - @WorkspaceIsNullable() - @WorkspaceIsSystem() - activityTargets: Relation; - @WorkspaceRelation({ standardId: COMPANY_STANDARD_FIELD_IDS.taskTargets, type: RelationMetadataType.ONE_TO_MANY, diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index a3bb717d9b..051f37a2ae 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -29,7 +29,6 @@ import { FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; @@ -177,19 +176,6 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsSystem() favorites: Relation; - @WorkspaceRelation({ - standardId: OPPORTUNITY_STANDARD_FIELD_IDS.activityTargets, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Activities', - description: 'Activities tied to the opportunity', - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityTargetWorkspaceEntity, - onDelete: RelationOnDeleteAction.CASCADE, - }) - @WorkspaceIsNullable() - @WorkspaceIsSystem() - activityTargets: Relation; - @WorkspaceRelation({ standardId: OPPORTUNITY_STANDARD_FIELD_IDS.taskTargets, type: RelationMetadataType.ONE_TO_MANY, diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index e42ccace2a..4f2256d2e1 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -32,7 +32,6 @@ import { FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; -import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; @@ -205,18 +204,6 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { }) pointOfContactForOpportunities: Relation; - @WorkspaceRelation({ - standardId: PERSON_STANDARD_FIELD_IDS.activityTargets, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Activities', - description: 'Activities tied to the contact', - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityTargetWorkspaceEntity, - onDelete: RelationOnDeleteAction.CASCADE, - }) - @WorkspaceIsSystem() - activityTargets: Relation; - @WorkspaceRelation({ standardId: PERSON_STANDARD_FIELD_IDS.taskTargets, type: RelationMetadataType.ONE_TO_MANY, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts index 9c53c69592..18ad2bbaa3 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook.ts @@ -2,10 +2,9 @@ import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-que import { DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; -import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity'; -import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; @WorkspaceQueryHook(`workspaceMember.deleteOne`) export class WorkspaceMemberDeleteOnePreQueryHook @@ -24,21 +23,12 @@ export class WorkspaceMemberDeleteOnePreQueryHook 'attachment', ); - const commentRepository = - await this.twentyORMManager.getRepository( - 'comment', - ); - const authorId = payload.id; await attachmentRepository.delete({ authorId, }); - await commentRepository.delete({ - authorId, - }); - return payload; } } diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index cb6797c7ab..d17ac395a7 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -27,8 +27,6 @@ import { FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; -import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/activity.workspace-entity'; -import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; @@ -142,30 +140,6 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity { userId: string; // Relations - @WorkspaceRelation({ - standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.authoredActivities, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Authored activities', - description: 'Activities created by the workspace member', - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityWorkspaceEntity, - inverseSideFieldKey: 'author', - onDelete: RelationOnDeleteAction.SET_NULL, - }) - authoredActivities: Relation; - - @WorkspaceRelation({ - standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.assignedActivities, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Assigned activities', - description: 'Activities assigned to the workspace member', - icon: 'IconCheckbox', - inverseSideTarget: () => ActivityWorkspaceEntity, - inverseSideFieldKey: 'assignee', - onDelete: RelationOnDeleteAction.SET_NULL, - }) - assignedActivities: Relation; - @WorkspaceRelation({ standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.assignedTasks, type: RelationMetadataType.ONE_TO_MANY, @@ -227,18 +201,6 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity { }) authoredAttachments: Relation; - @WorkspaceRelation({ - standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.authoredComments, - type: RelationMetadataType.ONE_TO_MANY, - label: 'Authored comments', - description: 'Authored comments', - icon: 'IconComment', - inverseSideTarget: () => CommentWorkspaceEntity, - inverseSideFieldKey: 'author', - onDelete: RelationOnDeleteAction.SET_NULL, - }) - authoredComments: Relation; - @WorkspaceRelation({ standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.connectedAccounts, type: RelationMetadataType.ONE_TO_MANY, diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/activities.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/activities.integration-spec.ts deleted file mode 100644 index 0e0134578e..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/activities.integration-spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('activitiesResolver (e2e)', () => { - it('should find many activities', () => { - const queryData = { - query: ` - query activities { - activities { - edges { - node { - title - body - type - reminderAt - dueAt - completedAt - id - createdAt - updatedAt - deletedAt - authorId - assigneeId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.activities; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const activities = edges[0].node; - - expect(activities).toHaveProperty('title'); - expect(activities).toHaveProperty('body'); - expect(activities).toHaveProperty('type'); - expect(activities).toHaveProperty('reminderAt'); - expect(activities).toHaveProperty('dueAt'); - expect(activities).toHaveProperty('completedAt'); - expect(activities).toHaveProperty('id'); - expect(activities).toHaveProperty('createdAt'); - expect(activities).toHaveProperty('updatedAt'); - expect(activities).toHaveProperty('deletedAt'); - expect(activities).toHaveProperty('authorId'); - expect(activities).toHaveProperty('assigneeId'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/activity-targets.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/activity-targets.integration-spec.ts deleted file mode 100644 index 99b4f0b1e1..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/activity-targets.integration-spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('activityTargetsResolver (e2e)', () => { - it('should find many activityTargets', () => { - const queryData = { - query: ` - query activityTargets { - activityTargets { - edges { - node { - id - createdAt - updatedAt - deletedAt - activityId - personId - companyId - opportunityId - rocketId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.activityTargets; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const activityTargets = edges[0].node; - - expect(activityTargets).toHaveProperty('id'); - expect(activityTargets).toHaveProperty('createdAt'); - expect(activityTargets).toHaveProperty('updatedAt'); - expect(activityTargets).toHaveProperty('deletedAt'); - expect(activityTargets).toHaveProperty('activityId'); - expect(activityTargets).toHaveProperty('personId'); - expect(activityTargets).toHaveProperty('companyId'); - expect(activityTargets).toHaveProperty('opportunityId'); - expect(activityTargets).toHaveProperty('rocketId'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/attachments.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/attachments.integration-spec.ts index fc96379633..9c2b7a75b7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/attachments.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/attachments.integration-spec.ts @@ -18,7 +18,6 @@ describe('attachmentsResolver (e2e)', () => { updatedAt deletedAt authorId - activityId taskId noteId personId @@ -60,7 +59,6 @@ describe('attachmentsResolver (e2e)', () => { expect(attachments).toHaveProperty('updatedAt'); expect(attachments).toHaveProperty('deletedAt'); expect(attachments).toHaveProperty('authorId'); - expect(attachments).toHaveProperty('activityId'); expect(attachments).toHaveProperty('taskId'); expect(attachments).toHaveProperty('noteId'); expect(attachments).toHaveProperty('personId'); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/comments.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/comments.integration-spec.ts deleted file mode 100644 index 2508ff628a..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/comments.integration-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('commentsResolver (e2e)', () => { - it('should find many comments', () => { - const queryData = { - query: ` - query comments { - comments { - edges { - node { - body - id - createdAt - updatedAt - deletedAt - authorId - activityId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.comments; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const comments = edges[0].node; - - expect(comments).toHaveProperty('body'); - expect(comments).toHaveProperty('id'); - expect(comments).toHaveProperty('createdAt'); - expect(comments).toHaveProperty('updatedAt'); - expect(comments).toHaveProperty('deletedAt'); - expect(comments).toHaveProperty('authorId'); - expect(comments).toHaveProperty('activityId'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activities.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activities.integration-spec.ts deleted file mode 100644 index 7d9be33624..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activities.integration-spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('searchActivitiesResolver (e2e)', () => { - it('should find many searchActivities', () => { - const queryData = { - query: ` - query searchActivities { - searchActivities { - edges { - node { - title - body - type - reminderAt - dueAt - completedAt - id - createdAt - updatedAt - deletedAt - authorId - assigneeId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.searchActivities; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const searchActivities = edges[0].node; - - expect(searchActivities).toHaveProperty('title'); - expect(searchActivities).toHaveProperty('body'); - expect(searchActivities).toHaveProperty('type'); - expect(searchActivities).toHaveProperty('reminderAt'); - expect(searchActivities).toHaveProperty('dueAt'); - expect(searchActivities).toHaveProperty('completedAt'); - expect(searchActivities).toHaveProperty('id'); - expect(searchActivities).toHaveProperty('createdAt'); - expect(searchActivities).toHaveProperty('updatedAt'); - expect(searchActivities).toHaveProperty('deletedAt'); - expect(searchActivities).toHaveProperty('authorId'); - expect(searchActivities).toHaveProperty('assigneeId'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activity-targets.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activity-targets.integration-spec.ts deleted file mode 100644 index 64b5fa8c2f..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-activity-targets.integration-spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('searchActivityTargetsResolver (e2e)', () => { - it('should find many searchActivityTargets', () => { - const queryData = { - query: ` - query searchActivityTargets { - searchActivityTargets { - edges { - node { - id - createdAt - updatedAt - deletedAt - activityId - personId - companyId - opportunityId - rocketId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.searchActivityTargets; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const searchActivityTargets = edges[0].node; - - expect(searchActivityTargets).toHaveProperty('id'); - expect(searchActivityTargets).toHaveProperty('createdAt'); - expect(searchActivityTargets).toHaveProperty('updatedAt'); - expect(searchActivityTargets).toHaveProperty('deletedAt'); - expect(searchActivityTargets).toHaveProperty('activityId'); - expect(searchActivityTargets).toHaveProperty('personId'); - expect(searchActivityTargets).toHaveProperty('companyId'); - expect(searchActivityTargets).toHaveProperty('opportunityId'); - expect(searchActivityTargets).toHaveProperty('rocketId'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-attachments.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/search-attachments.integration-spec.ts index 1debda9ff4..8ec16c5943 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-attachments.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/search-attachments.integration-spec.ts @@ -18,7 +18,6 @@ describe('searchAttachmentsResolver (e2e)', () => { updatedAt deletedAt authorId - activityId taskId noteId personId @@ -60,7 +59,6 @@ describe('searchAttachmentsResolver (e2e)', () => { expect(searchAttachments).toHaveProperty('updatedAt'); expect(searchAttachments).toHaveProperty('deletedAt'); expect(searchAttachments).toHaveProperty('authorId'); - expect(searchAttachments).toHaveProperty('activityId'); expect(searchAttachments).toHaveProperty('taskId'); expect(searchAttachments).toHaveProperty('noteId'); expect(searchAttachments).toHaveProperty('personId'); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-comments.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/search-comments.integration-spec.ts deleted file mode 100644 index 549f1d3011..0000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/search-comments.integration-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('searchCommentsResolver (e2e)', () => { - it('should find many searchComments', () => { - const queryData = { - query: ` - query searchComments { - searchComments { - edges { - node { - body - id - createdAt - updatedAt - deletedAt - authorId - activityId - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.searchComments; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const searchComments = edges[0].node; - - expect(searchComments).toHaveProperty('body'); - expect(searchComments).toHaveProperty('id'); - expect(searchComments).toHaveProperty('createdAt'); - expect(searchComments).toHaveProperty('updatedAt'); - expect(searchComments).toHaveProperty('deletedAt'); - expect(searchComments).toHaveProperty('authorId'); - expect(searchComments).toHaveProperty('activityId'); - } - }); - }); -}); diff --git a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx index 38cddead7c..8c40c0b313 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx @@ -5,10 +5,9 @@ image: /images/user-guide/notes/notes_header.png --- -This document is maintained by the community. It might contain issues. + This document is maintained by the community. It might contain issues. - ## Kubernetes via Terraform and Manifests Community-led documentation for Kubernetes deployment is available [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s) @@ -19,14 +18,12 @@ Community-led, might not be up to date [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/twentyhq/twenty) - -## RepoCloud +## RepoCloud Community-led, might not be up to date [![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=259) - ## Azure Container Apps Community-led, might not be up to date @@ -271,11 +268,8 @@ resource "azapi_update_resource" "cors" { ```hcl # backend.tf -# Create three random UUIDs -resource "random_uuid" "access_token_secret" {} -resource "random_uuid" "login_token_secret" {} -resource "random_uuid" "refresh_token_secret" {} -resource "random_uuid" "file_token_secret" {} +# Create a random UUID +resource "random_uuid" "app_secret" {} resource "azurerm_container_app" "twenty_server" { name = local.server_name @@ -343,20 +337,8 @@ resource "azurerm_container_app" "twenty_server" { value = "https://${local.front_app_name}" } env { - name = "ACCESS_TOKEN_SECRET" - value = random_uuid.access_token_secret.result - } - env { - name = "LOGIN_TOKEN_SECRET" - value = random_uuid.login_token_secret.result - } - env { - name = "REFRESH_TOKEN_SECRET" - value = random_uuid.refresh_token_secret.result - } - env { - name = "FILE_TOKEN_SECRET" - value = random_uuid.file_token_secret.result + name = "APP_SECRET" + value = random_uuid.app_secret.result } } } @@ -446,4 +428,4 @@ resource "azurerm_container_app" "twenty_db" { Please feel free to Open a PR to add more Cloud Provider options. - \ No newline at end of file + diff --git a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx index 14aa5821c9..a6d79bbb2e 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx @@ -50,23 +50,19 @@ Follow these steps for a manual setup. 2. **Generate Secret Tokens** - Run the following command four times to generate four unique random strings: + Run the following command to generate a unique random string: ```bash openssl rand -base64 32 ``` - **Important:** Keep these tokens secure and do not share them. + **Important:** Keep this value secret / do not share it. 3. **Update the `.env`** - Replace the placeholder values in your .env file with the generated tokens: + Replace the placeholder value in your .env file with the generated token: ```ini - ACCESS_TOKEN_SECRET=first_random_string - LOGIN_TOKEN_SECRET=second_random_string - REFRESH_TOKEN_SECRET=third_random_string - FILE_TOKEN_SECRET=fourth_random_string + APP_SECRET=first_random_string ``` - **Note:** Only modify these lines unless instructed otherwise. 4. **Set the Postgres Password** diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index 2465a2f459..c36497b258 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -51,14 +51,11 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ### Tokens ', 'Secret used for the access tokens'], + ['APP_SECRET', '', 'Secret used for encryption across the app'], ['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'], - ['LOGIN_TOKEN_SECRET', '', 'Secret used for the login tokens'], ['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'], - ['REFRESH_TOKEN_SECRET', '', 'Secret used for the refresh tokens'], ['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'], ['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'], - ['FILE_TOKEN_SECRET', '', 'Secret used for the file tokens'], ['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'], ['API_TOKEN_EXPIRES_IN', '1000y', 'API token expiration time'], ]}> diff --git a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx index 3b4ff61231..2b8182cb53 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx @@ -103,7 +103,7 @@ The `yarn command:prod upgrade-0.31` takes care of the data migration of all wor ### Environment Variables -The following environment variables have been changed: +We have updated the way we handle the Redis connection. - Removed: `REDIS_HOST`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD` - Added: `REDIS_URL` @@ -111,3 +111,10 @@ The following environment variables have been changed: Update your `.env` file to use the new `REDIS_URL` variable instead of the individual Redis connection parameters. + +We have also simplifed the way we handle the JWT tokens. + +- Removed: `ACCESS_TOKEN_SECRET`, `LOGIN_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `FILE_TOKEN_SECRET` +- Added: `APP_SECRET` + +Update your `.env` file to use the new `APP_SECRET` variable instead of the individual tokens secrets (you can use the same secret as before or generate a new random string) diff --git a/render.yaml b/render.yaml index 580cd4a26c..3d31f47f5b 100644 --- a/render.yaml +++ b/render.yaml @@ -18,13 +18,7 @@ services: name: server type: web envVarKey: RENDER_EXTERNAL_URL - - key: ACCESS_TOKEN_SECRET - generateValue: true - - key: LOGIN_TOKEN_SECRET - generateValue: true - - key: REFRESH_TOKEN_SECRET - generateValue: true - - key: FILE_TOKEN_SECRET + - key: APP_SECRET generateValue: true - key: PG_DATABASE_HOST fromService: @@ -55,13 +49,7 @@ services: name: server type: web envVarKey: RENDER_EXTERNAL_URL - - key: ACCESS_TOKEN_SECRET - generateValue: true - - key: LOGIN_TOKEN_SECRET - generateValue: true - - key: REFRESH_TOKEN_SECRET - generateValue: true - - key: FILE_TOKEN_SECRET + - key: APP_SECRET generateValue: true - key: PG_DATABASE_HOST fromService: