From 267ecb12db90c003abbbfb23a16114747a460203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 3 Aug 2026 19:54:54 +0200 Subject: [PATCH] Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642) --- .github/workflows/ci-server.yaml | 2 + .../src/metadata/generated/schema.graphql | 180 +- .../src/metadata/generated/schema.ts | 488 +++--- .../src/metadata/generated/types.ts | 1451 +++++++++-------- .../self-host/capabilities/setup.mdx | 21 + .../src/generated-metadata/graphql.ts | 78 +- ...sePageChangeEffectNavigateLocation.test.ts | 510 +++--- .../usePageChangeEffectNavigateLocation.ts | 10 +- .../modules/apollo/hooks/useApolloFactory.ts | 3 + .../modules/apollo/services/apollo.factory.ts | 19 +- .../utils/__tests__/streamingRestLink.test.ts | 87 + .../utils/isUnauthenticatedGraphQLError.ts | 9 + .../modules/apollo/utils/streamingRestLink.ts | 3 + .../app/components/SharedAppProviders.tsx | 2 + .../components/__tests__/DomainShell.test.tsx | 4 + .../components/VerifyLoginTokenEffect.tsx | 6 +- .../CookieSessionBootEffect.tsx | 154 ++ .../CookieSessionBootEffect.test.tsx | 143 ++ .../modules/auth/graphql/mutations/signOut.ts | 7 + .../graphql/mutations/stopImpersonation.ts | 9 + .../__tests__/useHasAccessTokenPair.test.ts | 41 - .../auth/hooks/__tests__/useIsLogged.test.ts | 63 + .../src/modules/auth/hooks/useAuth.ts | 25 +- .../auth/hooks/useHasAccessTokenPair.ts | 7 - .../auth/hooks/useImpersonationSession.ts | 64 +- .../src/modules/auth/hooks/useIsLogged.ts | 10 + .../src/modules/auth/services/AuthService.ts | 2 +- .../services/__tests__/AuthService.test.ts | 19 + .../SignInUpGlobalScopeFormEffect.tsx | 8 +- .../auth/states/currentUserWorkspaceState.ts | 2 +- .../auth/states/isCookieAuthActiveState.ts | 8 + .../auth/states/isImpersonatingState.ts | 22 +- .../states/isPendingServerSignOutState.ts | 11 + .../client-config/hooks/useClientConfig.ts | 6 + .../states/isCookieSessionEnabledState.ts | 6 + .../client-config/types/ClientConfig.ts | 1 + .../utils/__tests__/clientConfigUtils.test.ts | 1 + .../client-config/utils/getClientConfig.ts | 1 + .../IsMinimalMetadataReadyEffect.tsx | 8 +- .../MinimalMetadataLoadEffect.tsx | 6 +- .../UserMetadataProviderInitialEffect.tsx | 13 +- .../onboarding/hooks/useOnboardingStatus.ts | 6 +- .../SettingsListItemCardContent.tsx | 15 + .../SettingsProtectedRouteWrapper.tsx | 6 +- .../SettingsDeviceSessionRowDropdownMenu.tsx | 63 + .../SettingsProfileDevicesSection.tsx | 146 ++ .../mutations/revokeAllOtherUserSessions.ts | 7 + .../graphql/mutations/revokeUserSession.ts | 7 + .../graphql/queries/currentUserSessions.ts | 18 + .../parseUserAgentDescription.test.ts | 47 + .../utils/parseUserAgentDescription.ts | 42 + .../components/SSEClientEffect.tsx | 9 +- .../components/SSEEventStreamEffect.tsx | 8 +- .../useHandleSseClientConnectionRetry.ts | 52 +- .../graphql/fragments/userQueryFragment.ts | 1 + .../modules/users/hooks/useLoadCurrentUser.ts | 1 + .../src/pages/auth/PasswordReset.tsx | 6 +- .../settings/profile/SettingsProfile.tsx | 2 + .../src/testing/mock-data/config.ts | 1 + .../prefer-workspace-scoped-repository.ts | 2 + packages/twenty-server/@types/express.d.ts | 1 + packages/twenty-server/src/app.module.ts | 16 + .../commands/cron-register-all.command.ts | 6 + .../commands/database-command.module.ts | 2 + ...18325511-create-user-session-core-table.ts | 63 + .../instance-commands.constant.ts | 2 + .../dtos/stop-impersonation.dto.ts | 7 + .../engine/core-modules/auth/auth.module.ts | 2 + .../core-modules/auth/auth.resolver.spec.ts | 10 + .../engine/core-modules/auth/auth.resolver.ts | 123 +- ...lt-workspace-auto-login-window.constant.ts | 1 + .../auth/services/auth.service.spec.ts | 63 +- .../auth/services/auth.service.ts | 8 + .../services/access-token.service.spec.ts | 93 ++ .../token/services/access-token.service.ts | 49 +- .../core-modules/auth/token/token.module.ts | 2 + .../auth/types/raw-auth-context.type.ts | 3 + ...al-auto-login-into-workspaces.util.spec.ts | 92 ++ ...dential-auto-login-into-workspaces.util.ts | 47 + .../types/cache-storage-namespace.enum.ts | 1 + .../client-config.controller.spec.ts | 1 + .../client-config/client-config.entity.ts | 3 + .../services/client-config.service.spec.ts | 24 + .../services/client-config.service.ts | 3 + .../event-logs/emit/events.type.ts | 6 + .../auth-session/auth-session.ts | 17 + .../impersonation/impersonation.ts | 1 + .../__tests__/impersonation.service.spec.ts | 176 ++ .../impersonation/impersonation.module.ts | 2 + .../impersonation/impersonation.resolver.ts | 25 +- .../services/impersonation.service.ts | 125 ++ .../twenty-config/config-variables.ts | 84 +- .../decorators/is-duration.decorator.ts | 28 - .../is-non-negative-duration.decorator.ts | 17 + .../is-positive-duration.decorator.ts | 17 + .../utils/parse-config-duration.util.ts | 20 + .../positive-duration.validator.spec.ts | 46 + .../validators/positive-duration.validator.ts | 29 + ...r-session-cleanup-cron-pattern.constant.ts | 1 + .../user-session-cookie-name.constant.ts | 1 + ...ssion-impersonator-cookie-name.constant.ts | 2 + ...mpersonator-secure-cookie-name.constant.ts | 2 + ...ser-session-secure-cookie-name.constant.ts | 3 + .../user-session-token-prefix.constant.ts | 3 + .../user-session-cleanup.cron.command.ts | 33 + .../jobs/user-session-cleanup.cron.job.ts | 93 ++ .../user-session/dtos/user-session.dto.ts | 36 + .../user-session-cookie.service.spec.ts | 60 + .../services/user-session-cookie.service.ts | 165 ++ .../services/user-session.service.spec.ts | 878 ++++++++++ .../services/user-session.service.ts | 753 +++++++++ .../types/cached-user-session.type.ts | 17 + .../types/create-user-session-input.type.ts | 15 + .../user-session-creation-origin.type.ts | 1 + .../types/user-session-revoked-reason.type.ts | 7 + .../user-session/user-session.entity.ts | 106 ++ .../user-session/user-session.module.ts | 33 + .../user-session/user-session.resolver.ts | 133 ++ ...er-session-token-from-request.util.spec.ts | 68 + ...ct-user-session-token-from-request.util.ts | 63 + .../utils/generate-user-session-token.util.ts | 7 + .../utils/hash-user-session-token.util.ts | 5 + .../utils/is-request-origin-allowed.util.ts | 45 + .../utils/is-user-session-token.util.ts | 5 + ...solve-allowed-credentialed-origins.util.ts | 100 ++ .../user-workspace/user-workspace.entity.ts | 3 + .../user-workspace.service.spec.ts | 153 ++ .../user-workspace/user-workspace.service.ts | 24 +- .../engine/core-modules/user/user.resolver.ts | 22 +- .../auth/auth-authenticated-at.decorator.ts | 11 + .../auth-impersonation-context.decorator.ts | 11 + .../cookie-session-csrf.middleware.spec.ts | 310 ++++ .../cookie-session-csrf.middleware.ts | 71 + .../engine/middlewares/middleware.module.ts | 7 +- .../engine/middlewares/middleware.service.ts | 58 +- .../utils/bind-data-to-request-object.util.ts | 1 + .../src/filters/unhandled-exception.filter.ts | 23 +- packages/twenty-server/src/main.ts | 39 +- .../cookie-session.integration-spec.ts | 266 +++ .../src/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/icon/index.ts | 3 +- 141 files changed, 7339 insertions(+), 1463 deletions(-) create mode 100644 packages/twenty-front/src/modules/apollo/utils/isUnauthenticatedGraphQLError.ts create mode 100644 packages/twenty-front/src/modules/auth/effect-components/CookieSessionBootEffect.tsx create mode 100644 packages/twenty-front/src/modules/auth/effect-components/__tests__/CookieSessionBootEffect.test.tsx create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/signOut.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/stopImpersonation.ts delete mode 100644 packages/twenty-front/src/modules/auth/hooks/__tests__/useHasAccessTokenPair.test.ts create mode 100644 packages/twenty-front/src/modules/auth/hooks/__tests__/useIsLogged.test.ts delete mode 100644 packages/twenty-front/src/modules/auth/hooks/useHasAccessTokenPair.ts create mode 100644 packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts create mode 100644 packages/twenty-front/src/modules/auth/states/isCookieAuthActiveState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/isPendingServerSignOutState.ts create mode 100644 packages/twenty-front/src/modules/client-config/states/isCookieSessionEnabledState.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/components/SettingsDeviceSessionRowDropdownMenu.tsx create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeAllOtherUserSessions.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeUserSession.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/graphql/queries/currentUserSessions.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/utils/__tests__/parseUserAgentDescription.test.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/devices/utils/parseUserAgentDescription.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table.ts create mode 100644 packages/twenty-server/src/engine/core-modules/admin-panel/dtos/stop-impersonation.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/can-credential-auto-login-into-workspaces.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-duration.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/utils/parse-config-duration.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/validators/__tests__/positive-duration.validator.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/validators/positive-duration.validator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cookie-name.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-cookie-name.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-secure-cookie-name.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-token-prefix.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/dtos/user-session.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/services/__tests__/user-session-cookie.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/services/user-session-cookie.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/types/cached-user-session.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/types/create-user-session-input.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/types/user-session-creation-origin.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/types/user-session-revoked-reason.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/user-session.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/user-session.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/generate-user-session-token.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/hash-user-session-token.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/is-request-origin-allowed.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/is-user-session-token.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util.ts create mode 100644 packages/twenty-server/src/engine/decorators/auth/auth-authenticated-at.decorator.ts create mode 100644 packages/twenty-server/src/engine/decorators/auth/auth-impersonation-context.decorator.ts create mode 100644 packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.spec.ts create mode 100644 packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/cookie-session/cookie-session.integration-spec.ts diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index a4fa129eca..4836344b71 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -305,6 +305,7 @@ jobs: CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty" CLICKHOUSE_PASSWORD: clickhousePassword SHARD_COUNTER: 16 + AUTH_COOKIE_SESSIONS_ENABLED: ${{ contains(github.event.pull_request.labels.*.name, 'ci:auth-cookie-sessions') }} steps: - name: Fetch custom Github Actions and base branch history uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -315,6 +316,7 @@ jobs: - name: Update .env.test for integrations tests run: | echo "" >> .env.test + echo "AUTH_COOKIE_SESSIONS_ENABLED=$AUTH_COOKIE_SESSIONS_ENABLED" >> .env.test echo "IS_BILLING_ENABLED=true" >> .env.test echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index ad2d54e701..1d2f7b5291 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -636,6 +636,7 @@ type UserWorkspace { objectPermissions: [ObjectPermission!] objectsPermissions: [ObjectPermission!] twoFactorAuthenticationMethodSummary: [TwoFactorAuthenticationMethodSummary!] + isImpersonating: Boolean } enum PermissionFlagType { @@ -1598,84 +1599,6 @@ type FileUploadTarget { expiresAt: DateTime! } -type BillingEndTrialPeriod { - """Updated subscription status""" - status: SubscriptionStatus - - """Boolean that confirms if a payment method was found""" - hasPaymentMethod: Boolean! - - """ - Billing portal URL for payment method update (returned when no payment method exists) - """ - billingPortalUrl: String - - """Updated current billing subscription""" - currentBillingSubscription: BillingSubscription - - """All billing subscriptions""" - billingSubscriptions: [BillingSubscription!] -} - -type BillingResourceCreditUsage { - productKey: BillingProductKey! - periodStart: DateTime! - periodEnd: DateTime! - usedCredits: Float! - grantedCredits: Float! - rolloverCredits: Float! - totalGrantedCredits: Float! - unitPriceCents: Float! -} - -type BillingPlan { - planKey: BillingPlanKey! - baseProducts: [BillingLicensedProduct!]! - resourceCreditProducts: [BillingLicensedProduct!]! - meteredProducts: [BillingMeteredProduct!]! -} - -type BillingPaymentIntent { - clientSecret: String! - paymentIntentType: String! -} - -type BillingSession { - url: String -} - -type BillingUpdate { - """Current billing subscription""" - currentBillingSubscription: BillingSubscription! - - """All billing subscriptions""" - billingSubscriptions: [BillingSubscription!]! -} - -type InviteSuggestion { - email: String! - displayName: String -} - -type OnboardingStepSuccess { - """Boolean that confirms query was dispatched""" - success: Boolean! -} - -type WorkspaceInvitation { - id: UUID! - email: String! - roleId: UUID - expiresAt: DateTime! -} - -type SendInvitations { - """Boolean that confirms query was dispatched""" - success: Boolean! - errors: [String!]! - result: [WorkspaceInvitation!]! -} - type RecordIdentifier { id: UUID! labelIdentifier: String! @@ -1763,6 +1686,97 @@ type EventSubscription { metadataEvents: [MetadataEvent!]! } +type UserSession { + id: UUID! + workspaceId: UUID + authProvider: String! + isImpersonating: Boolean! + userAgent: String + ipAddress: String + createdAt: DateTime! + lastActiveAt: DateTime! + expiresAt: DateTime! + isCurrent: Boolean! +} + +type BillingEndTrialPeriod { + """Updated subscription status""" + status: SubscriptionStatus + + """Boolean that confirms if a payment method was found""" + hasPaymentMethod: Boolean! + + """ + Billing portal URL for payment method update (returned when no payment method exists) + """ + billingPortalUrl: String + + """Updated current billing subscription""" + currentBillingSubscription: BillingSubscription + + """All billing subscriptions""" + billingSubscriptions: [BillingSubscription!] +} + +type BillingResourceCreditUsage { + productKey: BillingProductKey! + periodStart: DateTime! + periodEnd: DateTime! + usedCredits: Float! + grantedCredits: Float! + rolloverCredits: Float! + totalGrantedCredits: Float! + unitPriceCents: Float! +} + +type BillingPlan { + planKey: BillingPlanKey! + baseProducts: [BillingLicensedProduct!]! + resourceCreditProducts: [BillingLicensedProduct!]! + meteredProducts: [BillingMeteredProduct!]! +} + +type BillingPaymentIntent { + clientSecret: String! + paymentIntentType: String! +} + +type BillingSession { + url: String +} + +type BillingUpdate { + """Current billing subscription""" + currentBillingSubscription: BillingSubscription! + + """All billing subscriptions""" + billingSubscriptions: [BillingSubscription!]! +} + +type InviteSuggestion { + email: String! + displayName: String +} + +type OnboardingStepSuccess { + """Boolean that confirms query was dispatched""" + success: Boolean! +} + +type WorkspaceInvitation { + id: UUID! + email: String! + roleId: UUID + expiresAt: DateTime! +} + +type SendInvitations { + """Boolean that confirms query was dispatched""" + success: Boolean! + errors: [String!]! + result: [WorkspaceInvitation!]! +} + type FeatureFlag { key: FeatureFlagKey! value: Boolean! @@ -1968,6 +1982,7 @@ type ClientConfig { api: ApiConfig! canManageFeatureFlags: Boolean! publicFeatureFlags: [PublicFeatureFlag!]! + isCookieSessionEnabled: Boolean! isMicrosoftMessagingEnabled: Boolean! isMicrosoftCalendarEnabled: Boolean! isGoogleMessagingEnabled: Boolean! @@ -2471,6 +2486,10 @@ type Impersonate { workspace: WorkspaceUrlsAndId! } +type StopImpersonation { + canRestoreImpersonatorSession: Boolean! +} + type UsageTimeSeries { date: String! creditsUsed: Float! @@ -3176,6 +3195,7 @@ type Query { apiKeys: [ApiKey!]! getApiKeyRoles: [Role!]! apiKey(input: GetApiKeyInput!): ApiKey + currentUserSessions: [UserSession!]! getInviteSuggestions: [InviteSuggestion!]! applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]! billingPortalSession(returnUrlPath: String, forPaymentMethodUpdate: Boolean): BillingSession! @@ -3447,6 +3467,8 @@ type Mutation { updateApiKey(input: UpdateApiKeyInput!): ApiKey revokeApiKey(input: RevokeApiKeyInput!): ApiKey assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean! + revokeUserSession(userSessionId: UUID!): Boolean! + revokeAllOtherUserSessions: Int! skipSyncEmailOnboardingStep: OnboardingStepSuccess! triggerInstallAppsOnboardingStep(universalIdentifiers: [String!]!): OnboardingStepSuccess! updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean! @@ -3595,6 +3617,7 @@ type Mutation { getAuthTokensFromSSOExchangeToken(ssoExchangeToken: String!): AuthTokens! authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp! renewToken(appToken: String!): AuthTokens! + signOut(refreshToken: String): Boolean! generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken! generatePlaygroundToken: AuthToken! emailPasswordResetLink(email: String!, workspaceId: UUID, captchaToken: String): EmailPasswordResetLink! @@ -3616,6 +3639,7 @@ type Mutation { trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics! duplicateDashboard(id: UUID!): DuplicatedDashboard! impersonate(userId: UUID!, workspaceId: UUID!): Impersonate! + stopImpersonation: StopImpersonation! createCalendarEvent(input: CreateCalendarEventInput!): CreateCalendarEventOutput! sendEmail(input: SendEmailInput!): SendEmailOutput! startChannelSync(connectedAccountId: UUID!): ChannelSyncSuccess! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index d1ace18c7c..3f35c151a7 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -442,6 +442,7 @@ export interface UserWorkspace { objectPermissions?: ObjectPermission[] objectsPermissions?: ObjectPermission[] twoFactorAuthenticationMethodSummary?: TwoFactorAuthenticationMethodSummary[] + isImpersonating?: Scalars['Boolean'] __typename: 'UserWorkspace' } @@ -1254,6 +1255,98 @@ export interface FileUploadTarget { __typename: 'FileUploadTarget' } +export interface RecordIdentifier { + id: Scalars['UUID'] + labelIdentifier: Scalars['String'] + imageIdentifier?: Scalars['String'] + __typename: 'RecordIdentifier' +} + +export interface NavigationMenuItem { + id: Scalars['UUID'] + userWorkspaceId?: Scalars['UUID'] + targetRecordId?: Scalars['UUID'] + targetObjectMetadataId?: Scalars['UUID'] + viewId?: Scalars['UUID'] + type: NavigationMenuItemType + name?: Scalars['String'] + link?: Scalars['String'] + icon?: Scalars['String'] + color?: Scalars['String'] + folderId?: Scalars['UUID'] + pageLayoutId?: Scalars['UUID'] + position: Scalars['Float'] + applicationId?: Scalars['UUID'] + createdAt: Scalars['DateTime'] + updatedAt: Scalars['DateTime'] + targetRecordIdentifier?: RecordIdentifier + __typename: 'NavigationMenuItem' +} + +export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD' | 'PAGE_LAYOUT' + +export interface ObjectRecordEventProperties { + updatedFields?: Scalars['String'][] + before?: Scalars['JSON'] + after?: Scalars['JSON'] + diff?: Scalars['JSON'] + __typename: 'ObjectRecordEventProperties' +} + +export interface MetadataEvent { + type: MetadataEventAction + metadataName: Scalars['String'] + recordId: Scalars['String'] + properties: ObjectRecordEventProperties + updatedCollectionHash?: Scalars['String'] + __typename: 'MetadataEvent' +} + + +/** Metadata Event Action */ +export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED' + +export interface ObjectRecordEvent { + action: DatabaseEventAction + objectNameSingular: Scalars['String'] + recordId: Scalars['String'] + userId?: Scalars['String'] + workspaceMemberId?: Scalars['String'] + properties: ObjectRecordEventProperties + __typename: 'ObjectRecordEvent' +} + + +/** Database Event Action */ +export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED' + +export interface ObjectRecordEventWithQueryIds { + queryIds: Scalars['String'][] + objectRecordEvent: ObjectRecordEvent + __typename: 'ObjectRecordEventWithQueryIds' +} + +export interface EventSubscription { + eventStreamId: Scalars['String'] + objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[] + metadataEvents: MetadataEvent[] + __typename: 'EventSubscription' +} + +export interface UserSession { + id: Scalars['UUID'] + workspaceId?: Scalars['UUID'] + authProvider: Scalars['String'] + isImpersonating: Scalars['Boolean'] + userAgent?: Scalars['String'] + ipAddress?: Scalars['String'] + createdAt: Scalars['DateTime'] + lastActiveAt: Scalars['DateTime'] + expiresAt: Scalars['DateTime'] + isCurrent: Scalars['Boolean'] + __typename: 'UserSession' +} + export interface BillingEndTrialPeriod { /** Updated subscription status */ status?: SubscriptionStatus @@ -1335,84 +1428,6 @@ export interface SendInvitations { __typename: 'SendInvitations' } -export interface RecordIdentifier { - id: Scalars['UUID'] - labelIdentifier: Scalars['String'] - imageIdentifier?: Scalars['String'] - __typename: 'RecordIdentifier' -} - -export interface NavigationMenuItem { - id: Scalars['UUID'] - userWorkspaceId?: Scalars['UUID'] - targetRecordId?: Scalars['UUID'] - targetObjectMetadataId?: Scalars['UUID'] - viewId?: Scalars['UUID'] - type: NavigationMenuItemType - name?: Scalars['String'] - link?: Scalars['String'] - icon?: Scalars['String'] - color?: Scalars['String'] - folderId?: Scalars['UUID'] - pageLayoutId?: Scalars['UUID'] - position: Scalars['Float'] - applicationId?: Scalars['UUID'] - createdAt: Scalars['DateTime'] - updatedAt: Scalars['DateTime'] - targetRecordIdentifier?: RecordIdentifier - __typename: 'NavigationMenuItem' -} - -export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD' | 'PAGE_LAYOUT' - -export interface ObjectRecordEventProperties { - updatedFields?: Scalars['String'][] - before?: Scalars['JSON'] - after?: Scalars['JSON'] - diff?: Scalars['JSON'] - __typename: 'ObjectRecordEventProperties' -} - -export interface MetadataEvent { - type: MetadataEventAction - metadataName: Scalars['String'] - recordId: Scalars['String'] - properties: ObjectRecordEventProperties - updatedCollectionHash?: Scalars['String'] - __typename: 'MetadataEvent' -} - - -/** Metadata Event Action */ -export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED' - -export interface ObjectRecordEvent { - action: DatabaseEventAction - objectNameSingular: Scalars['String'] - recordId: Scalars['String'] - userId?: Scalars['String'] - workspaceMemberId?: Scalars['String'] - properties: ObjectRecordEventProperties - __typename: 'ObjectRecordEvent' -} - - -/** Database Event Action */ -export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED' - -export interface ObjectRecordEventWithQueryIds { - queryIds: Scalars['String'][] - objectRecordEvent: ObjectRecordEvent - __typename: 'ObjectRecordEventWithQueryIds' -} - -export interface EventSubscription { - eventStreamId: Scalars['String'] - objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[] - metadataEvents: MetadataEvent[] - __typename: 'EventSubscription' -} - export interface FeatureFlag { key: FeatureFlagKey value: Scalars['Boolean'] @@ -1609,6 +1624,7 @@ export interface ClientConfig { api: ApiConfig canManageFeatureFlags: Scalars['Boolean'] publicFeatureFlags: PublicFeatureFlag[] + isCookieSessionEnabled: Scalars['Boolean'] isMicrosoftMessagingEnabled: Scalars['Boolean'] isMicrosoftCalendarEnabled: Scalars['Boolean'] isGoogleMessagingEnabled: Scalars['Boolean'] @@ -2154,6 +2170,11 @@ export interface Impersonate { __typename: 'Impersonate' } +export interface StopImpersonation { + canRestoreImpersonatorSession: Scalars['Boolean'] + __typename: 'StopImpersonation' +} + export interface UsageTimeSeries { date: Scalars['String'] creditsUsed: Scalars['Float'] @@ -2808,6 +2829,7 @@ export interface Query { apiKeys: ApiKey[] getApiKeyRoles: Role[] apiKey?: ApiKey + currentUserSessions: UserSession[] getInviteSuggestions: InviteSuggestion[] applicationConnectionProviders: ApplicationConnectionProvider[] billingPortalSession: BillingSession @@ -2960,6 +2982,8 @@ export interface Mutation { updateApiKey?: ApiKey revokeApiKey?: ApiKey assignRoleToApiKey: Scalars['Boolean'] + revokeUserSession: Scalars['Boolean'] + revokeAllOtherUserSessions: Scalars['Int'] skipSyncEmailOnboardingStep: OnboardingStepSuccess triggerInstallAppsOnboardingStep: OnboardingStepSuccess updateOneApplicationVariable: Scalars['Boolean'] @@ -3109,6 +3133,7 @@ export interface Mutation { getAuthTokensFromSSOExchangeToken: AuthTokens authorizeApp: AuthorizeApp renewToken: AuthTokens + signOut: Scalars['Boolean'] generateApiKeyToken: ApiKeyToken generatePlaygroundToken: AuthToken emailPasswordResetLink: EmailPasswordResetLink @@ -3130,6 +3155,7 @@ export interface Mutation { trackAnalytics: Analytics duplicateDashboard: DuplicatedDashboard impersonate: Impersonate + stopImpersonation: StopImpersonation createCalendarEvent: CreateCalendarEventOutput sendEmail: SendEmailOutput startChannelSync: ChannelSyncSuccess @@ -3616,6 +3642,7 @@ export interface UserWorkspaceGenqlSelection{ objectPermissions?: ObjectPermissionGenqlSelection objectsPermissions?: ObjectPermissionGenqlSelection twoFactorAuthenticationMethodSummary?: TwoFactorAuthenticationMethodSummaryGenqlSelection + isImpersonating?: boolean | number __typename?: boolean | number __scalar?: boolean | number } @@ -4442,6 +4469,96 @@ export interface FileUploadTargetGenqlSelection{ __scalar?: boolean | number } +export interface RecordIdentifierGenqlSelection{ + id?: boolean | number + labelIdentifier?: boolean | number + imageIdentifier?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface NavigationMenuItemGenqlSelection{ + id?: boolean | number + userWorkspaceId?: boolean | number + targetRecordId?: boolean | number + targetObjectMetadataId?: boolean | number + viewId?: boolean | number + type?: boolean | number + name?: boolean | number + link?: boolean | number + icon?: boolean | number + color?: boolean | number + folderId?: boolean | number + pageLayoutId?: boolean | number + position?: boolean | number + applicationId?: boolean | number + createdAt?: boolean | number + updatedAt?: boolean | number + targetRecordIdentifier?: RecordIdentifierGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface ObjectRecordEventPropertiesGenqlSelection{ + updatedFields?: boolean | number + before?: boolean | number + after?: boolean | number + diff?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface MetadataEventGenqlSelection{ + type?: boolean | number + metadataName?: boolean | number + recordId?: boolean | number + properties?: ObjectRecordEventPropertiesGenqlSelection + updatedCollectionHash?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface ObjectRecordEventGenqlSelection{ + action?: boolean | number + objectNameSingular?: boolean | number + recordId?: boolean | number + userId?: boolean | number + workspaceMemberId?: boolean | number + properties?: ObjectRecordEventPropertiesGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface ObjectRecordEventWithQueryIdsGenqlSelection{ + queryIds?: boolean | number + objectRecordEvent?: ObjectRecordEventGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface EventSubscriptionGenqlSelection{ + eventStreamId?: boolean | number + objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection + metadataEvents?: MetadataEventGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface UserSessionGenqlSelection{ + id?: boolean | number + workspaceId?: boolean | number + authProvider?: boolean | number + isImpersonating?: boolean | number + userAgent?: boolean | number + ipAddress?: boolean | number + createdAt?: boolean | number + lastActiveAt?: boolean | number + expiresAt?: boolean | number + isCurrent?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface BillingEndTrialPeriodGenqlSelection{ /** Updated subscription status */ status?: boolean | number @@ -4533,81 +4650,6 @@ export interface SendInvitationsGenqlSelection{ __scalar?: boolean | number } -export interface RecordIdentifierGenqlSelection{ - id?: boolean | number - labelIdentifier?: boolean | number - imageIdentifier?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface NavigationMenuItemGenqlSelection{ - id?: boolean | number - userWorkspaceId?: boolean | number - targetRecordId?: boolean | number - targetObjectMetadataId?: boolean | number - viewId?: boolean | number - type?: boolean | number - name?: boolean | number - link?: boolean | number - icon?: boolean | number - color?: boolean | number - folderId?: boolean | number - pageLayoutId?: boolean | number - position?: boolean | number - applicationId?: boolean | number - createdAt?: boolean | number - updatedAt?: boolean | number - targetRecordIdentifier?: RecordIdentifierGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface ObjectRecordEventPropertiesGenqlSelection{ - updatedFields?: boolean | number - before?: boolean | number - after?: boolean | number - diff?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface MetadataEventGenqlSelection{ - type?: boolean | number - metadataName?: boolean | number - recordId?: boolean | number - properties?: ObjectRecordEventPropertiesGenqlSelection - updatedCollectionHash?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface ObjectRecordEventGenqlSelection{ - action?: boolean | number - objectNameSingular?: boolean | number - recordId?: boolean | number - userId?: boolean | number - workspaceMemberId?: boolean | number - properties?: ObjectRecordEventPropertiesGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface ObjectRecordEventWithQueryIdsGenqlSelection{ - queryIds?: boolean | number - objectRecordEvent?: ObjectRecordEventGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface EventSubscriptionGenqlSelection{ - eventStreamId?: boolean | number - objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection - metadataEvents?: MetadataEventGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - export interface FeatureFlagGenqlSelection{ key?: boolean | number value?: boolean | number @@ -4813,6 +4855,7 @@ export interface ClientConfigGenqlSelection{ api?: ApiConfigGenqlSelection canManageFeatureFlags?: boolean | number publicFeatureFlags?: PublicFeatureFlagGenqlSelection + isCookieSessionEnabled?: boolean | number isMicrosoftMessagingEnabled?: boolean | number isMicrosoftCalendarEnabled?: boolean | number isGoogleMessagingEnabled?: boolean | number @@ -5413,6 +5456,12 @@ export interface ImpersonateGenqlSelection{ __scalar?: boolean | number } +export interface StopImpersonationGenqlSelection{ + canRestoreImpersonatorSession?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface UsageTimeSeriesGenqlSelection{ date?: boolean | number creditsUsed?: boolean | number @@ -6092,6 +6141,7 @@ export interface QueryGenqlSelection{ apiKeys?: ApiKeyGenqlSelection getApiKeyRoles?: RoleGenqlSelection apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} }) + currentUserSessions?: UserSessionGenqlSelection getInviteSuggestions?: InviteSuggestionGenqlSelection applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} }) billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null), forPaymentMethodUpdate?: (Scalars['Boolean'] | null)} }) @@ -6285,6 +6335,8 @@ export interface MutationGenqlSelection{ updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} }) revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} }) assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} } + revokeUserSession?: { __args: {userSessionId: Scalars['UUID']} } + revokeAllOtherUserSessions?: boolean | number skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection triggerInstallAppsOnboardingStep?: (OnboardingStepSuccessGenqlSelection & { __args: {universalIdentifiers: Scalars['String'][]} }) updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} } @@ -6434,6 +6486,7 @@ export interface MutationGenqlSelection{ getAuthTokensFromSSOExchangeToken?: (AuthTokensGenqlSelection & { __args: {ssoExchangeToken: Scalars['String']} }) authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} }) renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} }) + signOut?: { __args: {refreshToken?: (Scalars['String'] | null)} } | boolean | number generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} }) generatePlaygroundToken?: AuthTokenGenqlSelection emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null), captchaToken?: (Scalars['String'] | null)} }) @@ -6455,6 +6508,7 @@ export interface MutationGenqlSelection{ trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} }) duplicateDashboard?: (DuplicatedDashboardGenqlSelection & { __args: {id: Scalars['UUID']} }) impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} }) + stopImpersonation?: StopImpersonationGenqlSelection createCalendarEvent?: (CreateCalendarEventOutputGenqlSelection & { __args: {input: CreateCalendarEventInput} }) sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} }) startChannelSync?: (ChannelSyncSuccessGenqlSelection & { __args: {connectedAccountId: Scalars['UUID']} }) @@ -7644,6 +7698,70 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier'] + export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => { + if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"') + return RecordIdentifier_possibleTypes.includes(obj.__typename) + } + + + + const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem'] + export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => { + if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"') + return NavigationMenuItem_possibleTypes.includes(obj.__typename) + } + + + + const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties'] + export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => { + if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"') + return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename) + } + + + + const MetadataEvent_possibleTypes: string[] = ['MetadataEvent'] + export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => { + if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"') + return MetadataEvent_possibleTypes.includes(obj.__typename) + } + + + + const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent'] + export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => { + if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"') + return ObjectRecordEvent_possibleTypes.includes(obj.__typename) + } + + + + const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds'] + export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => { + if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"') + return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename) + } + + + + const EventSubscription_possibleTypes: string[] = ['EventSubscription'] + export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => { + if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"') + return EventSubscription_possibleTypes.includes(obj.__typename) + } + + + + const UserSession_possibleTypes: string[] = ['UserSession'] + export const isUserSession = (obj?: { __typename?: any } | null): obj is UserSession => { + if (!obj?.__typename) throw new Error('__typename is missing in "isUserSession"') + return UserSession_possibleTypes.includes(obj.__typename) + } + + + const BillingEndTrialPeriod_possibleTypes: string[] = ['BillingEndTrialPeriod'] export const isBillingEndTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingEndTrialPeriod => { if (!obj?.__typename) throw new Error('__typename is missing in "isBillingEndTrialPeriod"') @@ -7724,62 +7842,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null - const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier'] - export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => { - if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"') - return RecordIdentifier_possibleTypes.includes(obj.__typename) - } - - - - const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem'] - export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => { - if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"') - return NavigationMenuItem_possibleTypes.includes(obj.__typename) - } - - - - const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties'] - export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => { - if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"') - return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename) - } - - - - const MetadataEvent_possibleTypes: string[] = ['MetadataEvent'] - export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => { - if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"') - return MetadataEvent_possibleTypes.includes(obj.__typename) - } - - - - const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent'] - export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => { - if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"') - return ObjectRecordEvent_possibleTypes.includes(obj.__typename) - } - - - - const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds'] - export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => { - if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"') - return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename) - } - - - - const EventSubscription_possibleTypes: string[] = ['EventSubscription'] - export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => { - if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"') - return EventSubscription_possibleTypes.includes(obj.__typename) - } - - - const FeatureFlag_possibleTypes: string[] = ['FeatureFlag'] export const isFeatureFlag = (obj?: { __typename?: any } | null): obj is FeatureFlag => { if (!obj?.__typename) throw new Error('__typename is missing in "isFeatureFlag"') @@ -8468,6 +8530,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const StopImpersonation_possibleTypes: string[] = ['StopImpersonation'] + export const isStopImpersonation = (obj?: { __typename?: any } | null): obj is StopImpersonation => { + if (!obj?.__typename) throw new Error('__typename is missing in "isStopImpersonation"') + return StopImpersonation_possibleTypes.includes(obj.__typename) + } + + + const UsageTimeSeries_possibleTypes: string[] = ['UsageTimeSeries'] export const isUsageTimeSeries = (obj?: { __typename?: any } | null): obj is UsageTimeSeries => { if (!obj?.__typename) throw new Error('__typename is missing in "isUsageTimeSeries"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index c84cae5f09..06c3b6d3f7 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -52,44 +52,44 @@ export default { 135, 143, 146, - 164, - 167, - 169, - 173, - 180, + 154, + 157, + 159, + 174, 181, - 188, - 191, - 194, - 206, - 223, - 225, - 238, - 244, - 278, + 182, + 189, + 192, + 195, + 207, + 224, + 226, + 239, + 245, 280, - 281, 282, 283, 284, 285, 286, - 293, - 294, - 297, - 334, - 340, + 287, + 288, + 295, + 296, + 299, + 336, 342, - 343, 344, 345, + 346, 347, 349, - 362, - 369, - 376, - 377, - 509 + 351, + 364, + 371, + 378, + 379, + 511 ], "types": { "BillingProductDTO": { @@ -570,10 +570,10 @@ export default { 4 ], "relation": [ - 224 + 225 ], "morphRelations": [ - 224 + 225 ], "object": [ 28 @@ -609,7 +609,7 @@ export default { 6 ], "indexFieldMetadataList": [ - 226 + 227 ], "__typename": [ 1 @@ -699,10 +699,10 @@ export default { 26 ], "searchFieldMetadataList": [ - 228 + 229 ], "fields": [ - 235, + 236, { "paging": [ 30, @@ -715,7 +715,7 @@ export default { } ], "indexMetadatas": [ - 233, + 234, { "paging": [ 30, @@ -1265,6 +1265,9 @@ export default { "twoFactorAuthenticationMethodSummary": [ 53 ], + "isImpersonating": [ + 3 + ], "__typename": [ 1 ] @@ -1739,7 +1742,7 @@ export default { 52 ], "featureFlags": [ - 172 + 173 ], "billingSubscriptions": [ 142 @@ -1754,7 +1757,7 @@ export default { 144 ], "billingEntitlements": [ - 237 + 238 ], "hasValidSignedEnterpriseKey": [ 3 @@ -1763,7 +1766,7 @@ export default { 3 ], "workspaceUrls": [ - 174 + 175 ], "workspaceCustomApplicationId": [ 1 @@ -1833,7 +1836,7 @@ export default { 38 ], "deletedWorkspaceMembers": [ - 216 + 217 ], "hasPassword": [ 3 @@ -1845,7 +1848,7 @@ export default { 54 ], "availableWorkspaces": [ - 215 + 216 ], "__typename": [ 1 @@ -3076,6 +3079,199 @@ export default { 1 ] }, + "RecordIdentifier": { + "id": [ + 4 + ], + "labelIdentifier": [ + 1 + ], + "imageIdentifier": [ + 1 + ], + "__typename": [ + 1 + ] + }, + "NavigationMenuItem": { + "id": [ + 4 + ], + "userWorkspaceId": [ + 4 + ], + "targetRecordId": [ + 4 + ], + "targetObjectMetadataId": [ + 4 + ], + "viewId": [ + 4 + ], + "type": [ + 154 + ], + "name": [ + 1 + ], + "link": [ + 1 + ], + "icon": [ + 1 + ], + "color": [ + 1 + ], + "folderId": [ + 4 + ], + "pageLayoutId": [ + 4 + ], + "position": [ + 16 + ], + "applicationId": [ + 4 + ], + "createdAt": [ + 6 + ], + "updatedAt": [ + 6 + ], + "targetRecordIdentifier": [ + 152 + ], + "__typename": [ + 1 + ] + }, + "NavigationMenuItemType": {}, + "ObjectRecordEventProperties": { + "updatedFields": [ + 1 + ], + "before": [ + 5 + ], + "after": [ + 5 + ], + "diff": [ + 5 + ], + "__typename": [ + 1 + ] + }, + "MetadataEvent": { + "type": [ + 157 + ], + "metadataName": [ + 1 + ], + "recordId": [ + 1 + ], + "properties": [ + 155 + ], + "updatedCollectionHash": [ + 1 + ], + "__typename": [ + 1 + ] + }, + "MetadataEventAction": {}, + "ObjectRecordEvent": { + "action": [ + 159 + ], + "objectNameSingular": [ + 1 + ], + "recordId": [ + 1 + ], + "userId": [ + 1 + ], + "workspaceMemberId": [ + 1 + ], + "properties": [ + 155 + ], + "__typename": [ + 1 + ] + }, + "DatabaseEventAction": {}, + "ObjectRecordEventWithQueryIds": { + "queryIds": [ + 1 + ], + "objectRecordEvent": [ + 158 + ], + "__typename": [ + 1 + ] + }, + "EventSubscription": { + "eventStreamId": [ + 1 + ], + "objectRecordEventsWithQueryIds": [ + 160 + ], + "metadataEvents": [ + 156 + ], + "__typename": [ + 1 + ] + }, + "UserSession": { + "id": [ + 4 + ], + "workspaceId": [ + 4 + ], + "authProvider": [ + 1 + ], + "isImpersonating": [ + 3 + ], + "userAgent": [ + 1 + ], + "ipAddress": [ + 1 + ], + "createdAt": [ + 6 + ], + "lastActiveAt": [ + 6 + ], + "expiresAt": [ + 6 + ], + "isCurrent": [ + 3 + ], + "__typename": [ + 1 + ] + }, "BillingEndTrialPeriod": { "status": [ 143 @@ -3216,165 +3412,7 @@ export default { 1 ], "result": [ - 160 - ], - "__typename": [ - 1 - ] - }, - "RecordIdentifier": { - "id": [ - 4 - ], - "labelIdentifier": [ - 1 - ], - "imageIdentifier": [ - 1 - ], - "__typename": [ - 1 - ] - }, - "NavigationMenuItem": { - "id": [ - 4 - ], - "userWorkspaceId": [ - 4 - ], - "targetRecordId": [ - 4 - ], - "targetObjectMetadataId": [ - 4 - ], - "viewId": [ - 4 - ], - "type": [ - 164 - ], - "name": [ - 1 - ], - "link": [ - 1 - ], - "icon": [ - 1 - ], - "color": [ - 1 - ], - "folderId": [ - 4 - ], - "pageLayoutId": [ - 4 - ], - "position": [ - 16 - ], - "applicationId": [ - 4 - ], - "createdAt": [ - 6 - ], - "updatedAt": [ - 6 - ], - "targetRecordIdentifier": [ - 162 - ], - "__typename": [ - 1 - ] - }, - "NavigationMenuItemType": {}, - "ObjectRecordEventProperties": { - "updatedFields": [ - 1 - ], - "before": [ - 5 - ], - "after": [ - 5 - ], - "diff": [ - 5 - ], - "__typename": [ - 1 - ] - }, - "MetadataEvent": { - "type": [ - 167 - ], - "metadataName": [ - 1 - ], - "recordId": [ - 1 - ], - "properties": [ - 165 - ], - "updatedCollectionHash": [ - 1 - ], - "__typename": [ - 1 - ] - }, - "MetadataEventAction": {}, - "ObjectRecordEvent": { - "action": [ - 169 - ], - "objectNameSingular": [ - 1 - ], - "recordId": [ - 1 - ], - "userId": [ - 1 - ], - "workspaceMemberId": [ - 1 - ], - "properties": [ - 165 - ], - "__typename": [ - 1 - ] - }, - "DatabaseEventAction": {}, - "ObjectRecordEventWithQueryIds": { - "queryIds": [ - 1 - ], - "objectRecordEvent": [ - 168 - ], - "__typename": [ - 1 - ] - }, - "EventSubscription": { - "eventStreamId": [ - 1 - ], - "objectRecordEventsWithQueryIds": [ - 170 - ], - "metadataEvents": [ - 166 + 171 ], "__typename": [ 1 @@ -3382,7 +3420,7 @@ export default { }, "FeatureFlag": { "key": [ - 173 + 174 ], "value": [ 3 @@ -3460,7 +3498,7 @@ export default { 1 ], "versionDistribution": [ - 176 + 177 ], "__typename": [ 1 @@ -3485,10 +3523,10 @@ export default { 1 ], "type": [ - 180 + 181 ], "status": [ - 181 + 182 ], "issuer": [ 1 @@ -3501,7 +3539,7 @@ export default { "SSOIdentityProviderStatus": {}, "AuthProviders": { "sso": [ - 179 + 180 ], "google": [ 3 @@ -3538,10 +3576,10 @@ export default { 4 ], "authProviders": [ - 182 + 183 ], "authBypassProviders": [ - 183 + 184 ], "logo": [ 1 @@ -3550,7 +3588,7 @@ export default { 1 ], "workspaceUrls": [ - 174 + 175 ], "__typename": [ 1 @@ -3589,7 +3627,7 @@ export default { 1 ], "modelFamily": [ - 188 + 189 ], "modelFamilyLabel": [ 1 @@ -3604,7 +3642,7 @@ export default { 16 ], "nativeCapabilities": [ - 186 + 187 ], "isDeprecated": [ 3 @@ -3643,7 +3681,7 @@ export default { 1 ], "trialPeriods": [ - 178 + 179 ], "__typename": [ 1 @@ -3651,7 +3689,7 @@ export default { }, "Support": { "supportDriver": [ - 191 + 192 ], "supportFrontChatId": [ 1 @@ -3677,7 +3715,7 @@ export default { }, "Captcha": { "provider": [ - 194 + 195 ], "siteKey": [ 1 @@ -3711,10 +3749,10 @@ export default { }, "PublicFeatureFlag": { "key": [ - 173 + 174 ], "metadata": [ - 196 + 197 ], "__typename": [ 1 @@ -3739,13 +3777,13 @@ export default { 1 ], "authProviders": [ - 182 + 183 ], "billing": [ - 189 + 190 ], "aiModels": [ - 187 + 188 ], "signInPrefilled": [ 3 @@ -3769,25 +3807,28 @@ export default { 3 ], "support": [ - 190 + 191 ], "isAttachmentPreviewEnabled": [ 3 ], "sentry": [ - 192 - ], - "captcha": [ 193 ], + "captcha": [ + 194 + ], "api": [ - 195 + 196 ], "canManageFeatureFlags": [ 3 ], "publicFeatureFlags": [ - 197 + 198 + ], + "isCookieSessionEnabled": [ + 3 ], "isMicrosoftMessagingEnabled": [ 3 @@ -3832,7 +3873,7 @@ export default { 1 ], "maintenance": [ - 198 + 199 ], "__typename": [ 1 @@ -3922,7 +3963,7 @@ export default { }, "AppConnection": { "id": [ - 206 + 207 ], "providerName": [ 1 @@ -3974,7 +4015,7 @@ export default { 4 ], "type": [ - 180 + 181 ], "issuer": [ 1 @@ -3983,7 +4024,7 @@ export default { 1 ], "status": [ - 181 + 182 ], "__typename": [ 1 @@ -4002,7 +4043,7 @@ export default { }, "FindAvailableSSOIDP": { "type": [ - 180 + 181 ], "id": [ 4 @@ -4014,10 +4055,10 @@ export default { 1 ], "status": [ - 181 + 182 ], "workspace": [ - 210 + 211 ], "__typename": [ 1 @@ -4028,7 +4069,7 @@ export default { 4 ], "type": [ - 180 + 181 ], "issuer": [ 1 @@ -4037,7 +4078,7 @@ export default { 1 ], "status": [ - 181 + 182 ], "__typename": [ 1 @@ -4045,7 +4086,7 @@ export default { }, "SSOConnection": { "type": [ - 180 + 181 ], "id": [ 4 @@ -4057,7 +4098,7 @@ export default { 1 ], "status": [ - 181 + 182 ], "__typename": [ 1 @@ -4080,13 +4121,13 @@ export default { 1 ], "workspaceUrls": [ - 174 + 175 ], "logo": [ 1 ], "sso": [ - 213 + 214 ], "__typename": [ 1 @@ -4094,10 +4135,10 @@ export default { }, "AvailableWorkspaces": { "availableWorkspacesForSignIn": [ - 214 + 215 ], "availableWorkspacesForSignUp": [ - 214 + 215 ], "__typename": [ 1 @@ -4230,10 +4271,10 @@ export default { 1 ], "objectPermissions": [ - 218 + 219 ], "fieldPermissions": [ - 219 + 220 ], "__typename": [ 1 @@ -4301,7 +4342,7 @@ export default { 1 ], "roles": [ - 220 + 221 ], "manifest": [ 5 @@ -4312,7 +4353,7 @@ export default { }, "WorkspaceCompanyEnrichmentResult": { "outcome": [ - 223 + 224 ], "enrichment": [ 5 @@ -4324,7 +4365,7 @@ export default { "WorkspaceCompanyEnrichmentOutcome": {}, "Relation": { "type": [ - 225 + 226 ], "sourceObjectMetadata": [ 28 @@ -4430,10 +4471,10 @@ export default { }, "ObjectConnection": { "pageInfo": [ - 230 + 231 ], "edges": [ - 229 + 230 ], "__typename": [ 1 @@ -4452,10 +4493,10 @@ export default { }, "ObjectIndexMetadatasConnection": { "pageInfo": [ - 230 + 231 ], "edges": [ - 232 + 233 ], "__typename": [ 1 @@ -4474,10 +4515,10 @@ export default { }, "ObjectFieldsConnection": { "pageInfo": [ - 230 + 231 ], "edges": [ - 234 + 235 ], "__typename": [ 1 @@ -4485,10 +4526,10 @@ export default { }, "FieldConnection": { "pageInfo": [ - 230 + 231 ], "edges": [ - 234 + 235 ], "__typename": [ 1 @@ -4496,7 +4537,7 @@ export default { }, "BillingEntitlement": { "key": [ - 238 + 239 ], "value": [ 3 @@ -4534,7 +4575,7 @@ export default { 1 ], "records": [ - 239 + 240 ], "isCustomDomainEnabled": [ 3 @@ -4573,7 +4614,7 @@ export default { 1 ], "connectionSecurity": [ - 244 + 245 ], "__typename": [ 1 @@ -4582,13 +4623,13 @@ export default { "EmailConnectionSecurity": {}, "PublicImapSmtpCaldavConnectionParameters": { "IMAP": [ - 243 + 244 ], "SMTP": [ - 243 + 244 ], "CALDAV": [ - 243 + 244 ], "__typename": [ 1 @@ -4644,7 +4685,7 @@ export default { 6 ], "connectionParameters": [ - 245 + 246 ], "__typename": [ 1 @@ -4695,10 +4736,10 @@ export default { }, "AvailableWorkspacesAndAccessTokens": { "tokens": [ - 251 + 252 ], "availableWorkspaces": [ - 215 + 216 ], "__typename": [ 1 @@ -4736,7 +4777,7 @@ export default { }, "WorkspaceUrlsAndId": { "workspaceUrls": [ - 174 + 175 ], "id": [ 4 @@ -4750,7 +4791,7 @@ export default { 12 ], "workspace": [ - 256 + 257 ], "__typename": [ 1 @@ -4783,7 +4824,7 @@ export default { 12 ], "workspaceUrls": [ - 174 + 175 ], "__typename": [ 1 @@ -4827,7 +4868,7 @@ export default { }, "AuthTokens": { "tokens": [ - 251 + 252 ], "__typename": [ 1 @@ -4868,7 +4909,15 @@ export default { 12 ], "workspace": [ - 256 + 257 + ], + "__typename": [ + 1 + ] + }, + "StopImpersonation": { + "canRestoreImpersonatorSession": [ + 3 ], "__typename": [ 1 @@ -4890,7 +4939,7 @@ export default { 1 ], "dailyUsage": [ - 269 + 271 ], "__typename": [ 1 @@ -4898,16 +4947,16 @@ export default { }, "UsageAnalytics": { "usageByUser": [ - 200 + 201 ], "usageByOperationType": [ - 200 + 201 ], "usageByModel": [ - 200 + 201 ], "timeSeries": [ - 269 + 271 ], "periodStart": [ 6 @@ -4916,7 +4965,7 @@ export default { 6 ], "userDailyUsage": [ - 270 + 272 ], "__typename": [ 1 @@ -5015,10 +5064,10 @@ export default { 1 ], "status": [ - 278 + 280 ], "verificationRecords": [ - 276 + 278 ], "verifiedAt": [ 6 @@ -5033,7 +5082,7 @@ export default { 4 ], "visibility": [ - 280 + 282 ], "handle": [ 1 @@ -5042,16 +5091,16 @@ export default { 1 ], "type": [ - 281 + 283 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 282 + 284 ], "messageFolderImportPolicy": [ - 283 + 285 ], "excludeNonProfessionalEmails": [ 3 @@ -5060,7 +5109,7 @@ export default { 3 ], "pendingGroupEmailsAction": [ - 284 + 286 ], "isSyncEnabled": [ 3 @@ -5069,10 +5118,10 @@ export default { 6 ], "syncStatus": [ - 285 + 287 ], "syncStage": [ - 286 + 288 ], "syncStageStartedAt": [ 6 @@ -5093,7 +5142,7 @@ export default { 6 ], "connectedAccount": [ - 246 + 247 ], "__typename": [ 1 @@ -5108,7 +5157,7 @@ export default { "MessageChannelSyncStage": {}, "CreateEmailGroupChannelOutput": { "messageChannel": [ - 279 + 281 ], "forwardingAddress": [ 1 @@ -5170,7 +5219,7 @@ export default { 31 ], "skipped": [ - 290 + 292 ], "__typename": [ 1 @@ -5187,10 +5236,10 @@ export default { 1 ], "reason": [ - 293 + 295 ], "source": [ - 294 + 296 ], "unsubscribeTopicId": [ 4 @@ -5203,7 +5252,7 @@ export default { "MessageSuppressionSource": {}, "MessageSuppressionList": { "records": [ - 292 + 294 ], "totalCount": [ 31 @@ -5229,7 +5278,7 @@ export default { 1 ], "visibility": [ - 297 + 299 ], "__typename": [ 1 @@ -5275,7 +5324,7 @@ export default { 1 ], "location": [ - 299 + 301 ], "__typename": [ 1 @@ -5292,7 +5341,7 @@ export default { 1 ], "connectionSecurity": [ - 244 + 245 ], "__typename": [ 1 @@ -5303,13 +5352,13 @@ export default { 1 ], "IMAP": [ - 301 + 303 ], "SMTP": [ - 301 + 303 ], "CALDAV": [ - 301 + 303 ], "__typename": [ 1 @@ -5329,7 +5378,7 @@ export default { 4 ], "connectionParameters": [ - 302 + 304 ], "__typename": [ 1 @@ -5551,7 +5600,7 @@ export default { 1 ], "series": [ - 311 + 313 ], "xAxisLabel": [ 1 @@ -5600,7 +5649,7 @@ export default { 1 ], "data": [ - 313 + 315 ], "__typename": [ 1 @@ -5608,7 +5657,7 @@ export default { }, "LineChartData": { "series": [ - 314 + 316 ], "xAxisLabel": [ 1 @@ -5645,7 +5694,7 @@ export default { }, "PieChartData": { "data": [ - 316 + 318 ], "showLegend": [ 3 @@ -5750,13 +5799,13 @@ export default { }, "EventLogQueryResult": { "records": [ - 321 + 323 ], "totalCount": [ 31 ], "pageInfo": [ - 322 + 324 ], "__typename": [ 1 @@ -5820,7 +5869,7 @@ export default { 1 ], "parts": [ - 307 + 309 ], "processedAt": [ 6 @@ -5834,7 +5883,7 @@ export default { }, "AgentChatThread": { "id": [ - 206 + 207 ], "title": [ 1 @@ -5889,7 +5938,7 @@ export default { }, "AiSystemPromptPreview": { "sections": [ - 327 + 329 ], "estimatedTokenCount": [ 31 @@ -5917,7 +5966,7 @@ export default { 31 ], "error": [ - 329 + 331 ], "__typename": [ 1 @@ -5950,10 +5999,10 @@ export default { }, "StartWorkspaceSetupChatResult": { "outcome": [ - 334 + 336 ], "thread": [ - 326 + 328 ], "__typename": [ 1 @@ -5991,10 +6040,10 @@ export default { 4 ], "evaluations": [ - 335 + 337 ], "messages": [ - 325 + 327 ], "createdAt": [ 6 @@ -6036,7 +6085,7 @@ export default { 5 ], "scope": [ - 340 + 342 ], "__typename": [ 1 @@ -6051,19 +6100,19 @@ export default { 1 ], "syncStatus": [ - 342 + 344 ], "syncStage": [ - 343 + 345 ], "visibility": [ - 344 + 346 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 345 + 347 ], "isSyncEnabled": [ 3 @@ -6114,7 +6163,7 @@ export default { 1 ], "pendingSyncAction": [ - 347 + 349 ], "messageChannelId": [ 4 @@ -6132,7 +6181,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 349 + 351 ], "hash": [ 1 @@ -6196,13 +6245,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 350 + 352 ], "views": [ - 351 + 353 ], "collectionHashes": [ - 348 + 350 ], "__typename": [ 1 @@ -6210,10 +6259,10 @@ export default { }, "Query": { "navigationMenuItems": [ - 163 + 153 ], "navigationMenuItem": [ - 163, + 153, { "id": [ 4, @@ -6376,13 +6425,16 @@ export default { 7, { "input": [ - 354, + 356, "GetApiKeyInput!" ] } ], + "currentUserSessions": [ + 162 + ], "getInviteSuggestions": [ - 158 + 169 ], "applicationConnectionProviders": [ 127, @@ -6394,7 +6446,7 @@ export default { } ], "billingPortalSession": [ - 156, + 167, { "returnUrlPath": [ 1 @@ -6405,13 +6457,13 @@ export default { } ], "listPlans": [ - 154 + 165 ], "getResourceCreditUsage": [ - 153 + 164 ], "findWorkspaceInvitations": [ - 160 + 171 ], "getApprovedAccessDomains": [ 149 @@ -6479,13 +6531,13 @@ export default { 11, { "input": [ - 355, + 357, "AgentIdInput!" ] } ], "objectRecordCounts": [ - 227 + 228 ], "mostlyEmptyFieldMetadataIds": [ 4, @@ -6506,14 +6558,14 @@ export default { } ], "objects": [ - 231, + 232, { "paging": [ 30, "CursorPaging!" ], "filter": [ - 356, + 358, "ObjectFilter!" ] } @@ -6522,7 +6574,7 @@ export default { 22, { "input": [ - 357, + 359, "LogicFunctionIdInput!" ] } @@ -6534,7 +6586,7 @@ export default { 5, { "input": [ - 357, + 359, "LogicFunctionIdInput!" ] } @@ -6543,7 +6595,7 @@ export default { 1, { "input": [ - 357, + 359, "LogicFunctionIdInput!" ] } @@ -6576,7 +6628,7 @@ export default { 72 ], "getPublicWorkspaceDataByDomain": [ - 184, + 185, { "origin": [ 1 @@ -6584,7 +6636,7 @@ export default { } ], "getPublicWorkspaceDataById": [ - 185, + 186, { "id": [ 4, @@ -6593,7 +6645,7 @@ export default { } ], "findApplicationRegistrationByClientId": [ - 203, + 204, { "clientId": [ 1, @@ -6623,7 +6675,7 @@ export default { } ], "findApplicationRegistrationStats": [ - 177, + 178, { "id": [ 1, @@ -6632,7 +6684,7 @@ export default { } ], "findApplicationRegistrationVariables": [ - 175, + 176, { "applicationRegistrationId": [ 1, @@ -6650,7 +6702,7 @@ export default { } ], "findClaimableApplicationRegistration": [ - 201, + 202, { "sourcePackage": [ 1 @@ -6684,7 +6736,7 @@ export default { } ], "findManyMarketplaceApps": [ - 217, + 218, { "universalIdentifiers": [ 1, @@ -6693,7 +6745,7 @@ export default { } ], "findMarketplaceAppDetail": [ - 221, + 222, { "universalIdentifier": [ 1, @@ -6702,7 +6754,7 @@ export default { } ], "publicMarketplaceApps": [ - 217, + 218, { "isVetted": [ 3, @@ -6711,7 +6763,7 @@ export default { } ], "publicMarketplaceAppDetail": [ - 221, + 222, { "universalIdentifier": [ 1, @@ -6729,7 +6781,7 @@ export default { } ], "fields": [ - 236, + 237, { "paging": [ 30, @@ -6762,28 +6814,28 @@ export default { 51 ], "previewMessageCampaignAudience": [ - 288, + 290, { "input": [ - 358, + 360, "PreviewMessageCampaignAudienceInput!" ] } ], "messageSuppressions": [ - 295, + 297, { "input": [ - 359, + 361, "FindMessageSuppressionsInput!" ] } ], "unsubscribeTopics": [ - 296 + 298 ], "myMessageChannels": [ - 279, + 281, { "connectedAccountId": [ 4 @@ -6791,13 +6843,13 @@ export default { } ], "getEmailingDomains": [ - 277 + 279 ], "myConnectedAccounts": [ - 246 + 247 ], "getToolIndex": [ - 306 + 308 ], "getToolInputSchema": [ 5, @@ -6809,10 +6861,10 @@ export default { } ], "webhooks": [ - 305 + 307 ], "webhook": [ - 305, + 307, { "id": [ 4, @@ -6821,7 +6873,7 @@ export default { } ], "myMessageFolders": [ - 346, + 348, { "messageChannelId": [ 4 @@ -6829,7 +6881,7 @@ export default { } ], "myCalendarChannels": [ - 341, + 343, { "connectedAccountId": [ 4 @@ -6837,45 +6889,45 @@ export default { } ], "minimalMetadata": [ - 352 + 354 ], "appKeyValue": [ - 339, + 341, { "key": [ 1, "String!" ], "scope": [ - 340 + 342 ] } ], "appConnections": [ - 205, + 206, { "filter": [ - 360 + 362 ] } ], "appConnection": [ - 205, + 206, { "id": [ - 206, + 207, "ID!" ] } ], "findWorkspaceAiStats": [ - 337 + 339 ], "chatThreads": [ - 326 + 328 ], "chatThread": [ - 326, + 328, { "id": [ 4, @@ -6884,7 +6936,7 @@ export default { } ], "chatMessages": [ - 325, + 327, { "threadId": [ 4, @@ -6893,7 +6945,7 @@ export default { } ], "chatStreamCatchupChunks": [ - 330, + 332, { "threadId": [ 4, @@ -6902,13 +6954,13 @@ export default { } ], "getAiSystemPromptPreview": [ - 328 + 330 ], "skills": [ - 324 + 326 ], "skill": [ - 324, + 326, { "id": [ 4, @@ -6917,7 +6969,7 @@ export default { } ], "agentTurns": [ - 336, + 338, { "agentId": [ 4, @@ -6926,7 +6978,7 @@ export default { } ], "checkUserExists": [ - 266, + 267, { "email": [ 1, @@ -6938,7 +6990,7 @@ export default { } ], "checkWorkspaceInviteHashIsValid": [ - 267, + 268, { "inviteHash": [ 1, @@ -6956,7 +7008,7 @@ export default { } ], "checkWorkspaceSubdomainAvailability": [ - 261, + 262, { "subdomain": [ 1, @@ -6965,10 +7017,10 @@ export default { } ], "getWorkspaceCreationDefaults": [ - 262 + 263 ], "validatePasswordResetToken": [ - 259, + 260, { "passwordResetToken": [ 1, @@ -6980,46 +7032,46 @@ export default { 75 ], "getSSOIdentityProviders": [ - 211 + 212 ], "eventLogs": [ - 323, + 325, { "input": [ - 361, + 363, "EventLogQueryInput!" ] } ], "pieChartData": [ - 317, + 319, { "input": [ - 365, + 367, "PieChartDataInput!" ] } ], "lineChartData": [ - 315, + 317, { "input": [ - 366, + 368, "LineChartDataInput!" ] } ], "barChartData": [ - 312, + 314, { "input": [ - 367, + 369, "BarChartDataInput!" ] } ], "getConnectedImapSmtpCaldavAccount": [ - 303, + 305, { "id": [ 4, @@ -7028,7 +7080,7 @@ export default { } ], "getAutoCompleteAddress": [ - 298, + 300, { "address": [ 1, @@ -7047,7 +7099,7 @@ export default { } ], "getAddressDetails": [ - 300, + 302, { "placeId": [ 1, @@ -7060,15 +7112,15 @@ export default { } ], "getUsageAnalytics": [ - 271, + 273, { "input": [ - 368 + 370 ] } ], "findManyPublicDomains": [ - 275 + 277 ], "__typename": [ 1 @@ -7092,10 +7144,10 @@ export default { }, "ObjectFilter": { "and": [ - 356 + 358 ], "or": [ - 356 + 358 ], "id": [ 34 @@ -7127,7 +7179,7 @@ export default { }, "LogicFunctionIdInput": { "id": [ - 206 + 207 ], "__typename": [ 1 @@ -7146,7 +7198,7 @@ export default { }, "FindMessageSuppressionsInput": { "reason": [ - 293 + 295 ], "searchTerm": [ 1 @@ -7180,10 +7232,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 362 + 364 ], "filters": [ - 363 + 365 ], "first": [ 31 @@ -7204,7 +7256,7 @@ export default { 1 ], "dateRange": [ - 364 + 366 ], "recordId": [ 1 @@ -7271,7 +7323,7 @@ export default { 1 ], "operationTypes": [ - 369 + 371 ], "__typename": [ 1 @@ -7283,7 +7335,7 @@ export default { 3, { "input": [ - 371, + 373, "AddQuerySubscriptionInput!" ] } @@ -7292,49 +7344,49 @@ export default { 3, { "input": [ - 372, + 374, "RemoveQueryFromEventStreamInput!" ] } ], "createManyNavigationMenuItems": [ - 163, + 153, { "inputs": [ - 373, + 375, "[CreateNavigationMenuItemInput!]!" ] } ], "createNavigationMenuItem": [ - 163, + 153, { "input": [ - 373, + 375, "CreateNavigationMenuItemInput!" ] } ], "updateManyNavigationMenuItems": [ - 163, + 153, { "inputs": [ - 374, + 376, "[UpdateOneNavigationMenuItemInput!]!" ] } ], "updateNavigationMenuItem": [ - 163, + 153, { "input": [ - 374, + 376, "UpdateOneNavigationMenuItemInput!" ] } ], "deleteManyNavigationMenuItems": [ - 163, + 153, { "ids": [ 4, @@ -7343,7 +7395,7 @@ export default { } ], "deleteNavigationMenuItem": [ - 163, + 153, { "id": [ 4, @@ -7363,7 +7415,7 @@ export default { "Float!" ], "fileFolder": [ - 376, + 378, "FileFolder!" ], "fieldMetadataId": [ @@ -7402,7 +7454,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ] } @@ -7411,7 +7463,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ] } @@ -7420,7 +7472,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ] } @@ -7429,7 +7481,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ] } @@ -7438,7 +7490,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ] } @@ -7447,7 +7499,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ], "fieldMetadataId": [ @@ -7460,7 +7512,7 @@ export default { 150, { "file": [ - 377, + 379, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -7473,7 +7525,7 @@ export default { 58, { "input": [ - 378, + 380, "CreateViewFilterGroupInput!" ] } @@ -7486,7 +7538,7 @@ export default { "String!" ], "input": [ - 379, + 381, "UpdateViewFilterGroupInput!" ] } @@ -7513,7 +7565,7 @@ export default { 60, { "input": [ - 380, + 382, "CreateViewFilterInput!" ] } @@ -7522,7 +7574,7 @@ export default { 60, { "input": [ - 381, + 383, "UpdateViewFilterInput!" ] } @@ -7531,7 +7583,7 @@ export default { 60, { "input": [ - 383, + 385, "DeleteViewFilterInput!" ] } @@ -7540,7 +7592,7 @@ export default { 60, { "input": [ - 384, + 386, "DestroyViewFilterInput!" ] } @@ -7549,7 +7601,7 @@ export default { 66, { "input": [ - 385, + 387, "CreateViewInput!" ] } @@ -7562,7 +7614,7 @@ export default { "String!" ], "input": [ - 386, + 388, "UpdateViewInput!" ] } @@ -7589,7 +7641,7 @@ export default { 66, { "input": [ - 387, + 389, "UpsertViewWidgetInput!" ] } @@ -7598,7 +7650,7 @@ export default { 63, { "input": [ - 393, + 395, "CreateViewSortInput!" ] } @@ -7607,7 +7659,7 @@ export default { 63, { "input": [ - 394, + 396, "UpdateViewSortInput!" ] } @@ -7616,7 +7668,7 @@ export default { 3, { "input": [ - 396, + 398, "DeleteViewSortInput!" ] } @@ -7625,7 +7677,7 @@ export default { 3, { "input": [ - 397, + 399, "DestroyViewSortInput!" ] } @@ -7634,7 +7686,7 @@ export default { 56, { "input": [ - 398, + 400, "UpdateViewFieldInput!" ] } @@ -7643,7 +7695,7 @@ export default { 56, { "input": [ - 400, + 402, "CreateViewFieldInput!" ] } @@ -7652,7 +7704,7 @@ export default { 56, { "inputs": [ - 400, + 402, "[CreateViewFieldInput!]!" ] } @@ -7661,7 +7713,7 @@ export default { 56, { "input": [ - 401, + 403, "DeleteViewFieldInput!" ] } @@ -7670,7 +7722,7 @@ export default { 56, { "input": [ - 402, + 404, "DestroyViewFieldInput!" ] } @@ -7679,7 +7731,7 @@ export default { 65, { "input": [ - 403, + 405, "UpdateViewFieldGroupInput!" ] } @@ -7688,7 +7740,7 @@ export default { 65, { "input": [ - 405, + 407, "CreateViewFieldGroupInput!" ] } @@ -7697,7 +7749,7 @@ export default { 65, { "inputs": [ - 405, + 407, "[CreateViewFieldGroupInput!]!" ] } @@ -7706,7 +7758,7 @@ export default { 65, { "input": [ - 406, + 408, "DeleteViewFieldGroupInput!" ] } @@ -7715,7 +7767,7 @@ export default { 65, { "input": [ - 407, + 409, "DestroyViewFieldGroupInput!" ] } @@ -7724,7 +7776,7 @@ export default { 66, { "input": [ - 408, + 410, "UpsertFieldsWidgetInput!" ] } @@ -7733,7 +7785,7 @@ export default { 7, { "input": [ - 411, + 413, "CreateApiKeyInput!" ] } @@ -7742,7 +7794,7 @@ export default { 7, { "input": [ - 412, + 414, "UpdateApiKeyInput!" ] } @@ -7751,7 +7803,7 @@ export default { 7, { "input": [ - 413, + 415, "RevokeApiKeyInput!" ] } @@ -7769,11 +7821,23 @@ export default { ] } ], + "revokeUserSession": [ + 3, + { + "userSessionId": [ + 4, + "UUID!" + ] + } + ], + "revokeAllOtherUserSessions": [ + 31 + ], "skipSyncEmailOnboardingStep": [ - 159 + 170 ], "triggerInstallAppsOnboardingStep": [ - 159, + 170, { "universalIdentifiers": [ 1, @@ -7799,7 +7863,7 @@ export default { } ], "checkoutSession": [ - 156, + 167, { "recurringInterval": [ 135, @@ -7819,7 +7883,7 @@ export default { } ], "createSubscriptionPaymentIntent": [ - 155, + 166, { "recurringInterval": [ 135, @@ -7843,22 +7907,22 @@ export default { } ], "createBillingPaymentMethodSetupIntent": [ - 155 + 166 ], "switchSubscriptionInterval": [ - 157 + 168 ], "switchBillingPlan": [ - 157 + 168 ], "cancelSwitchBillingPlan": [ - 157 + 168 ], "cancelSwitchBillingInterval": [ - 157 + 168 ], "setResourceCreditSubscriptionPrice": [ - 157, + 168, { "priceId": [ 1, @@ -7867,10 +7931,10 @@ export default { } ], "endSubscriptionTrialPeriod": [ - 152 + 163 ], "cancelSwitchResourceCreditPrice": [ - 157 + 168 ], "deleteWorkspaceInvitation": [ 1, @@ -7882,7 +7946,7 @@ export default { } ], "resendWorkspaceInvitation": [ - 161, + 172, { "appTokenId": [ 1, @@ -7891,7 +7955,7 @@ export default { } ], "sendInvitations": [ - 161, + 172, { "emails": [ 1, @@ -7906,7 +7970,7 @@ export default { 149, { "input": [ - 414, + 416, "CreateApprovedAccessDomainInput!" ] } @@ -7915,7 +7979,7 @@ export default { 3, { "input": [ - 415, + 417, "DeleteApprovedAccessDomainInput!" ] } @@ -7924,7 +7988,7 @@ export default { 149, { "input": [ - 416, + 418, "ValidateApprovedAccessDomainInput!" ] } @@ -7933,7 +7997,7 @@ export default { 123, { "input": [ - 417, + 419, "CreatePageLayoutTabInput!" ] } @@ -7946,7 +8010,7 @@ export default { "String!" ], "input": [ - 418, + 420, "UpdatePageLayoutTabInput!" ] } @@ -7964,7 +8028,7 @@ export default { 124, { "input": [ - 419, + 421, "CreatePageLayoutInput!" ] } @@ -7977,7 +8041,7 @@ export default { "String!" ], "input": [ - 420, + 422, "UpdatePageLayoutInput!" ] } @@ -7999,7 +8063,7 @@ export default { "String!" ], "input": [ - 421, + 423, "UpdatePageLayoutWithTabsInput!" ] } @@ -8035,7 +8099,7 @@ export default { 83, { "input": [ - 425, + 427, "CreatePageLayoutWidgetInput!" ] } @@ -8048,7 +8112,7 @@ export default { "String!" ], "input": [ - 426, + 428, "UpdatePageLayoutWidgetInput!" ] } @@ -8066,7 +8130,7 @@ export default { 11, { "input": [ - 427, + 429, "CreateAgentInput!" ] } @@ -8075,7 +8139,7 @@ export default { 11, { "input": [ - 428, + 430, "UpdateAgentInput!" ] } @@ -8084,7 +8148,7 @@ export default { 11, { "input": [ - 355, + 357, "AgentIdInput!" ] } @@ -8093,7 +8157,7 @@ export default { 28, { "input": [ - 429, + 431, "CreateOneObjectInput!" ] } @@ -8102,7 +8166,7 @@ export default { 28, { "input": [ - 431, + 433, "DeleteOneObjectInput!" ] } @@ -8111,7 +8175,7 @@ export default { 28, { "input": [ - 432, + 434, "UpdateOneObjectInput!" ] } @@ -8120,7 +8184,7 @@ export default { 26, { "input": [ - 434, + 436, "CreateOneIndexInput!" ] } @@ -8129,7 +8193,7 @@ export default { 26, { "input": [ - 437, + 439, "DeleteOneIndexInput!" ] } @@ -8138,7 +8202,7 @@ export default { 22, { "input": [ - 357, + 359, "LogicFunctionIdInput!" ] } @@ -8147,7 +8211,7 @@ export default { 22, { "input": [ - 438, + 440, "CreateLogicFunctionFromSourceInput!" ] } @@ -8156,7 +8220,7 @@ export default { 145, { "input": [ - 439, + 441, "ExecuteOneLogicFunctionInput!" ] } @@ -8165,7 +8229,7 @@ export default { 3, { "input": [ - 440, + 442, "UpdateLogicFunctionFromSourceInput!" ] } @@ -8174,7 +8238,7 @@ export default { 15, { "input": [ - 442, + 444, "CreateCommandMenuItemInput!" ] } @@ -8183,7 +8247,7 @@ export default { 15, { "input": [ - 443, + 445, "UpdateCommandMenuItemInput!" ] } @@ -8210,7 +8274,7 @@ export default { 14, { "input": [ - 444, + 446, "CreateFrontComponentInput!" ] } @@ -8219,7 +8283,7 @@ export default { 14, { "input": [ - 445, + 447, "UpdateFrontComponentInput!" ] } @@ -8237,7 +8301,7 @@ export default { 72, { "data": [ - 447, + 449, "ActivateWorkspaceInput!" ] } @@ -8246,7 +8310,7 @@ export default { 72, { "data": [ - 448, + 450, "UpdateWorkspaceInput!" ] } @@ -8255,10 +8319,10 @@ export default { 72 ], "checkCustomDomainValidRecords": [ - 240 + 241 ], "enrichWorkspaceCompany": [ - 222 + 223 ], "upgradeApplication": [ 3, @@ -8274,10 +8338,10 @@ export default { } ], "createApplicationRegistration": [ - 202, + 203, { "input": [ - 449, + 451, "CreateApplicationRegistrationInput!" ] } @@ -8286,7 +8350,7 @@ export default { 78, { "input": [ - 450, + 452, "UpdateApplicationRegistrationInput!" ] } @@ -8301,7 +8365,7 @@ export default { } ], "rotateApplicationRegistrationClientSecret": [ - 204, + 205, { "id": [ 1, @@ -8313,7 +8377,7 @@ export default { 2, { "input": [ - 452, + 454, "CreateApplicationRegistrationVariableInput!" ] } @@ -8322,7 +8386,7 @@ export default { 2, { "input": [ - 453, + 455, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8340,7 +8404,7 @@ export default { 78, { "file": [ - 377, + 379, "Upload!" ], "universalIdentifier": [ @@ -8402,7 +8466,7 @@ export default { "UUID!" ], "input": [ - 455, + 457, "UpdateApplicationInput!" ] } @@ -8423,7 +8487,7 @@ export default { 24, { "input": [ - 456, + 458, "CreateOneFieldMetadataInput!" ] } @@ -8432,7 +8496,7 @@ export default { 24, { "input": [ - 458, + 460, "UpdateOneFieldMetadataInput!" ] } @@ -8441,7 +8505,7 @@ export default { 24, { "input": [ - 460, + 462, "DeleteOneFieldInput!" ] } @@ -8450,7 +8514,7 @@ export default { 62, { "input": [ - 461, + 463, "CreateViewGroupInput!" ] } @@ -8459,7 +8523,7 @@ export default { 62, { "inputs": [ - 461, + 463, "[CreateViewGroupInput!]!" ] } @@ -8468,7 +8532,7 @@ export default { 62, { "input": [ - 462, + 464, "UpdateViewGroupInput!" ] } @@ -8477,7 +8541,7 @@ export default { 62, { "inputs": [ - 462, + 464, "[UpdateViewGroupInput!]!" ] } @@ -8486,7 +8550,7 @@ export default { 62, { "input": [ - 464, + 466, "DeleteViewGroupInput!" ] } @@ -8495,7 +8559,7 @@ export default { 62, { "input": [ - 465, + 467, "DestroyViewGroupInput!" ] } @@ -8517,7 +8581,7 @@ export default { 51, { "createRoleInput": [ - 466, + 468, "CreateRoleInput!" ] } @@ -8526,7 +8590,7 @@ export default { 51, { "updateRoleInput": [ - 467, + 469, "UpdateRoleInput!" ] } @@ -8544,7 +8608,7 @@ export default { 48, { "upsertObjectPermissionsInput": [ - 469, + 471, "UpsertObjectPermissionsInput!" ] } @@ -8553,7 +8617,7 @@ export default { 49, { "upsertPermissionFlagsInput": [ - 471, + 473, "UpsertPermissionFlagsInput!" ] } @@ -8562,16 +8626,16 @@ export default { 43, { "upsertFieldPermissionsInput": [ - 472, + 474, "UpsertFieldPermissionsInput!" ] } ], "upsertRowLevelPermissionPredicates": [ - 241, + 242, { "input": [ - 474, + 476, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -8599,46 +8663,46 @@ export default { } ], "sendEmailViaEmailingDomain": [ - 289, + 291, { "input": [ - 477, + 479, "SendEmailViaDomainInput!" ] } ], "sendMessageCampaign": [ - 291, + 293, { "input": [ - 478, + 480, "SendMessageCampaignInput!" ] } ], "sendMessageCampaignTest": [ - 289, + 291, { "input": [ - 479, + 481, "SendMessageCampaignTestInput!" ] } ], "createUnsubscribeTopic": [ - 296, + 298, { "input": [ - 480, + 482, "CreateUnsubscribeTopicInput!" ] } ], "updateUnsubscribeTopic": [ - 296, + 298, { "input": [ - 481, + 483, "UpdateUnsubscribeTopicInput!" ] } @@ -8653,34 +8717,34 @@ export default { } ], "updateMessageChannel": [ - 279, + 281, { "input": [ - 482, + 484, "UpdateMessageChannelInput!" ] } ], "createEmailGroupChannel": [ - 287, + 289, { "input": [ - 484, + 486, "CreateEmailGroupChannelInput!" ] } ], "updateEmailGroupChannel": [ - 279, + 281, { "input": [ - 485, + 487, "UpdateEmailGroupChannelInput!" ] } ], "deleteEmailGroupChannel": [ - 279, + 281, { "id": [ 4, @@ -8689,10 +8753,10 @@ export default { } ], "createEmailingDomain": [ - 277, + 279, { "input": [ - 486, + 488, "CreateEmailingDomainInput!" ] } @@ -8707,7 +8771,7 @@ export default { } ], "verifyEmailingDomain": [ - 277, + 279, { "id": [ 1, @@ -8716,7 +8780,7 @@ export default { } ], "deleteConnectedAccount": [ - 246, + 247, { "id": [ 4, @@ -8725,34 +8789,34 @@ export default { } ], "runAgent": [ - 308, + 310, { "input": [ - 487, + 489, "RunAgentInput!" ] } ], "createWebhook": [ - 305, + 307, { "input": [ - 488, + 490, "CreateWebhookInput!" ] } ], "updateWebhook": [ - 305, + 307, { "input": [ - 489, + 491, "UpdateWebhookInput!" ] } ], "deleteWebhook": [ - 305, + 307, { "id": [ 4, @@ -8761,37 +8825,37 @@ export default { } ], "updateMessageFolder": [ - 346, + 348, { "input": [ - 491, + 493, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 346, + 348, { "input": [ - 493, + 495, "UpdateMessageFoldersInput!" ] } ], "updateCalendarChannel": [ - 341, + 343, { "input": [ - 494, + 496, "UpdateCalendarChannelInput!" ] } ], "setAppKeyValue": [ - 339, + 341, { "input": [ - 496, + 498, "SetAppKeyValueInput!" ] } @@ -8804,24 +8868,24 @@ export default { "String!" ], "scope": [ - 340 + 342 ] } ], "enqueueJob": [ - 338, + 340, { "input": [ - 497, + 499, "EnqueueJobInput!" ] } ], "createChatThread": [ - 326 + 328 ], "sendChatMessage": [ - 331, + 333, { "threadId": [ 4, @@ -8842,13 +8906,13 @@ export default { 1 ], "fileAttachments": [ - 498, + 500, "[FileAttachmentInput!]" ] } ], "retryChatMessage": [ - 331, + 333, { "threadId": [ 4, @@ -8860,7 +8924,7 @@ export default { } ], "answerAgentChatQuestion": [ - 331, + 333, { "threadId": [ 4, @@ -8871,7 +8935,7 @@ export default { "UUID!" ], "answers": [ - 499, + 501, "[AgentChatQuestionAnswerInput!]!" ], "modelId": [ @@ -8889,7 +8953,7 @@ export default { } ], "renameChatThread": [ - 326, + 328, { "id": [ 4, @@ -8902,7 +8966,7 @@ export default { } ], "archiveChatThread": [ - 326, + 328, { "id": [ 4, @@ -8911,7 +8975,7 @@ export default { } ], "unarchiveChatThread": [ - 326, + 328, { "id": [ 4, @@ -8938,7 +9002,7 @@ export default { } ], "startWorkspaceSetupChat": [ - 333, + 335, { "companyContext": [ 5 @@ -8946,25 +9010,25 @@ export default { } ], "createSkill": [ - 324, + 326, { "input": [ - 500, + 502, "CreateSkillInput!" ] } ], "updateSkill": [ - 324, + 326, { "input": [ - 501, + 503, "UpdateSkillInput!" ] } ], "deleteSkill": [ - 324, + 326, { "id": [ 4, @@ -8973,7 +9037,7 @@ export default { } ], "activateSkill": [ - 324, + 326, { "id": [ 4, @@ -8982,7 +9046,7 @@ export default { } ], "deactivateSkill": [ - 324, + 326, { "id": [ 4, @@ -8991,7 +9055,7 @@ export default { } ], "evaluateAgentTurn": [ - 335, + 337, { "turnId": [ 4, @@ -9000,7 +9064,7 @@ export default { } ], "runEvaluationInput": [ - 336, + 338, { "agentId": [ 4, @@ -9013,16 +9077,16 @@ export default { } ], "getAuthorizationUrlForSSO": [ - 254, + 255, { "input": [ - 502, + 504, "GetAuthorizationUrlForSSOInput!" ] } ], "getLoginTokenFromCredentials": [ - 265, + 266, { "email": [ 1, @@ -9048,7 +9112,7 @@ export default { } ], "signIn": [ - 252, + 253, { "email": [ 1, @@ -9070,7 +9134,7 @@ export default { } ], "verifyEmailAndGetLoginToken": [ - 260, + 261, { "emailVerificationToken": [ 1, @@ -9090,7 +9154,7 @@ export default { } ], "verifyEmailAndGetWorkspaceAgnosticToken": [ - 252, + 253, { "emailVerificationToken": [ 1, @@ -9106,7 +9170,7 @@ export default { } ], "getAuthTokensFromOTP": [ - 264, + 265, { "otp": [ 1, @@ -9126,7 +9190,7 @@ export default { } ], "signUp": [ - 252, + 253, { "email": [ 1, @@ -9148,7 +9212,7 @@ export default { } ], "signUpInWorkspace": [ - 257, + 258, { "email": [ 1, @@ -9179,10 +9243,10 @@ export default { } ], "signUpInNewWorkspace": [ - 257, + 258, { "input": [ - 503 + 505 ] } ], @@ -9194,16 +9258,16 @@ export default { "String!" ], "file": [ - 377, + 379, "Upload!" ] } ], "generateTransientToken": [ - 258 + 259 ], "getAuthTokensFromLoginToken": [ - 264, + 265, { "loginToken": [ 1, @@ -9216,7 +9280,7 @@ export default { } ], "getAuthTokensFromSSOExchangeToken": [ - 264, + 265, { "ssoExchangeToken": [ 1, @@ -9225,7 +9289,7 @@ export default { } ], "authorizeApp": [ - 250, + 251, { "clientId": [ 1, @@ -9247,7 +9311,7 @@ export default { } ], "renewToken": [ - 264, + 265, { "appToken": [ 1, @@ -9255,8 +9319,16 @@ export default { ] } ], + "signOut": [ + 3, + { + "refreshToken": [ + 1 + ] + } + ], "generateApiKeyToken": [ - 263, + 264, { "apiKeyId": [ 4, @@ -9272,7 +9344,7 @@ export default { 12 ], "emailPasswordResetLink": [ - 253, + 254, { "email": [ 1, @@ -9287,7 +9359,7 @@ export default { } ], "updatePasswordViaResetToken": [ - 255, + 256, { "passwordResetToken": [ 1, @@ -9300,7 +9372,7 @@ export default { } ], "initiateOTPProvisioning": [ - 248, + 249, { "loginToken": [ 1, @@ -9313,10 +9385,10 @@ export default { } ], "initiateOTPProvisioningForAuthenticatedUser": [ - 248 + 249 ], "deleteTwoFactorAuthenticationMethod": [ - 247, + 248, { "twoFactorAuthenticationMethodId": [ 4, @@ -9325,7 +9397,7 @@ export default { } ], "verifyTwoFactorAuthenticationMethodForAuthenticatedUser": [ - 249, + 250, { "otp": [ 1, @@ -9349,7 +9421,7 @@ export default { 3, { "input": [ - 504, + 506, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -9367,7 +9439,7 @@ export default { } ], "resendEmailVerificationToken": [ - 207, + 208, { "email": [ 1, @@ -9380,43 +9452,43 @@ export default { } ], "createOIDCIdentityProvider": [ - 212, + 213, { "input": [ - 505, + 507, "SetupOIDCSsoInput!" ] } ], "createSAMLIdentityProvider": [ - 212, + 213, { "input": [ - 506, + 508, "SetupSAMLSsoInput!" ] } ], "deleteSSOIdentityProvider": [ - 208, + 209, { "input": [ - 507, + 509, "DeleteSsoInput!" ] } ], "editSSOIdentityProvider": [ - 209, + 210, { "input": [ - 508, + 510, "EditSsoInput!" ] } ], "createObjectEvent": [ - 320, + 322, { "event": [ 1, @@ -9436,10 +9508,10 @@ export default { } ], "trackAnalytics": [ - 320, + 322, { "type": [ - 509, + 511, "AnalyticsType!" ], "name": [ @@ -9454,7 +9526,7 @@ export default { } ], "duplicateDashboard": [ - 318, + 320, { "id": [ 4, @@ -9463,7 +9535,7 @@ export default { } ], "impersonate": [ - 268, + 269, { "userId": [ 4, @@ -9475,26 +9547,29 @@ export default { ] } ], + "stopImpersonation": [ + 270 + ], "createCalendarEvent": [ - 310, + 312, { "input": [ - 510, + 512, "CreateCalendarEventInput!" ] } ], "sendEmail": [ - 319, + 321, { "input": [ - 511, + 513, "SendEmailInput!" ] } ], "startChannelSync": [ - 309, + 311, { "connectedAccountId": [ 4, @@ -9503,14 +9578,14 @@ export default { } ], "saveImapSmtpCaldavAccount": [ - 304, + 306, { "handle": [ 1, "String!" ], "connectionParameters": [ - 513, + 515, "EmailAccountConnectionParameters!" ], "id": [ @@ -9519,16 +9594,16 @@ export default { } ], "updateLabPublicFeatureFlag": [ - 172, + 173, { "input": [ - 515, + 517, "UpdateLabPublicFeatureFlagInput!" ] } ], "createPublicDomain": [ - 275, + 277, { "domain": [ 1, @@ -9550,7 +9625,7 @@ export default { } ], "checkPublicDomainValidRecords": [ - 240, + 241, { "domain": [ 1, @@ -9559,7 +9634,7 @@ export default { } ], "createDevelopmentApplication": [ - 272, + 274, { "universalIdentifier": [ 1, @@ -9572,7 +9647,7 @@ export default { } ], "syncApplication": [ - 273, + 275, { "manifest": [ 5, @@ -9584,10 +9659,10 @@ export default { } ], "uploadApplicationFile": [ - 274, + 276, { "file": [ - 377, + 379, "Upload!" ], "applicationUniversalIdentifier": [ @@ -9595,7 +9670,7 @@ export default { "String!" ], "fileFolder": [ - 376, + 378, "FileFolder!" ], "filePath": [ @@ -9668,7 +9743,7 @@ export default { 4 ], "type": [ - 164 + 154 ], "name": [ 1 @@ -9700,7 +9775,7 @@ export default { 4 ], "update": [ - 375 + 377 ], "__typename": [ 1 @@ -9811,7 +9886,7 @@ export default { 4 ], "update": [ - 382 + 384 ], "__typename": [ 1 @@ -9982,20 +10057,20 @@ export default { 4 ], "view": [ - 388 - ], - "viewFields": [ - 389 - ], - "viewFilters": [ 390 ], - "viewFilterGroups": [ + "viewFields": [ 391 ], - "viewSorts": [ + "viewFilters": [ 392 ], + "viewFilterGroups": [ + 393 + ], + "viewSorts": [ + 394 + ], "__typename": [ 1 ] @@ -10143,7 +10218,7 @@ export default { 4 ], "update": [ - 395 + 397 ], "__typename": [ 1 @@ -10181,7 +10256,7 @@ export default { 4 ], "update": [ - 399 + 401 ], "__typename": [ 1 @@ -10257,7 +10332,7 @@ export default { 4 ], "update": [ - 404 + 406 ], "__typename": [ 1 @@ -10321,10 +10396,10 @@ export default { 4 ], "groups": [ - 409 + 411 ], "fields": [ - 410 + 412 ], "__typename": [ 1 @@ -10344,7 +10419,7 @@ export default { 3 ], "fields": [ - 410 + 412 ], "__typename": [ 1 @@ -10512,7 +10587,7 @@ export default { 4 ], "tabs": [ - 422 + 424 ], "__typename": [ 1 @@ -10535,7 +10610,7 @@ export default { 87 ], "widgets": [ - 423 + 425 ], "__typename": [ 1 @@ -10558,7 +10633,7 @@ export default { 4 ], "gridPosition": [ - 424 + 426 ], "position": [ 5 @@ -10607,7 +10682,7 @@ export default { 4 ], "gridPosition": [ - 424 + 426 ], "position": [ 5 @@ -10633,7 +10708,7 @@ export default { 4 ], "gridPosition": [ - 424 + 426 ], "position": [ 5 @@ -10726,7 +10801,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 430 + 432 ], "__typename": [ 1 @@ -10786,7 +10861,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 433 + 435 ], "id": [ 4 @@ -10844,7 +10919,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 435 + 437 ], "__typename": [ 1 @@ -10855,7 +10930,7 @@ export default { 4 ], "fields": [ - 436 + 438 ], "indexType": [ 27 @@ -10940,7 +11015,7 @@ export default { 4 ], "update": [ - 441 + 443 ], "__typename": [ 1 @@ -11100,7 +11175,7 @@ export default { 4 ], "update": [ - 446 + 448 ], "__typename": [ 1 @@ -11227,7 +11302,7 @@ export default { 1 ], "update": [ - 451 + 453 ], "__typename": [ 1 @@ -11281,7 +11356,7 @@ export default { 1 ], "update": [ - 454 + 456 ], "__typename": [ 1 @@ -11311,7 +11386,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 457 + 459 ], "__typename": [ 1 @@ -11384,7 +11459,7 @@ export default { 4 ], "update": [ - 459 + 461 ], "__typename": [ 1 @@ -11479,7 +11554,7 @@ export default { 4 ], "update": [ - 463 + 465 ], "__typename": [ 1 @@ -11564,7 +11639,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 468 + 470 ], "id": [ 4 @@ -11619,7 +11694,7 @@ export default { 4 ], "objectPermissions": [ - 470 + 472 ], "__typename": [ 1 @@ -11661,7 +11736,7 @@ export default { 4 ], "fieldPermissions": [ - 473 + 475 ], "__typename": [ 1 @@ -11692,10 +11767,10 @@ export default { 4 ], "predicates": [ - 475 + 477 ], "predicateGroups": [ - 476 + 478 ], "__typename": [ 1 @@ -11821,7 +11896,7 @@ export default { 1 ], "visibility": [ - 297 + 299 ], "__typename": [ 1 @@ -11838,7 +11913,7 @@ export default { 1 ], "visibility": [ - 297 + 299 ], "__typename": [ 1 @@ -11849,7 +11924,7 @@ export default { 4 ], "update": [ - 483 + 485 ], "__typename": [ 1 @@ -11857,16 +11932,16 @@ export default { }, "UpdateMessageChannelInputUpdates": { "visibility": [ - 280 + 282 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 282 + 284 ], "messageFolderImportPolicy": [ - 283 + 285 ], "isSyncEnabled": [ 3 @@ -11947,7 +12022,7 @@ export default { 4 ], "update": [ - 490 + 492 ], "__typename": [ 1 @@ -11975,7 +12050,7 @@ export default { 4 ], "update": [ - 492 + 494 ], "__typename": [ 1 @@ -11994,7 +12069,7 @@ export default { 4 ], "update": [ - 492 + 494 ], "__typename": [ 1 @@ -12005,7 +12080,7 @@ export default { 4 ], "update": [ - 495 + 497 ], "__typename": [ 1 @@ -12013,13 +12088,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 344 + 346 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 345 + 347 ], "isSyncEnabled": [ 3 @@ -12036,7 +12111,7 @@ export default { 5 ], "scope": [ - 340 + 342 ], "__typename": [ 1 @@ -12219,7 +12294,7 @@ export default { 4 ], "status": [ - 181 + 182 ], "__typename": [ 1 @@ -12290,7 +12365,7 @@ export default { 1 ], "files": [ - 512 + 514 ], "__typename": [ 1 @@ -12312,13 +12387,13 @@ export default { 1 ], "IMAP": [ - 514 + 516 ], "SMTP": [ - 514 + 516 ], "CALDAV": [ - 514 + 516 ], "__typename": [ 1 @@ -12338,7 +12413,7 @@ export default { 1 ], "connectionSecurity": [ - 244 + 245 ], "__typename": [ 1 @@ -12357,7 +12432,7 @@ export default { }, "Subscription": { "onEventSubscription": [ - 171, + 161, { "eventStreamId": [ 1, @@ -12366,16 +12441,16 @@ export default { } ], "logicFunctionLogs": [ - 242, + 243, { "input": [ - 517, + 519, "LogicFunctionLogsInput!" ] } ], "onAgentChatEvent": [ - 332, + 334, { "threadId": [ 4, @@ -12384,10 +12459,10 @@ export default { } ], "eventLogsLive": [ - 321, + 323, { "table": [ - 362, + 364, "EventLogTable!" ] } diff --git a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx index 4f2332016a..722b2eca6d 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx @@ -375,3 +375,24 @@ LOGIC_FUNCTION_TYPE=DISABLED # default when NODE_ENV=production When using `LOGIC_FUNCTION_TYPE=DISABLED` or `CODE_INTERPRETER_TYPE=DISABLED`, any attempt to execute will return an error. This is useful if you want to run Twenty without these capabilities. + +## API and front-end on separate hosts + +Only relevant if you do not let the back-end serve the front-end. Same-origin +deployments need nothing here. + +Declare every browser origin that talks to the API, comma separated. `SERVER_URL` +and `FRONTEND_URL` are already trusted. + +```bash +AUTH_COOKIE_ALLOWED_ORIGINS=https://app.example.com +``` + + +Upgrading to a version with cookie sessions makes this required. The front-end +now sends credentials on every request, and browsers reject a credentialed +response from an undeclared origin, so the app fails to load until you set this. + + +If the two hosts are on different registrable domains (not just different +subdomains), also set `AUTH_COOKIE_SAME_SITE=none`, which requires HTTPS. diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 4e3f8761c0..92495ea6b6 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -952,6 +952,7 @@ export type ClientConfig = { isClickHouseConfigured: Scalars['Boolean']['output']; isCloudflareIntegrationEnabled: Scalars['Boolean']['output']; isConfigVariablesInDbEnabled: Scalars['Boolean']['output']; + isCookieSessionEnabled: Scalars['Boolean']['output']; isEmailVerificationRequired: Scalars['Boolean']['output']; isEmailingDomainInDemoMode: Scalars['Boolean']['output']; isGoogleCalendarEnabled: Scalars['Boolean']['output']; @@ -2720,7 +2721,9 @@ export type Mutation = { resetPageLayoutToDefault: PageLayout; resetPageLayoutWidgetToDefault: PageLayoutWidget; retryChatMessage: SendChatMessageResult; + revokeAllOtherUserSessions: Scalars['Int']['output']; revokeApiKey?: Maybe; + revokeUserSession: Scalars['Boolean']['output']; rotateApplicationRegistrationClientSecret: RotateClientSecret; runAgent: RunAgentResult; runEvaluationInput: AgentTurn; @@ -2735,6 +2738,7 @@ export type Mutation = { setEnterpriseKey: EnterpriseLicenseInfoDto; setResourceCreditSubscriptionPrice: BillingUpdate; signIn: AvailableWorkspacesAndAccessTokens; + signOut: Scalars['Boolean']['output']; signUp: AvailableWorkspacesAndAccessTokens; signUpInNewWorkspace: SignUp; signUpInWorkspace: SignUp; @@ -2742,6 +2746,7 @@ export type Mutation = { startChannelSync: ChannelSyncSuccess; startWorkspaceSetupChat: StartWorkspaceSetupChatResult; stopAgentChatStream: Scalars['Boolean']['output']; + stopImpersonation: StopImpersonation; switchBillingPlan: BillingUpdate; switchSubscriptionInterval: BillingUpdate; syncApplication: WorkspaceMigration; @@ -3496,6 +3501,11 @@ export type MutationRevokeApiKeyArgs = { }; +export type MutationRevokeUserSessionArgs = { + userSessionId: Scalars['UUID']['input']; +}; + + export type MutationRotateApplicationRegistrationClientSecretArgs = { id: Scalars['String']['input']; }; @@ -3579,6 +3589,11 @@ export type MutationSignInArgs = { }; +export type MutationSignOutArgs = { + refreshToken?: InputMaybe; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']['input']; @@ -4498,6 +4513,7 @@ export type Query = { commandMenuItem?: Maybe; commandMenuItems: Array; currentUser: User; + currentUserSessions: Array; currentWorkspace: Workspace; enterpriseCheckoutSession?: Maybe; enterprisePortalSession?: Maybe; @@ -5382,6 +5398,11 @@ export type StartWorkspaceSetupChatResult = { thread?: Maybe; }; +export type StopImpersonation = { + __typename?: 'StopImpersonation'; + canRestoreImpersonatorSession: Scalars['Boolean']['output']; +}; + export type SubdomainAvailabilityDto = { __typename?: 'SubdomainAvailabilityDTO'; available: Scalars['Boolean']['output']; @@ -6159,11 +6180,26 @@ export type User = { workspaces: Array; }; +export type UserSession = { + __typename?: 'UserSession'; + authProvider: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + expiresAt: Scalars['DateTime']['output']; + id: Scalars['UUID']['output']; + ipAddress?: Maybe; + isCurrent: Scalars['Boolean']['output']; + isImpersonating: Scalars['Boolean']['output']; + lastActiveAt: Scalars['DateTime']['output']; + userAgent?: Maybe; + workspaceId?: Maybe; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']['output']; deletedAt?: Maybe; id: Scalars['UUID']['output']; + isImpersonating?: Maybe; locale: Scalars['String']['output']; objectPermissions?: Maybe>; objectsPermissions?: Maybe>; @@ -7138,6 +7174,13 @@ export type SignInMutationVariables = Exact<{ export type SignInMutation = { __typename?: 'Mutation', signIn: { __typename?: 'AvailableWorkspacesAndAccessTokens', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type SignOutMutationVariables = Exact<{ + refreshToken?: InputMaybe; +}>; + + +export type SignOutMutation = { __typename?: 'Mutation', signOut: boolean }; + export type SignUpMutationVariables = Exact<{ email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -7170,6 +7213,11 @@ export type SignUpInWorkspaceMutationVariables = Exact<{ export type SignUpInWorkspaceMutation = { __typename?: 'Mutation', signUpInWorkspace: { __typename?: 'SignUp', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } }; +export type StopImpersonationMutationVariables = Exact<{ [key: string]: never; }>; + + +export type StopImpersonationMutation = { __typename?: 'Mutation', stopImpersonation: { __typename?: 'StopImpersonation', canRestoreImpersonatorSession: boolean } }; + export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ token: Scalars['String']['input']; newPassword: Scalars['String']['input']; @@ -8511,6 +8559,23 @@ export type UploadWorkspaceMemberProfilePictureMutationVariables = Exact<{ export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'FileWithSignedUrl', id: string, url: string } }; +export type RevokeAllOtherUserSessionsMutationVariables = Exact<{ [key: string]: never; }>; + + +export type RevokeAllOtherUserSessionsMutation = { __typename?: 'Mutation', revokeAllOtherUserSessions: number }; + +export type RevokeUserSessionMutationVariables = Exact<{ + userSessionId: Scalars['UUID']['input']; +}>; + + +export type RevokeUserSessionMutation = { __typename?: 'Mutation', revokeUserSession: boolean }; + +export type CurrentUserSessionsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type CurrentUserSessionsQuery = { __typename?: 'Query', currentUserSessions: Array<{ __typename?: 'UserSession', id: string, workspaceId?: string | null, authProvider: string, isImpersonating: boolean, userAgent?: string | null, ipAddress?: string | null, createdAt: string, lastActiveAt: string, expiresAt: string, isCurrent: boolean }> }; + export type UpdateUserEmailMutationVariables = Exact<{ newEmail: Scalars['String']['input']; verifyEmailRedirectPath?: InputMaybe; @@ -8710,7 +8775,7 @@ export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'Billing | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, openRecordIn: OpenRecordIn, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, workspaceDiscoverability: WorkspaceDiscoverability, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, isInternalMessagesImportEnabled: boolean, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, installedApplications: Array<{ __typename?: 'Application', id: string, name: string, universalIdentifier: string, logoUrl?: string | null }>, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, openRecordIn: OpenRecordIn, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, isImpersonating?: boolean | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, workspaceDiscoverability: WorkspaceDiscoverability, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, isInternalMessagesImportEnabled: boolean, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, installedApplications: Array<{ __typename?: 'Application', id: string, name: string, universalIdentifier: string, logoUrl?: string | null }>, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: | { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingCustomer?: { __typename?: 'BillingCustomer', id: string, hasPaymentMethod?: boolean | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; @@ -8732,7 +8797,7 @@ export type DeleteUserWorkspaceMutation = { __typename?: 'Mutation', deleteUserF export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, openRecordIn: OpenRecordIn, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, workspaceDiscoverability: WorkspaceDiscoverability, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, isInternalMessagesImportEnabled: boolean, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, installedApplications: Array<{ __typename?: 'Application', id: string, name: string, universalIdentifier: string, logoUrl?: string | null }>, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, openRecordIn: OpenRecordIn, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, isImpersonating?: boolean | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, workspaceDiscoverability: WorkspaceDiscoverability, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, isInternalMessagesImportEnabled: boolean, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, installedApplications: Array<{ __typename?: 'Application', id: string, name: string, universalIdentifier: string, logoUrl?: string | null }>, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: | { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingCustomer?: { __typename?: 'BillingCustomer', id: string, hasPaymentMethod?: boolean | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, cancelAt?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; @@ -9177,7 +9242,7 @@ export const BillingSubscriptionFragmentFragmentDoc = {"kind":"Document","defini export const RoleFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]} as unknown as DocumentNode; export const AvailableWorkspaceFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const AvailableWorkspacesFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; -export const UserQueryFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"hasPassword"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}},{"kind":"Field","name":{"kind":"Name","value":"supportUserHash"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingStatus"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"deletedWorkspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentUserWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissionFlags"}},{"kind":"Field","name":{"kind":"Name","value":"objectsPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ObjectPermissionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"strategy"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"isPublicInviteLinkEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDiscoverability"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}},{"kind":"Field","name":{"kind":"Name","value":"customDomain"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidSignedEnterpriseKey"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidEnterpriseValidityToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceCustomApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"installedApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCustomDomainEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingCustomer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingEntitlements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembersCount"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"fastModel"}},{"kind":"Field","name":{"kind":"Name","value":"smartModel"}},{"kind":"Field","name":{"kind":"Name","value":"aiAdditionalInstructions"}},{"kind":"Field","name":{"kind":"Name","value":"enabledAiModelIds"}},{"kind":"Field","name":{"kind":"Name","value":"useRecommendedModels"}},{"kind":"Field","name":{"kind":"Name","value":"isTwoFactorAuthenticationEnforced"}},{"kind":"Field","name":{"kind":"Name","value":"trashRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"eventLogRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"editableProfileFields"}},{"kind":"Field","name":{"kind":"Name","value":"isInternalMessagesImportEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userVars"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicate"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberSubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentRowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"colorScheme"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"locale"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"dateFormat"}},{"kind":"Field","name":{"kind":"Name","value":"timeFormat"}},{"kind":"Field","name":{"kind":"Name","value":"calendarStartDay"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DeletedWorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ObjectPermissionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectPermission"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"canReadObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"restrictedFields"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}}]} as unknown as DocumentNode; +export const UserQueryFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"hasPassword"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}},{"kind":"Field","name":{"kind":"Name","value":"supportUserHash"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingStatus"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"deletedWorkspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentUserWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissionFlags"}},{"kind":"Field","name":{"kind":"Name","value":"isImpersonating"}},{"kind":"Field","name":{"kind":"Name","value":"objectsPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ObjectPermissionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"strategy"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"isPublicInviteLinkEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDiscoverability"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}},{"kind":"Field","name":{"kind":"Name","value":"customDomain"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidSignedEnterpriseKey"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidEnterpriseValidityToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceCustomApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"installedApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCustomDomainEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingCustomer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingEntitlements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembersCount"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"fastModel"}},{"kind":"Field","name":{"kind":"Name","value":"smartModel"}},{"kind":"Field","name":{"kind":"Name","value":"aiAdditionalInstructions"}},{"kind":"Field","name":{"kind":"Name","value":"enabledAiModelIds"}},{"kind":"Field","name":{"kind":"Name","value":"useRecommendedModels"}},{"kind":"Field","name":{"kind":"Name","value":"isTwoFactorAuthenticationEnforced"}},{"kind":"Field","name":{"kind":"Name","value":"trashRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"eventLogRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"editableProfileFields"}},{"kind":"Field","name":{"kind":"Name","value":"isInternalMessagesImportEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userVars"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicate"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberSubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentRowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"colorScheme"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"locale"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"dateFormat"}},{"kind":"Field","name":{"kind":"Name","value":"timeFormat"}},{"kind":"Field","name":{"kind":"Name","value":"calendarStartDay"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DeletedWorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ObjectPermissionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectPermission"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"canReadObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"restrictedFields"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}}]} as unknown as DocumentNode; export const ViewFieldFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const ViewFieldGroupFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFieldGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const ViewFilterFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFilterFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFilter"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"viewFilterGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInViewFilterGroup"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"relationTargetFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; @@ -9248,9 +9313,11 @@ export const RenewTokenDocument = {"kind":"Document","definitions":[{"kind":"Ope export const ResendEmailVerificationTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendEmailVerificationToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resendEmailVerificationToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const DeleteTwoFactorAuthenticationMethodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteTwoFactorAuthenticationMethod"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTwoFactorAuthenticationMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const SignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; +export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"refreshToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signOut"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"refreshToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"refreshToken"}}}]}]}}]} as unknown as DocumentNode; export const SignUpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignUp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signUp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; export const SignUpInNewWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignUpInNewWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SignUpInNewWorkspaceInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signUpInNewWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}}]} as unknown as DocumentNode; export const SignUpInWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignUpInWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceInviteHash"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspacePersonalInviteToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signUpInWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceInviteHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceInviteHash"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspacePersonalInviteToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspacePersonalInviteToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]} as unknown as DocumentNode; +export const StopImpersonationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopImpersonation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopImpersonation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canRestoreImpersonatorSession"}}]}}]}}]} as unknown as DocumentNode; export const UpdatePasswordViaResetTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdatePasswordViaResetToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatePasswordViaResetToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"passwordResetToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"newPassword"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const UploadNewWorkspaceLogoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadNewWorkspaceLogo"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadNewWorkspaceLogo"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const VerifyEmailAndGetLoginTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailAndGetLoginToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailAndGetLoginToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"emailVerificationToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}}]} as unknown as DocumentNode; @@ -9396,6 +9463,9 @@ export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"Oper export const EventLogsLiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"EventLogsLive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"table"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogTable"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogsLive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"table"},"value":{"kind":"Variable","name":{"kind":"Name","value":"table"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}}]}}]} as unknown as DocumentNode; export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode; export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const RevokeAllOtherUserSessionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeAllOtherUserSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"revokeAllOtherUserSessions"}}]}}]} as unknown as DocumentNode; +export const RevokeUserSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeUserSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"revokeUserSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userSessionId"}}}]}]}}]} as unknown as DocumentNode; +export const CurrentUserSessionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CurrentUserSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUserSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"authProvider"}},{"kind":"Field","name":{"kind":"Name","value":"isImpersonating"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"isCurrent"}}]}}]}}]} as unknown as DocumentNode; export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode; export const UpdateWorkspaceMemberSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceMemberSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateOneRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"createRoleInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]} as unknown as DocumentNode; @@ -9424,7 +9494,7 @@ export const MessageSuppressionsDocument = {"kind":"Document","definitions":[{"k export const GetUsageAnalyticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUsageAnalytics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UsageAnalyticsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUsageAnalytics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"usageByUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"usageByOperationType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"usageByModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeSeries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"periodStart"}},{"kind":"Field","name":{"kind":"Name","value":"periodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"userDailyUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"dailyUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteUserAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUserAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const DeleteUserWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUserWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceMemberIdToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserFromWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceMemberIdToDelete"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceMemberIdToDelete"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const GetCurrentUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCurrentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserQueryFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"colorScheme"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"locale"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"dateFormat"}},{"kind":"Field","name":{"kind":"Name","value":"timeFormat"}},{"kind":"Field","name":{"kind":"Name","value":"calendarStartDay"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DeletedWorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicate"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberSubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentRowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ObjectPermissionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectPermission"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"canReadObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"restrictedFields"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"hasPassword"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}},{"kind":"Field","name":{"kind":"Name","value":"supportUserHash"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingStatus"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"deletedWorkspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentUserWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissionFlags"}},{"kind":"Field","name":{"kind":"Name","value":"objectsPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ObjectPermissionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"strategy"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"isPublicInviteLinkEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDiscoverability"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}},{"kind":"Field","name":{"kind":"Name","value":"customDomain"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidSignedEnterpriseKey"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidEnterpriseValidityToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceCustomApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"installedApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCustomDomainEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingCustomer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingEntitlements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembersCount"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"fastModel"}},{"kind":"Field","name":{"kind":"Name","value":"smartModel"}},{"kind":"Field","name":{"kind":"Name","value":"aiAdditionalInstructions"}},{"kind":"Field","name":{"kind":"Name","value":"enabledAiModelIds"}},{"kind":"Field","name":{"kind":"Name","value":"useRecommendedModels"}},{"kind":"Field","name":{"kind":"Name","value":"isTwoFactorAuthenticationEnforced"}},{"kind":"Field","name":{"kind":"Name","value":"trashRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"eventLogRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"editableProfileFields"}},{"kind":"Field","name":{"kind":"Name","value":"isInternalMessagesImportEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userVars"}}]}}]} as unknown as DocumentNode; +export const GetCurrentUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCurrentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserQueryFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"colorScheme"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"locale"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"dateFormat"}},{"kind":"Field","name":{"kind":"Name","value":"timeFormat"}},{"kind":"Field","name":{"kind":"Name","value":"calendarStartDay"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DeletedWorkspaceMember"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicate"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberSubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentRowLevelPermissionPredicateGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInRowLevelPermissionPredicateGroup"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ObjectPermissionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectPermission"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"canReadObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"restrictedFields"}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rowLevelPermissionPredicateGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RowLevelPermissionPredicateGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserQueryFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"hasPassword"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}},{"kind":"Field","name":{"kind":"Name","value":"supportUserHash"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingStatus"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"deletedWorkspaceMembers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DeletedWorkspaceMemberQueryFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentUserWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissionFlags"}},{"kind":"Field","name":{"kind":"Name","value":"isImpersonating"}},{"kind":"Field","name":{"kind":"Name","value":"objectsPermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ObjectPermissionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodSummary"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"twoFactorAuthenticationMethodId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"strategy"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"isPublicInviteLinkEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDiscoverability"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isGoogleAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isMicrosoftAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isPasswordAuthBypassEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}},{"kind":"Field","name":{"kind":"Name","value":"customDomain"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidSignedEnterpriseKey"}},{"kind":"Field","name":{"kind":"Name","value":"hasValidEnterpriseValidityToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceCustomApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"installedApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isCustomDomainEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingCustomer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingEntitlements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMembersCount"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"fastModel"}},{"kind":"Field","name":{"kind":"Name","value":"smartModel"}},{"kind":"Field","name":{"kind":"Name","value":"aiAdditionalInstructions"}},{"kind":"Field","name":{"kind":"Name","value":"enabledAiModelIds"}},{"kind":"Field","name":{"kind":"Name","value":"useRecommendedModels"}},{"kind":"Field","name":{"kind":"Name","value":"isTwoFactorAuthenticationEnforced"}},{"kind":"Field","name":{"kind":"Name","value":"trashRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"eventLogRetentionDays"}},{"kind":"Field","name":{"kind":"Name","value":"editableProfileFields"}},{"kind":"Field","name":{"kind":"Name","value":"isInternalMessagesImportEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userVars"}}]}}]} as unknown as DocumentNode; export const CreateManyViewFieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateManyViewFields"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateViewFieldInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createManyViewFields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const CreateManyViewGroupsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateManyViewGroups"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateViewGroupInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createManyViewGroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"fieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const CreateViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFieldGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFilterFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFilter"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"viewFilterGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInViewFilterGroup"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"relationTargetFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFilterGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFilterGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentViewFilterGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInViewFilterGroup"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewSortFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewSort"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"fieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"View"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isCompact"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"kanbanAggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"kanbanAggregateOperationFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"mainGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shouldHideEmptyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"kanbanColumnWidth"}},{"kind":"Field","name":{"kind":"Name","value":"anyFieldFilterValue"}},{"kind":"Field","name":{"kind":"Name","value":"calendarFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"calendarEndFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"calendarLayout"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldGroupFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFilters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFilterFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFilterGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFilterGroupFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewSorts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewSortFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewGroupFragment"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 1b3468bf23..6d94da2605 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo'; @@ -40,9 +40,9 @@ const setupMockIsMatchingLocation = (pathname: string) => { ); }; -jest.mock('@/auth/hooks/useHasAccessTokenPair'); -const setupMockHasAccessTokenPair = (hasAccessTokenPair: boolean) => { - jest.mocked(useHasAccessTokenPair).mockReturnValueOnce(hasAccessTokenPair); +jest.mock('@/auth/hooks/useIsLogged'); +const setupMockIsLogged = (isLogged: boolean) => { + jest.mocked(useIsLogged).mockReturnValueOnce(isLogged); }; const defaultHomePagePath = '/objects/companies'; @@ -104,7 +104,7 @@ const setupMockState = ( // prettier-ignore const testCases: { loc: AppPath; - hasAccessTokenPair: boolean; + isLogged: boolean; isWorkspaceSuspended: boolean; onboardingStatus: OnboardingStatus | undefined; res: string | undefined; @@ -120,285 +120,285 @@ const testCases: { shouldOpenAiChatAfterOnboarding?: boolean; isOnboardingCheckoutPending?: boolean; }[] = [ - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.WorkspaceSetup, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.Verify, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.Verify, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.SignInUp, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.SignInUp, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.Invite, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' }, - { loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.Invite, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' }, + { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' }, - { loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.ResetPassword, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' }, + { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, verifyEmailRedirectPath: '/nextPath?key=value', res: '/nextPath?key=value' }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailRedirectPath: '/nextPath?key=value', res: undefined }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, verifyEmailRedirectPath: '/nextPath?key=value', res: '/nextPath?key=value' }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.VerifyEmail, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailRedirectPath: '/nextPath?key=value', res: undefined }, + { loc: AppPath.VerifyEmail, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.WorkspaceActivation, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: undefined }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.CreateProfile, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: undefined }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.SyncEmails, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.InstallApps, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: undefined }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.InstallApps, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.InstallApps, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: undefined }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.PlanRequired }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.InviteTeam, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.PlanRequired }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.BookCall, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.BookCall, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.PlanRequired, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.PlanRequiredSuccess, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.Index, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.Index, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.TasksPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.TasksPage, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.OpportunitiesPage, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' }, - { loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object', isMinimalMetadataReady: false }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.RecordIndexPage, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' }, + { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object', isMinimalMetadataReady: false }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.RecordShowPage, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-id', useQueryResult: { loading: true } }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'non-existent-id', useQueryResult: { data: { getPageLayout: null }, loading: false } }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'wrong-type-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.RECORD_PAGE } }, loading: false } }, - { loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-standalone-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.STANDALONE_PAGE } }, loading: false } }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.PageLayoutPage, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-id', useQueryResult: { loading: true } }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'non-existent-id', useQueryResult: { data: { getPageLayout: null }, loading: false } }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'wrong-type-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.RECORD_PAGE } }, loading: false } }, + { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-standalone-id', useQueryResult: { data: { getPageLayout: { type: PageLayoutType.STANDALONE_PAGE } }, loading: false } }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.SettingsCatchAll, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.DevelopersCatchAll, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.Authorize, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.Authorize, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.NotFoundWildcard, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, - { loc: AppPath.NotFound, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, - { loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.NotFound, isLogged: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, // isBillingEnabled:false — no post-invite-team upgrade interception on billing-disabled instances - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, res: defaultHomePagePath }, - { loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, res: defaultHomePagePath }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, res: defaultHomePagePath }, + { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, res: defaultHomePagePath }, // returnToPath: should redirect to saved path instead of defaultHomePagePath - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/authorize?clientId=abc', res: '/authorize?clientId=abc' }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' }, - { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/authorize?clientId=abc', res: '/authorize?clientId=abc' }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' }, + { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, - { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: false, res: defaultHomePagePath }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, returnToPath: '/objects/tasks', res: '/objects/tasks' }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, + { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: false, res: defaultHomePagePath }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, returnToPath: '/objects/tasks', res: '/objects/tasks' }, - { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: undefined }, - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: defaultHomePagePath }, + { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: undefined }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: defaultHomePagePath }, // isOnAWorkspace:false — on default domain, don't redirect to returnToPath or defaultHomePagePath from auth pages - { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, - { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, + { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, + { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, ]; describe('usePageChangeEffectNavigateLocation', () => { @@ -408,7 +408,7 @@ describe('usePageChangeEffectNavigateLocation', () => { loc, onboardingStatus, isWorkspaceSuspended, - hasAccessTokenPair, + isLogged, isOnAWorkspace, objectNamePluralFromParams, objectNamePluralFromMetadata, @@ -425,7 +425,7 @@ describe('usePageChangeEffectNavigateLocation', () => { setupMockIsMatchingLocation(loc); setupMockOnboardingStatus(onboardingStatus); setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended); - setupMockHasAccessTokenPair(hasAccessTokenPair); + setupMockIsLogged(isLogged); setupMockIsOnAWorkspace(isOnAWorkspace ?? true); setupMockUseQuery(useQueryResult); setupMockUseParams(objectNamePluralFromParams, pageLayoutId); @@ -488,7 +488,7 @@ describe('usePageChangeEffectNavigateLocation — authenticated with no current setupMockIsMatchingLocation(loc); setupMockOnboardingStatus(undefined); setupMockIsWorkspaceActivationStatusEqualsTo(false); - setupMockHasAccessTokenPair(true); + setupMockIsLogged(true); setupMockIsOnAWorkspace(true); setupMockUseQuery(); setupMockUseParams(); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 6a54062aed..3112fc0bff 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -1,7 +1,7 @@ import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState'; import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths'; import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { returnToPathState } from '@/auth/states/returnToPathState'; import { billingState } from '@/client-config/states/billingState'; @@ -35,7 +35,7 @@ const readReturnToPathFromUrlSearchParams = (): string | null => { }; export const usePageChangeEffectNavigateLocation = () => { - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); const onboardingStatus = useOnboardingStatus(); @@ -93,7 +93,7 @@ export const usePageChangeEffectNavigateLocation = () => { ); if ( - (!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) && + (!isLogged || !isOnAWorkspace || !isDefined(currentWorkspace)) && !someMatchingLocationOf([ ...ONGOING_USER_CREATION_PATHS, AppPath.ResetPassword, @@ -180,7 +180,7 @@ export const usePageChangeEffectNavigateLocation = () => { ...ONGOING_USER_CREATION_PATHS, ]) && !isMatchingLocation(location, AppPath.ResetPassword) && - hasAccessTokenPair && + isLogged && isOnAWorkspace ) { if ( @@ -193,7 +193,7 @@ export const usePageChangeEffectNavigateLocation = () => { return resolvedReturnToPath ?? onboardingCompletedPath; } - if (isMatchingLocation(location, AppPath.Index) && hasAccessTokenPair) { + if (isMatchingLocation(location, AppPath.Index) && isLogged) { return resolvedReturnToPath ?? defaultHomePagePath; } diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index d0e10f69a5..ce4925a0d6 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory'; import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; import { currentUserState } from '@/auth/states/currentUserState'; +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -28,6 +29,7 @@ export const useApolloFactory = (options: Partial = {}) => { const navigate = useNavigate(); const setTokenPair = useSetAtomState(tokenPairState); + const setIsCookieAuthActive = useSetAtomState(isCookieAuthActiveState); const [currentWorkspace, setCurrentWorkspace] = useAtomState( currentWorkspaceState, ); @@ -71,6 +73,7 @@ export const useApolloFactory = (options: Partial = {}) => { }, onUnauthenticatedError: () => { setTokenPair(null); + setIsCookieAuthActive(false); setCurrentUser(null); setCurrentWorkspaceMember(null); setCurrentWorkspace(null); diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index bce77eb18e..b81d6398f4 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -21,6 +21,7 @@ import { retryWithBackoff } from '~/utils/retryWithBackoff'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { type ApolloManager } from '@/apollo/types/apolloManager.interface'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; +import { isUnauthenticatedGraphQLError } from '@/apollo/utils/isUnauthenticatedGraphQLError'; import { loggerLink } from '@/apollo/utils/loggerLink'; import { StreamingRestLink } from '@/apollo/utils/streamingRestLink'; import { i18n } from '@lingui/core'; @@ -114,14 +115,17 @@ export class ApolloFactory implements ApolloManager { const buildApolloLink = (): ApolloLink => { const uploadLink = new UploadHttpLink({ uri, + credentials: 'include', }); const streamingRestLink = new StreamingRestLink({ uri: REST_API_BASE_URL, + credentials: 'include', }); const restLink = new RestLink({ uri: REST_API_BASE_URL, + credentials: 'include', }); const authLink = setContext(async (_, { headers, skipAuthToken }) => { @@ -195,6 +199,12 @@ export class ApolloFactory implements ApolloManager { forward: ApolloLink.ForwardFunction, error: ErrorLike, ) => { + // Renewing and replaying a deliberately headerless operation (the cookie + // session probe) could loop, so it must fail as-is. + if (operation.getContext().skipAuthToken === true) { + return throwError(() => error); + } + if (!getTokenPair()?.refreshToken?.token) { onUnauthenticatedError?.(); @@ -296,9 +306,9 @@ export class ApolloFactory implements ApolloManager { if (CombinedGraphQLErrors.is(error)) { onErrorCb?.(error.errors); for (const graphQLError of error.errors) { - if (graphQLError.message === 'Unauthorized') { + if (isUnauthenticatedGraphQLError(graphQLError)) { // oxlint-disable-next-line no-console - console.log('Unauthorized, triggering token renewal'); + console.log('Unauthenticated, triggering token renewal'); return handleTokenRenewal(operation, forward, error); } @@ -310,11 +320,6 @@ export class ApolloFactory implements ApolloManager { ); return; } - case 'UNAUTHENTICATED': { - // oxlint-disable-next-line no-console - console.log('UNAUTHENTICATED, triggering token renewal'); - return handleTokenRenewal(operation, forward, error); - } case 'NOT_FOUND': case 'BAD_USER_INPUT': case 'FORBIDDEN': diff --git a/packages/twenty-front/src/modules/apollo/utils/__tests__/streamingRestLink.test.ts b/packages/twenty-front/src/modules/apollo/utils/__tests__/streamingRestLink.test.ts index 63f9621d60..f5dccc7d98 100644 --- a/packages/twenty-front/src/modules/apollo/utils/__tests__/streamingRestLink.test.ts +++ b/packages/twenty-front/src/modules/apollo/utils/__tests__/streamingRestLink.test.ts @@ -86,6 +86,93 @@ describe('StreamingRestLink', () => { ); }); + it('should send credentials when the link is configured with them', () => { + const credentialedLink = new StreamingRestLink({ + uri: 'https://api.example.com', + credentials: 'include', + }); + + const operation = { + query: gql` + query StreamTest($threadId: String!) { + streamChatResponse(threadId: $threadId) + @stream( + path: "/agent-chat/stream/{args.threadId}" + method: "POST" + bodyKey: "requestBody" + ) + } + `, + variables: { threadId: '123', requestBody: { threadId: '123' } }, + getContext: () => ({ onChunk: jest.fn() }), + operationName: 'StreamTest', + extensions: {}, + setContext: jest.fn(), + } as unknown as Operation; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ done: true }), + releaseLock: jest.fn(), + }), + }, + }); + + credentialedLink.request(operation, mockForward).subscribe({ + next: jest.fn(), + error: jest.fn(), + complete: jest.fn(), + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ credentials: 'include' }), + ); + }); + + it('should omit credentials when the link is not configured with them', () => { + const operation = { + query: gql` + query StreamTest($threadId: String!) { + streamChatResponse(threadId: $threadId) + @stream( + path: "/agent-chat/stream/{args.threadId}" + method: "POST" + bodyKey: "requestBody" + ) + } + `, + variables: { threadId: '123', requestBody: { threadId: '123' } }, + getContext: () => ({ onChunk: jest.fn() }), + operationName: 'StreamTest', + extensions: {}, + setContext: jest.fn(), + } as unknown as Operation; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ done: true }), + releaseLock: jest.fn(), + }), + }, + }); + + streamingLink.request(operation, mockForward).subscribe({ + next: jest.fn(), + error: jest.fn(), + complete: jest.fn(), + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ credentials: expect.anything() }), + ); + }); + it('should handle network errors', async () => { const operation = { query: gql` diff --git a/packages/twenty-front/src/modules/apollo/utils/isUnauthenticatedGraphQLError.ts b/packages/twenty-front/src/modules/apollo/utils/isUnauthenticatedGraphQLError.ts new file mode 100644 index 0000000000..27fdfae840 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/utils/isUnauthenticatedGraphQLError.ts @@ -0,0 +1,9 @@ +import { type GraphQLFormattedError } from 'graphql'; + +// Guards that throw before the UNAUTHENTICATED code is attached reach the +// client as a bare "Unauthorized" message. +export const isUnauthenticatedGraphQLError = ( + graphQLError: GraphQLFormattedError, +): boolean => + graphQLError.extensions?.code === 'UNAUTHENTICATED' || + graphQLError.message === 'Unauthorized'; diff --git a/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts b/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts index c3b40e7c92..80ee30fddd 100644 --- a/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts +++ b/packages/twenty-front/src/modules/apollo/utils/streamingRestLink.ts @@ -25,11 +25,13 @@ type StreamDirective = { export class StreamingRestLink extends ApolloLink { private readonly baseUri: string; private readonly defaultHeaders: Record; + private readonly credentials?: RequestCredentials; constructor(options: StreamingRestLinkOptions) { super(); this.baseUri = options.uri; this.defaultHeaders = options.headers || {}; + this.credentials = options.credentials; } public request( @@ -218,6 +220,7 @@ export class StreamingRestLink extends ApolloLink { }, body, signal, + ...(isDefined(this.credentials) ? { credentials: this.credentials } : {}), }; } } diff --git a/packages/twenty-front/src/modules/app/components/SharedAppProviders.tsx b/packages/twenty-front/src/modules/app/components/SharedAppProviders.tsx index e1b82528b7..0367e44de5 100644 --- a/packages/twenty-front/src/modules/app/components/SharedAppProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/SharedAppProviders.tsx @@ -1,6 +1,7 @@ import { type PropsWithChildren } from 'react'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; +import { CookieSessionBootEffect } from '@/auth/effect-components/CookieSessionBootEffect'; import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider'; import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect'; import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider'; @@ -12,6 +13,7 @@ export const SharedAppProviders = ({ children }: SharedAppProvidersProps) => { + {children} diff --git a/packages/twenty-front/src/modules/app/components/__tests__/DomainShell.test.tsx b/packages/twenty-front/src/modules/app/components/__tests__/DomainShell.test.tsx index edc352c027..5475fe06ca 100644 --- a/packages/twenty-front/src/modules/app/components/__tests__/DomainShell.test.tsx +++ b/packages/twenty-front/src/modules/app/components/__tests__/DomainShell.test.tsx @@ -23,6 +23,10 @@ jest.mock('@/client-config/components/ClientConfigProviderEffect', () => ({ ClientConfigProviderEffect: () => null, })); +jest.mock('@/auth/effect-components/CookieSessionBootEffect', () => ({ + CookieSessionBootEffect: () => null, +})); + jest.mock('@/client-config/components/ClientConfigProvider', () => ({ ClientConfigProvider: ({ children }: React.PropsWithChildren) => ( <>{children} diff --git a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx index fe88ad808c..0e331a3abb 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin'; import { AppPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -11,7 +11,7 @@ export const VerifyLoginTokenEffect = () => { const [searchParams] = useSearchParams(); const loginToken = searchParams.get('loginToken'); - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const navigate = useNavigateApp(); const { verifyLoginToken } = useVerifyLogin(); @@ -27,7 +27,7 @@ export const VerifyLoginTokenEffect = () => { if (isDefined(loginToken)) { verifyLoginToken(loginToken); - } else if (!hasAccessTokenPair) { + } else if (!isLogged) { navigate(AppPath.SignInUp); } // oxlint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/twenty-front/src/modules/auth/effect-components/CookieSessionBootEffect.tsx b/packages/twenty-front/src/modules/auth/effect-components/CookieSessionBootEffect.tsx new file mode 100644 index 0000000000..77d9bee27e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/effect-components/CookieSessionBootEffect.tsx @@ -0,0 +1,154 @@ +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useApolloClient } from '@apollo/client/react'; +import { useStore } from 'jotai'; +import { useEffect, useRef } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; +import { isPendingServerSignOutState } from '@/auth/states/isPendingServerSignOutState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { ensureTokenRenewed } from '@/auth/utils/ensureTokenRenewed'; +import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; +import { isCookieSessionEnabledState } from '@/client-config/states/isCookieSessionEnabledState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { + GetCurrentUserDocument, + SignOutDocument, +} from '~/generated-metadata/graphql'; + +type CookieSessionProbeResult = + | 'authenticated' + | 'unauthenticated' + | 'unreachable'; + +// A request with no credential at all is refused as FORBIDDEN, while an +// invalid one is UNAUTHENTICATED, and guards that throw before the code is +// attached surface a bare "Unauthorized". The probe deliberately sends no +// credential, so it has to recognise all three or the migration never starts. +const AUTH_REFUSAL_CODES = new Set(['UNAUTHENTICATED', 'FORBIDDEN']); + +const isAuthRefusal = (error: unknown): boolean => + CombinedGraphQLErrors.is(error) && + error.errors.some( + (graphQLError) => + AUTH_REFUSAL_CODES.has(String(graphQLError.extensions?.code)) || + graphQLError.message === 'Unauthorized', + ); + +// Migrates the client from the localStorage token pair onto the httpOnly +// session cookie. Clients without a cookie get one through a single token +// renewal, which the server sets on renewToken, then switch on the next probe. +export const CookieSessionBootEffect = () => { + const apolloClient = useApolloClient(); + const store = useStore(); + const { isLoadedOnce } = useAtomStateValue(clientConfigApiStatusState); + const isCookieSessionEnabled = useAtomStateValue(isCookieSessionEnabledState); + const [isCookieAuthActive, setIsCookieAuthActive] = useAtomState( + isCookieAuthActiveState, + ); + const [tokenPair, setTokenPair] = useAtomState(tokenPairState); + // oxlint-disable-next-line twenty/no-state-useref + const hasProbeRunRef = useRef(false); + + useEffect(() => { + const probeCookieSession = async (): Promise => { + try { + const result = await apolloClient.query({ + query: GetCurrentUserDocument, + fetchPolicy: 'network-only', + context: { skipAuthToken: true }, + }); + + return isDefined(result.data?.currentUser) + ? 'authenticated' + : 'unauthenticated'; + } catch (error) { + return isAuthRefusal(error) ? 'unauthenticated' : 'unreachable'; + } + }; + + const switchToCookieAuth = () => { + setIsCookieAuthActive(true); + setTokenPair(null); + }; + + const attemptCookieSessionBoot = async (): Promise => { + const probeResult = await probeCookieSession(); + + if (probeResult === 'authenticated') { + switchToCookieAuth(); + + return true; + } + + if (probeResult === 'unreachable') { + return false; + } + + if (!isDefined(tokenPair?.refreshToken?.token)) { + return true; + } + + if (!(await ensureTokenRenewed(store))) { + return false; + } + + const probeResultAfterRenewal = await probeCookieSession(); + + if (probeResultAfterRenewal === 'authenticated') { + switchToCookieAuth(); + + return true; + } + + return probeResultAfterRenewal === 'unauthenticated'; + }; + + const runCookieSessionBoot = async () => { + if (!isLoadedOnce) { + return; + } + + if (store.get(isPendingServerSignOutState.atom)) { + try { + await apolloClient.mutate({ mutation: SignOutDocument }); + store.set(isPendingServerSignOutState.atom, false); + } catch {} + + return; + } + + if (!isCookieSessionEnabled) { + if (isCookieAuthActive) { + setIsCookieAuthActive(false); + } + + return; + } + + if (isCookieAuthActive || hasProbeRunRef.current) { + return; + } + + hasProbeRunRef.current = true; + + if (!(await attemptCookieSessionBoot())) { + hasProbeRunRef.current = false; + } + }; + + void runCookieSessionBoot(); + }, [ + apolloClient, + isCookieAuthActive, + isCookieSessionEnabled, + isLoadedOnce, + setIsCookieAuthActive, + setTokenPair, + store, + tokenPair, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/auth/effect-components/__tests__/CookieSessionBootEffect.test.tsx b/packages/twenty-front/src/modules/auth/effect-components/__tests__/CookieSessionBootEffect.test.tsx new file mode 100644 index 0000000000..7252853de0 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/effect-components/__tests__/CookieSessionBootEffect.test.tsx @@ -0,0 +1,143 @@ +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Provider as JotaiProvider, createStore } from 'jotai'; +import { type ReactNode } from 'react'; + +import { CookieSessionBootEffect } from '@/auth/effect-components/CookieSessionBootEffect'; +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; +import { isCookieSessionEnabledState } from '@/client-config/states/isCookieSessionEnabledState'; + +const mockQuery = jest.fn(); +const mockEnsureTokenRenewed = jest.fn(); + +jest.mock('@apollo/client/react', () => ({ + useApolloClient: () => ({ + query: mockQuery, + mutate: jest.fn(), + }), +})); + +jest.mock('@/auth/utils/ensureTokenRenewed', () => ({ + ensureTokenRenewed: (...args: unknown[]) => mockEnsureTokenRenewed(...args), +})); + +const buildTokenPair = () => ({ + accessOrWorkspaceAgnosticToken: { + token: 'access', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + refreshToken: { + token: 'refresh', + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }, +}); + +// A request carrying no credential is refused as FORBIDDEN, not UNAUTHENTICATED. +const buildForbiddenError = () => + new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Forbidden resource', + extensions: { code: 'FORBIDDEN' }, + }, + ], + }); + +const renderBootEffect = () => { + const store = createStore(); + + store.set(clientConfigApiStatusState.atom, { + isLoadedOnce: true, + isLoading: false, + isErrored: false, + isSaved: false, + }); + store.set(isCookieSessionEnabledState.atom, true); + store.set(isCookieAuthActiveState.atom, false); + store.set(tokenPairState.atom, buildTokenPair()); + + renderHook(() => CookieSessionBootEffect(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + return store; +}; + +describe('CookieSessionBootEffect', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnsureTokenRenewed.mockResolvedValue(true); + }); + + it('should renew once so the server can set the cookie when the probe is refused', async () => { + mockQuery + .mockRejectedValueOnce(buildForbiddenError()) + .mockResolvedValueOnce({ data: { currentUser: { id: 'user-id' } } }); + + const store = renderBootEffect(); + + await waitFor(() => { + expect(mockEnsureTokenRenewed).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(store.get(isCookieAuthActiveState.atom)).toBe(true); + }); + + expect(store.get(tokenPairState.atom)).toBeNull(); + }); + + it('should stay retryable when the probe fails for an unrelated reason', async () => { + mockQuery.mockRejectedValue( + new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Something broke in a resolver', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }, + ], + }), + ); + + const store = renderBootEffect(); + + await waitFor(() => { + expect(mockQuery).toHaveBeenCalled(); + }); + + expect(mockEnsureTokenRenewed).not.toHaveBeenCalled(); + expect(store.get(isCookieAuthActiveState.atom)).toBe(false); + expect(store.get(tokenPairState.atom)).not.toBeNull(); + }); + + it('should not renew when the server cannot be reached', async () => { + mockQuery.mockRejectedValue(new Error('Network request failed')); + + const store = renderBootEffect(); + + await waitFor(() => { + expect(mockQuery).toHaveBeenCalled(); + }); + + expect(mockEnsureTokenRenewed).not.toHaveBeenCalled(); + expect(store.get(isCookieAuthActiveState.atom)).toBe(false); + }); + + it('should switch straight over when the cookie already authenticates', async () => { + mockQuery.mockResolvedValue({ data: { currentUser: { id: 'user-id' } } }); + + const store = renderBootEffect(); + + await waitFor(() => { + expect(store.get(isCookieAuthActiveState.atom)).toBe(true); + }); + + expect(mockEnsureTokenRenewed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signOut.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signOut.ts new file mode 100644 index 0000000000..5e84c1e34a --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signOut.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const SIGN_OUT = gql` + mutation SignOut($refreshToken: String) { + signOut(refreshToken: $refreshToken) + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/stopImpersonation.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/stopImpersonation.ts new file mode 100644 index 0000000000..aaa2f6bfcc --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/stopImpersonation.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const STOP_IMPERSONATION = gql` + mutation StopImpersonation { + stopImpersonation { + canRestoreImpersonatorSession + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useHasAccessTokenPair.test.ts b/packages/twenty-front/src/modules/auth/hooks/__tests__/useHasAccessTokenPair.test.ts deleted file mode 100644 index 2a3f88076f..0000000000 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useHasAccessTokenPair.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; -import { tokenPairState } from '@/auth/states/tokenPairState'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; - -const renderHooks = () => { - const { result } = renderHook(() => { - const hasAccessTokenPair = useHasAccessTokenPair(); - const setTokenPair = useSetAtomState(tokenPairState); - - return { - hasAccessTokenPair, - setTokenPair, - }; - }); - return { result }; -}; - -describe('useHasAccessTokenPair', () => { - it('should return correct value', async () => { - const { result } = renderHooks(); - - expect(result.current.hasAccessTokenPair).toBe(false); - - await act(async () => { - result.current.setTokenPair({ - accessOrWorkspaceAgnosticToken: { - expiresAt: '', - token: 'testToken', - }, - refreshToken: { - expiresAt: '', - token: 'testToken', - }, - }); - }); - - expect(result.current.hasAccessTokenPair).toBe(true); - }); -}); diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useIsLogged.test.ts b/packages/twenty-front/src/modules/auth/hooks/__tests__/useIsLogged.test.ts new file mode 100644 index 0000000000..1b97263ab8 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useIsLogged.test.ts @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useIsLogged } from '@/auth/hooks/useIsLogged'; +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; + +const renderHooks = () => { + const { result } = renderHook(() => { + const isLogged = useIsLogged(); + const setTokenPair = useSetAtomState(tokenPairState); + const setIsCookieAuthActive = useSetAtomState(isCookieAuthActiveState); + + return { + isLogged, + setTokenPair, + setIsCookieAuthActive, + }; + }); + + return { result }; +}; + +describe('useIsLogged', () => { + it('should be true when a token pair is present', async () => { + const { result } = renderHooks(); + + expect(result.current.isLogged).toBe(false); + + await act(async () => { + result.current.setTokenPair({ + accessOrWorkspaceAgnosticToken: { + expiresAt: '', + token: 'testToken', + }, + refreshToken: { + expiresAt: '', + token: 'testToken', + }, + }); + }); + + expect(result.current.isLogged).toBe(true); + + await act(async () => { + result.current.setTokenPair(null); + }); + + expect(result.current.isLogged).toBe(false); + }); + + it('should be true when cookie auth is active without a token pair', async () => { + const { result } = renderHooks(); + + expect(result.current.isLogged).toBe(false); + + await act(async () => { + result.current.setIsCookieAuthActive(true); + }); + + expect(result.current.isLogged).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index f7511f15d0..ea014a4f58 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -16,6 +16,7 @@ import { GetLoginTokenFromCredentialsDocument, GetWorkspaceCreationDefaultsDocument, SignInDocument, + SignOutDocument, SignUpInWorkspaceDocument, SignUpDocument, VerifyEmailAndGetLoginTokenDocument, @@ -23,6 +24,8 @@ import { } from '~/generated-metadata/graphql'; import { currentUserState } from '@/auth/states/currentUserState'; +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; +import { isPendingServerSignOutState } from '@/auth/states/isPendingServerSignOutState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -100,6 +103,7 @@ export const useAuth = () => { VerifyEmailAndGetWorkspaceAgnosticTokenDocument, ); const [getAuthTokensFromOtp] = useMutation(GetAuthTokensFromOtpDocument); + const [signOutMutation] = useMutation(SignOutDocument); const workspacePublicData = useAtomStateValue(workspacePublicDataState); @@ -116,6 +120,7 @@ export const useAuth = () => { const clearSession = useCallback(() => { sessionStorage.clear(); store.set(tokenPairState.atom, null); + store.set(isCookieAuthActiveState.atom, false); store.set(currentUserState.atom, null); store.set(currentWorkspaceState.atom, null); store.set(currentWorkspaceMemberState.atom, null); @@ -128,8 +133,9 @@ export const useAuth = () => { const handleSetAuthTokens = useCallback( (tokens: AuthTokenPair) => { setTokenPair(tokens); + store.set(isPendingServerSignOutState.atom, false); }, - [setTokenPair], + [setTokenPair, store], ); const navigateAfterMultiWorkspaceSignInUp = useCallback( @@ -444,10 +450,23 @@ export const useAuth = () => { [handleGetLoginTokenFromCredentials, handleGetAuthTokensFromLoginToken], ); - const handleSignOut = useCallback(() => { + const handleSignOut = useCallback(async () => { + // Before clearSession: it needs the refresh token, and the navigation there + // kills in-flight requests. + store.set(isPendingServerSignOutState.atom, true); + + try { + await signOutMutation({ + variables: { + refreshToken: store.get(tokenPairState.atom)?.refreshToken?.token, + }, + }); + store.set(isPendingServerSignOutState.atom, false); + } catch {} + broadcastSignOutToOtherTabs(); clearSession(); - }, [clearSession]); + }, [clearSession, signOutMutation, store]); const handleCredentialsSignUpInWorkspace = useCallback( async ({ diff --git a/packages/twenty-front/src/modules/auth/hooks/useHasAccessTokenPair.ts b/packages/twenty-front/src/modules/auth/hooks/useHasAccessTokenPair.ts deleted file mode 100644 index 845598a277..0000000000 --- a/packages/twenty-front/src/modules/auth/hooks/useHasAccessTokenPair.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { tokenPairState } from '@/auth/states/tokenPairState'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; - -export const useHasAccessTokenPair = (): boolean => { - const [tokenPair] = useAtomState(tokenPairState); - return !!tokenPair; -}; diff --git a/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts b/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts index 89fb135d94..06689c3054 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts @@ -1,22 +1,26 @@ +import { useMutation } from '@apollo/client/react'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { useAuth } from '@/auth/hooks/useAuth'; +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { clearSessionLocalStorageKeys } from '@/auth/utils/clearSessionLocalStorageKeys'; -import { type AuthTokenPair } from '~/generated-metadata/graphql'; +import { + type AuthTokenPair, + StopImpersonationDocument, +} from '~/generated-metadata/graphql'; const IMPERSONATION_SESSION_KEY = 'impersonation_original_session'; type StoredImpersonationSession = { - tokenPair: AuthTokenPair; + tokenPair?: AuthTokenPair; returnPath: string; }; // Token swaps without a full reload would require enumerating every // user-scoped atom, localStorage entry, and Apollo cache key — brittle and // silently broken every time a new piece of user state is added. Instead, -// set the cookie-backed token pair and let the browser re-bootstrap the app. const reloadWithSession = (returnPath: string) => { window.location.assign(returnPath); }; @@ -24,17 +28,20 @@ const reloadWithSession = (returnPath: string) => { export const useImpersonationSession = () => { const store = useStore(); const { getAuthTokensFromLoginToken, signOut } = useAuth(); + const [stopImpersonationMutation] = useMutation(StopImpersonationDocument); const startImpersonating = useCallback( async (loginToken: string, returnPath?: string) => { const currentTokenPair = store.get(tokenPairState.atom); + const isCookieAuthActive = store.get(isCookieAuthActiveState.atom); const targetPath = returnPath ?? window.location.pathname; - if (currentTokenPair) { + if (currentTokenPair || isCookieAuthActive) { const session: StoredImpersonationSession = { - tokenPair: currentTokenPair, + ...(currentTokenPair ? { tokenPair: currentTokenPair } : {}), returnPath: targetPath, }; + sessionStorage.setItem( IMPERSONATION_SESSION_KEY, JSON.stringify(session), @@ -48,6 +55,12 @@ export const useImpersonationSession = () => { throw error; } + if (isCookieAuthActive) { + // Drop the token pair the exchange also returned, so the cookie it set stays + // the only credential. + store.set(tokenPairState.atom, null); + } + clearSessionLocalStorageKeys(); reloadWithSession(targetPath); }, @@ -56,8 +69,39 @@ export const useImpersonationSession = () => { const stopImpersonating = useCallback(async () => { const raw = sessionStorage.getItem(IMPERSONATION_SESSION_KEY); + const isCookieAuthActive = store.get(isCookieAuthActiveState.atom); - if (!raw) { + if (isCookieAuthActive) { + let returnPath = window.location.pathname; + + if (raw !== null) { + sessionStorage.removeItem(IMPERSONATION_SESSION_KEY); + try { + returnPath = (JSON.parse(raw) as StoredImpersonationSession) + .returnPath; + } catch {} + } + + try { + const { data } = await stopImpersonationMutation(); + + if (data?.stopImpersonation.canRestoreImpersonatorSession === true) { + store.set(tokenPairState.atom, null); + clearSessionLocalStorageKeys(); + reloadWithSession(returnPath); + + return; + } + } catch {} + + // Cross-workspace: the admin session on its own origin was never replaced. + window.close(); + await signOut(); + + return; + } + + if (raw === null) { // Cross-workspace tab opened via redirect — no stored admin session // to restore. Close the tab; fall back to sign out if the browser // blocks window.close(). @@ -75,11 +119,17 @@ export const useImpersonationSession = () => { return; } + if (!session.tokenPair) { + sessionStorage.removeItem(IMPERSONATION_SESSION_KEY); + await signOut(); + return; + } + sessionStorage.removeItem(IMPERSONATION_SESSION_KEY); store.set(tokenPairState.atom, session.tokenPair); clearSessionLocalStorageKeys(); reloadWithSession(session.returnPath); - }, [store, signOut]); + }, [store, signOut, stopImpersonationMutation]); const hasStoredSession = useCallback(() => { return sessionStorage.getItem(IMPERSONATION_SESSION_KEY) !== null; diff --git a/packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts b/packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts new file mode 100644 index 0000000000..51a05117eb --- /dev/null +++ b/packages/twenty-front/src/modules/auth/hooks/useIsLogged.ts @@ -0,0 +1,10 @@ +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; + +export const useIsLogged = (): boolean => { + const [tokenPair] = useAtomState(tokenPairState); + const [isCookieAuthActive] = useAtomState(isCookieAuthActiveState); + + return !!tokenPair || isCookieAuthActive; +}; diff --git a/packages/twenty-front/src/modules/auth/services/AuthService.ts b/packages/twenty-front/src/modules/auth/services/AuthService.ts index ff42316a7f..fe423a4ed9 100644 --- a/packages/twenty-front/src/modules/auth/services/AuthService.ts +++ b/packages/twenty-front/src/modules/auth/services/AuthService.ts @@ -22,7 +22,7 @@ const renewTokenMutation = async ( uri: string | undefined, refreshToken: string, ) => { - const httpLink = new HttpLink({ uri }); + const httpLink = new HttpLink({ uri, credentials: 'include' }); const client = new ApolloClient({ link: ApolloLink.from([...(isDebugMode ? [logger] : []), httpLink]), diff --git a/packages/twenty-front/src/modules/auth/services/__tests__/AuthService.test.ts b/packages/twenty-front/src/modules/auth/services/__tests__/AuthService.test.ts index bb2b4ec0b0..adf838c49c 100644 --- a/packages/twenty-front/src/modules/auth/services/__tests__/AuthService.test.ts +++ b/packages/twenty-front/src/modules/auth/services/__tests__/AuthService.test.ts @@ -30,4 +30,23 @@ describe('AuthService', () => { expect(res).toEqual(tokens); }); }); + + it('should send credentials so the renewal response can set the session cookie', async () => { + fetchMock.mockResponse(() => + Promise.resolve({ + body: JSON.stringify({ + data: { renewToken: { tokens } }, + }), + }), + ); + + await act(async () => { + await renewToken('http://localhost:3000', tokens); + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ credentials: 'include' }), + ); + }); }); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx index e1877ff1ff..5347db4e30 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx @@ -1,5 +1,5 @@ import { useAuth } from '@/auth/hooks/useAuth'; -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { SignInUpStep, signInUpStepState, @@ -12,7 +12,7 @@ export const SignInUpGlobalScopeFormEffect = () => { const signInUpStep = useAtomStateValue(signInUpStepState); const { navigateAfterMultiWorkspaceSignInUp } = useAuth(); const { loadCurrentUser } = useLoadCurrentUser(); - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); useEffect(() => { const resumeOnCentralDomain = async () => { @@ -24,13 +24,13 @@ export const SignInUpGlobalScopeFormEffect = () => { }; if (signInUpStep !== SignInUpStep.Init) return; - if (!hasAccessTokenPair) return; + if (!isLogged) return; void resumeOnCentralDomain(); }, [ loadCurrentUser, signInUpStep, - hasAccessTokenPair, + isLogged, navigateAfterMultiWorkspaceSignInUp, ]); diff --git a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts index 0e735c1c4f..941c0cc979 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts @@ -4,7 +4,7 @@ import { type UserWorkspace } from '~/generated-metadata/graphql'; export type CurrentUserWorkspace = Pick< UserWorkspace, - 'permissionFlags' | 'twoFactorAuthenticationMethodSummary' + 'permissionFlags' | 'twoFactorAuthenticationMethodSummary' | 'isImpersonating' > & { objectsPermissions: Array; }; diff --git a/packages/twenty-front/src/modules/auth/states/isCookieAuthActiveState.ts b/packages/twenty-front/src/modules/auth/states/isCookieAuthActiveState.ts new file mode 100644 index 0000000000..91285790a9 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/isCookieAuthActiveState.ts @@ -0,0 +1,8 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isCookieAuthActiveState = createAtomState({ + key: 'isCookieAuthActiveState', + defaultValue: false, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, +}); diff --git a/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts b/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts index 920efece50..683072fb10 100644 --- a/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts +++ b/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts @@ -1,6 +1,7 @@ import { jwtDecode } from 'jwt-decode'; import { isDefined } from 'twenty-shared/utils'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector'; @@ -9,17 +10,18 @@ export const isImpersonatingState = createAtomSelector({ get: ({ get }) => { const tokenPair = get(tokenPairState); - if (!isDefined(tokenPair?.accessOrWorkspaceAgnosticToken?.token)) { - return false; + if (isDefined(tokenPair?.accessOrWorkspaceAgnosticToken?.token)) { + try { + const decodedToken = jwtDecode<{ isImpersonating: boolean }>( + tokenPair.accessOrWorkspaceAgnosticToken.token, + ); + + return decodedToken?.isImpersonating ?? false; + } catch { + return false; + } } - try { - const decodedToken = jwtDecode<{ isImpersonating: boolean }>( - tokenPair.accessOrWorkspaceAgnosticToken.token, - ); - return decodedToken?.isImpersonating ?? false; - } catch { - return false; - } + return get(currentUserWorkspaceState)?.isImpersonating === true; }, }); diff --git a/packages/twenty-front/src/modules/auth/states/isPendingServerSignOutState.ts b/packages/twenty-front/src/modules/auth/states/isPendingServerSignOutState.ts new file mode 100644 index 0000000000..d1406c9cee --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/isPendingServerSignOutState.ts @@ -0,0 +1,11 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +// Only the server can end an httpOnly session, so a signOut that never +// reached it leaves the cookie alive and the next boot must retry the +// revocation instead of probing back into the session. +export const isPendingServerSignOutState = createAtomState({ + key: 'isPendingServerSignOutState', + defaultValue: false, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, +}); diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts index f1b1c14c3a..bf55e537a3 100644 --- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -23,6 +23,7 @@ import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpC import { maintenanceModeState } from '@/client-config/states/maintenanceModeState'; import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState'; import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState'; +import { isCookieSessionEnabledState } from '@/client-config/states/isCookieSessionEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState'; import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; @@ -58,6 +59,9 @@ export const useClientConfig = (): UseClientConfigResult => { const setIsMultiWorkspaceEnabled = useSetAtomState( isMultiWorkspaceEnabledState, ); + const setIsCookieSessionEnabled = useSetAtomState( + isCookieSessionEnabledState, + ); const setIsEmailVerificationRequired = useSetAtomState( isEmailVerificationRequiredState, ); @@ -172,6 +176,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsAnalyticsEnabled(clientConfig.analyticsEnabled); setIsDeveloperDefaultSignInPrefilled(clientConfig.signInPrefilled); setIsMultiWorkspaceEnabled(clientConfig.isMultiWorkspaceEnabled); + setIsCookieSessionEnabled(clientConfig.isCookieSessionEnabled); setIsEmailVerificationRequired(clientConfig.isEmailVerificationRequired); setBilling(clientConfig.billing); setSupportChat(clientConfig.support); @@ -260,6 +265,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsEmailVerificationRequired, setIsImapSmtpCaldavEnabled, setIsMultiWorkspaceEnabled, + setIsCookieSessionEnabled, setIsEmailingDomainInDemoMode, setIsClickHouseConfigured, setIsCloudflareIntegrationEnabled, diff --git a/packages/twenty-front/src/modules/client-config/states/isCookieSessionEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isCookieSessionEnabledState.ts new file mode 100644 index 0000000000..eb0e1576f0 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isCookieSessionEnabledState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isCookieSessionEnabledState = createAtomState({ + key: 'isCookieSessionEnabled', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index c30f5189ca..86f6415819 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -33,6 +33,7 @@ export type ClientConfig = { isMicrosoftCalendarEnabled: boolean; isMicrosoftMessagingEnabled: boolean; isMultiWorkspaceEnabled: boolean; + isCookieSessionEnabled: boolean; isImapSmtpCaldavEnabled: boolean; isEmailingDomainInDemoMode: boolean; isCloudflareIntegrationEnabled: boolean; diff --git a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts index 857f69a4d6..d0630dad11 100644 --- a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts +++ b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts @@ -68,6 +68,7 @@ describe('getClientConfig', () => { headers: { 'Content-Type': 'application/json', }, + credentials: 'include', }, ); expect(result).toEqual(mockClientConfig); diff --git a/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts index 9c7fdaf1cf..32051ebb96 100644 --- a/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/utils/getClientConfig.ts @@ -7,6 +7,7 @@ export const getClientConfig = async (): Promise => { headers: { 'Content-Type': 'application/json', }, + credentials: 'include', }); if (!response.ok) { diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/IsMinimalMetadataReadyEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/IsMinimalMetadataReadyEffect.tsx index 04a0506e34..83e45439a4 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/IsMinimalMetadataReadyEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/IsMinimalMetadataReadyEffect.tsx @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState'; @@ -11,7 +11,7 @@ import { isDefined } from 'twenty-shared/utils'; import { isWorkspaceProvisioned } from 'twenty-shared/workspace'; export const IsMinimalMetadataReadyEffect = () => { - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const metadataStoreObjectMetadataItems = useAtomFamilyStateValue( @@ -35,7 +35,7 @@ export const IsMinimalMetadataReadyEffect = () => { ); useEffect(() => { - if (!hasAccessTokenPair) { + if (!isLogged) { setIsMinimalMetadataReady(true); return; } @@ -61,7 +61,7 @@ export const IsMinimalMetadataReadyEffect = () => { setIsMinimalMetadataReady(true); } }, [ - hasAccessTokenPair, + isLogged, currentUser, currentWorkspace, metadataStoreObjectMetadataItems.status, diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx index 8aa9e265f1..c47303f51b 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useIsOnAuthOrOnboardingPage } from '@/auth/hooks/useIsOnAuthOrOnboardingPage'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -12,7 +12,7 @@ import { isDefined } from 'twenty-shared/utils'; import { isWorkspaceProvisioned } from 'twenty-shared/workspace'; export const MinimalMetadataLoadEffect = () => { - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState); const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); @@ -26,7 +26,7 @@ export const MinimalMetadataLoadEffect = () => { const isProvisionedWorkspace = isWorkspaceProvisioned(currentWorkspace); const shouldLoadRealMetadata = - hasAccessTokenPair && isProvisionedWorkspace && !isOnAuthOrOnboardingPage; + isLogged && isProvisionedWorkspace && !isOnAuthOrOnboardingPage; useEffect(() => { if (!isCurrentUserLoaded && !isDefined(currentUser)) { diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx index 4cf07b7a47..187c04f7ce 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx @@ -1,5 +1,5 @@ +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { toOpenRecordInPreference } from '@/workspace-member/utils/toOpenRecordInPreference'; -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; @@ -27,7 +27,7 @@ import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; export const UserMetadataProviderInitialEffect = () => { - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const store = useStore(); const [isInitialized, setIsInitialized] = useState(false); @@ -64,7 +64,7 @@ export const UserMetadataProviderInitialEffect = () => { [store], ); - const shouldSkipUserQuery = !hasAccessTokenPair; + const shouldSkipUserQuery = !isLogged; const { data: userQueryData, loading: userQueryLoading } = useQuery( GetCurrentUserDocument, @@ -79,7 +79,7 @@ export const UserMetadataProviderInitialEffect = () => { return; } - if (!hasAccessTokenPair) { + if (!isLogged) { setIsCurrentUserLoaded(true); setIsInitialized(true); return; @@ -114,6 +114,9 @@ export const UserMetadataProviderInitialEffect = () => { .objectsPermissions as Array< ObjectPermissions & { objectMetadataId: string } >) ?? [], + isImpersonating: + userQueryData.currentUser.currentUserWorkspace.isImpersonating ?? + false, }); } @@ -166,7 +169,7 @@ export const UserMetadataProviderInitialEffect = () => { setIsInitialized(true); }, [ isInitialized, - hasAccessTokenPair, + isLogged, userQueryLoading, userQueryData?.currentUser, setCurrentUser, diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingStatus.ts index e3a48a6868..c3d4baa483 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingStatus.ts @@ -1,10 +1,10 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { currentUserState } from '@/auth/states/currentUserState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { type OnboardingStatus } from '~/generated-metadata/graphql'; export const useOnboardingStatus = (): OnboardingStatus | null | undefined => { const currentUser = useAtomStateValue(currentUserState); - const hasAccessTokenPair = useHasAccessTokenPair(); - return hasAccessTokenPair ? currentUser?.onboardingStatus : undefined; + const isLogged = useIsLogged(); + return isLogged ? currentUser?.onboardingStatus : undefined; }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsListItemCardContent.tsx b/packages/twenty-front/src/modules/settings/components/SettingsListItemCardContent.tsx index 7b6056ad22..abed866862 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsListItemCardContent.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsListItemCardContent.tsx @@ -17,16 +17,22 @@ const StyledRowContainer = styled.div` height: ${themeCssVariables.spacing[10]}; padding: ${themeCssVariables.spacing[2]}; padding-left: ${themeCssVariables.spacing[3]}; + + > svg { + flex-shrink: 0; + } } `; const StyledRightContainer = styled.div` align-items: center; display: flex; + flex-shrink: 0; gap: ${themeCssVariables.spacing[1]}; `; const StyledContent = styled.div` + align-items: center; display: flex; flex: 1 1 0; gap: ${themeCssVariables.spacing[1]}; @@ -35,16 +41,25 @@ const StyledContent = styled.div` `; const StyledLabel = styled.span` + flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `; +// Rows are a fixed height, so the description has to give way rather than wrap +// out of the row. A zero basis means it only ever takes the space the label +// leaves, so the label stays readable and the description truncates first. const StyledDescription = styled.span` color: ${themeCssVariables.font.color.light}; + flex: 1 1 0; font-weight: ${themeCssVariables.font.weight.regular}; line-height: ${themeCssVariables.text.lineHeight.lg}; + min-width: 0; + overflow: hidden; padding-left: ${themeCssVariables.spacing[1]}; + text-overflow: ellipsis; + white-space: nowrap; `; const StyledLinkContainer = styled.div` diff --git a/packages/twenty-front/src/modules/settings/components/SettingsProtectedRouteWrapper.tsx b/packages/twenty-front/src/modules/settings/components/SettingsProtectedRouteWrapper.tsx index ea2ab0160d..f9e69d414c 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsProtectedRouteWrapper.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsProtectedRouteWrapper.tsx @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { type ReactNode } from 'react'; @@ -21,13 +21,13 @@ export const SettingsProtectedRouteWrapper = ({ settingsPermission, requiredFeatureFlag, }: SettingsProtectedRouteWrapperProps) => { - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const hasPermission = useHasPermissionFlag(settingsPermission); const requiredFeatureFlagEnabled = useIsFeatureEnabled( requiredFeatureFlag || null, ); - if (!hasAccessTokenPair) { + if (!isLogged) { return null; } diff --git a/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsDeviceSessionRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsDeviceSessionRowDropdownMenu.tsx new file mode 100644 index 0000000000..d3ee4c4177 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsDeviceSessionRowDropdownMenu.tsx @@ -0,0 +1,63 @@ +import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; + +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { IconDotsVertical, IconLogout } from 'twenty-ui/icon'; +import { LightIconButton } from 'twenty-ui/input'; +import { MenuItem } from 'twenty-ui/navigation'; +import { RevokeUserSessionDocument } from '~/generated-metadata/graphql'; + +type SettingsDeviceSessionRowDropdownMenuProps = { + userSessionId: string; + onRevoked: () => void; +}; + +export const SettingsDeviceSessionRowDropdownMenu = ({ + userSessionId, + onRevoked, +}: SettingsDeviceSessionRowDropdownMenuProps) => { + const dropdownId = `settings-device-session-row-${userSessionId}`; + + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + const { closeDropdown } = useCloseDropdown(); + + const [revokeUserSession] = useMutation(RevokeUserSessionDocument); + + const handleRevokeSession = async () => { + closeDropdown(dropdownId); + + try { + await revokeUserSession({ variables: { userSessionId } }); + enqueueSuccessSnackBar({ message: t`Device logged out` }); + onRevoked(); + } catch { + enqueueErrorSnackBar({ message: t`Failed to log out this device` }); + } + }; + + return ( + + } + dropdownComponents={ + + + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx new file mode 100644 index 0000000000..360d62d5b4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery } from '@apollo/client/react'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; + +import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { SettingsDeviceSessionRowDropdownMenu } from '@/settings/profile/devices/components/SettingsDeviceSessionRowDropdownMenu'; +import { parseUserAgentDescription } from '@/settings/profile/devices/utils/parseUserAgentDescription'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { isCookieSessionEnabledState } from '@/client-config/states/isCookieSessionEnabledState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { Status } from 'twenty-ui/data-display'; +import { IconDeviceDesktop, IconLogout } from 'twenty-ui/icon'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { H2Title } from 'twenty-ui/typography'; +import { + CurrentUserSessionsDocument, + type CurrentUserSessionsQuery, + RevokeAllOtherUserSessionsDocument, +} from '~/generated-metadata/graphql'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; +import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; + +type UserSessionListItem = + CurrentUserSessionsQuery['currentUserSessions'][number]; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; +`; + +// A flex row so the button keeps its natural width while the card above it +// stretches to the section. +const StyledButtonContainer = styled.div` + display: flex; +`; + +export const SettingsProfileDevicesSection = () => { + const { t } = useLingui(); + const { localeCatalog } = useAtomStateValue(dateLocaleState); + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + + const isCookieSessionEnabled = useAtomStateValue(isCookieSessionEnabledState); + + const { data, loading, error, refetch } = useQuery( + CurrentUserSessionsDocument, + { fetchPolicy: 'network-only', skip: !isCookieSessionEnabled }, + ); + + useSnackBarOnQueryError(error); + + const [revokeAllOtherUserSessions] = useMutation( + RevokeAllOtherUserSessionsDocument, + ); + + const sessions = data?.currentUserSessions ?? []; + // Without it, "log out all other devices" would revoke this browser too. + const hasCurrentSession = sessions.some((session) => session.isCurrent); + const hasOtherSessions = + hasCurrentSession && sessions.some((session) => !session.isCurrent); + + if (!loading && sessions.length === 0) { + return null; + } + + const handleRevokeAllOtherSessions = async () => { + try { + await revokeAllOtherUserSessions(); + enqueueSuccessSnackBar({ message: t`Logged out all other devices` }); + await refetch(); + } catch { + enqueueErrorSnackBar({ message: t`Failed to log out other devices` }); + } + }; + + const getSessionLabel = (session: UserSessionListItem) => { + const { browser, operatingSystem } = parseUserAgentDescription( + session.userAgent, + ); + + if (browser && operatingSystem) { + return t`${browser} on ${operatingSystem}`; + } + + return browser ?? operatingSystem ?? t`Unknown device`; + }; + + const getSessionDescription = (session: UserSessionListItem) => { + const lastActive = beautifyPastDateRelativeToNow( + session.lastActiveAt, + localeCatalog, + ); + + return isNonEmptyString(session.ipAddress) + ? t`Last active ${lastActive} · ${session.ipAddress}` + : t`Last active ${lastActive}`; + }; + + return ( +
+ + + ( + <> + {session.isImpersonating && ( + + )} + {session.isCurrent ? ( + + ) : ( + void refetch()} + /> + )} + + )} + /> + {hasOtherSessions && ( + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeAllOtherUserSessions.ts b/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeAllOtherUserSessions.ts new file mode 100644 index 0000000000..df2a78d631 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeAllOtherUserSessions.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const REVOKE_ALL_OTHER_USER_SESSIONS = gql` + mutation RevokeAllOtherUserSessions { + revokeAllOtherUserSessions + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeUserSession.ts b/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeUserSession.ts new file mode 100644 index 0000000000..726c5f71b9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/graphql/mutations/revokeUserSession.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const REVOKE_USER_SESSION = gql` + mutation RevokeUserSession($userSessionId: UUID!) { + revokeUserSession(userSessionId: $userSessionId) + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/devices/graphql/queries/currentUserSessions.ts b/packages/twenty-front/src/modules/settings/profile/devices/graphql/queries/currentUserSessions.ts new file mode 100644 index 0000000000..bfa7fe3ae4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/graphql/queries/currentUserSessions.ts @@ -0,0 +1,18 @@ +import { gql } from '@apollo/client'; + +export const CURRENT_USER_SESSIONS = gql` + query CurrentUserSessions { + currentUserSessions { + id + workspaceId + authProvider + isImpersonating + userAgent + ipAddress + createdAt + lastActiveAt + expiresAt + isCurrent + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/devices/utils/__tests__/parseUserAgentDescription.test.ts b/packages/twenty-front/src/modules/settings/profile/devices/utils/__tests__/parseUserAgentDescription.test.ts new file mode 100644 index 0000000000..d448f886e5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/utils/__tests__/parseUserAgentDescription.test.ts @@ -0,0 +1,47 @@ +import { parseUserAgentDescription } from '@/settings/profile/devices/utils/parseUserAgentDescription'; + +describe('parseUserAgentDescription', () => { + it('should return nothing for missing user agents', () => { + expect(parseUserAgentDescription(null)).toEqual({}); + expect(parseUserAgentDescription(undefined)).toEqual({}); + expect(parseUserAgentDescription('')).toEqual({}); + }); + + it('should detect Chrome on macOS', () => { + expect( + parseUserAgentDescription( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + ), + ).toEqual({ browser: 'Chrome', operatingSystem: 'macOS' }); + }); + + it('should detect Edge before Chrome', () => { + expect( + parseUserAgentDescription( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', + ), + ).toEqual({ browser: 'Edge', operatingSystem: 'Windows' }); + }); + + it('should detect Safari on iOS', () => { + expect( + parseUserAgentDescription( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + ), + ).toEqual({ browser: 'Safari', operatingSystem: 'iOS' }); + }); + + it('should detect Firefox on Linux', () => { + expect( + parseUserAgentDescription( + 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0', + ), + ).toEqual({ browser: 'Firefox', operatingSystem: 'Linux' }); + }); + + it('should return the browser alone when the platform is unknown', () => { + expect(parseUserAgentDescription('SomeAgent Chrome/1.0')).toEqual({ + browser: 'Chrome', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/profile/devices/utils/parseUserAgentDescription.ts b/packages/twenty-front/src/modules/settings/profile/devices/utils/parseUserAgentDescription.ts new file mode 100644 index 0000000000..8ef7239b76 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/devices/utils/parseUserAgentDescription.ts @@ -0,0 +1,42 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +type UserAgentMatcher = { + label: string; + pattern: RegExp; +}; + +// Order matters: several browsers embed competitor tokens in their user +// agent (Edge and Opera contain "Chrome", Chrome contains "Safari"). +const BROWSER_MATCHERS: UserAgentMatcher[] = [ + { label: 'Edge', pattern: /Edg(e|A|iOS)?\// }, + { label: 'Opera', pattern: /(OPR|Opera)\// }, + { label: 'Samsung Internet', pattern: /SamsungBrowser\// }, + { label: 'Firefox', pattern: /(Firefox|FxiOS)\// }, + { label: 'Chrome', pattern: /(Chrome|CriOS)\// }, + { label: 'Safari', pattern: /Safari\// }, +]; + +const OPERATING_SYSTEM_MATCHERS: UserAgentMatcher[] = [ + { label: 'Android', pattern: /Android/ }, + { label: 'iOS', pattern: /(iPhone|iPad|iPod)/ }, + { label: 'Windows', pattern: /Windows/ }, + { label: 'macOS', pattern: /Mac OS X|Macintosh/ }, + { label: 'Chrome OS', pattern: /CrOS/ }, + { label: 'Linux', pattern: /Linux/ }, +]; + +export const parseUserAgentDescription = ( + userAgent: string | null | undefined, +): { browser?: string; operatingSystem?: string } => { + if (!isNonEmptyString(userAgent)) { + return {}; + } + + return { + browser: BROWSER_MATCHERS.find(({ pattern }) => pattern.test(userAgent)) + ?.label, + operatingSystem: OPERATING_SYSTEM_MATCHERS.find(({ pattern }) => + pattern.test(userAgent), + )?.label, + }; +}; diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEClientEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEClientEffect.tsx index edefa2dbdd..ba311c2438 100644 --- a/packages/twenty-front/src/modules/sse-db-event/components/SSEClientEffect.tsx +++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEClientEffect.tsx @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent'; import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent'; @@ -20,7 +20,7 @@ import { useStore } from 'jotai'; export const SSEClientEffect = () => { const store = useStore(); - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const [sseClient, setSseClient] = useAtomState(sseClientState); const tokenPair = useAtomStateValue(tokenPairState); const { resyncMetadataStore } = useResyncMetadataStore(); @@ -57,9 +57,10 @@ export const SSEClientEffect = () => { useHandleSseClientConnectionRetry(); useEffect(() => { - if (hasAccessTokenPair && !isDefined(sseClient) && isDefined(tokenPair)) { + if (isLogged && !isDefined(sseClient)) { const newSseClient = createClient({ url: `${REACT_APP_SERVER_BASE_URL}/metadata`, + credentials: 'include', headers: () => { const currentTokenPair = store.get(tokenPairState.atom); const token = currentTokenPair?.accessOrWorkspaceAgnosticToken?.token; @@ -80,7 +81,7 @@ export const SSEClientEffect = () => { } }, [ handleSSEClientConnected, - hasAccessTokenPair, + isLogged, setSseClient, sseClient, store, diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx index 8268c8f674..dda08d36b7 100644 --- a/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx +++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEEventStreamEffect.tsx @@ -1,4 +1,4 @@ -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { currentUserState } from '@/auth/states/currentUserState'; import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { useTriggerEventStreamCreation } from '@/sse-db-event/hooks/useTriggerEventStreamCreation'; @@ -27,7 +27,7 @@ export const SSEEventStreamEffect = () => { isDestroyingEventStreamState, ); - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const currentUser = useAtomStateValue(currentUserState); const { triggerEventStreamCreation } = useTriggerEventStreamCreation(); @@ -39,7 +39,7 @@ export const SSEEventStreamEffect = () => { const willCreateEventStream = isSseClientAvailable && - hasAccessTokenPair && + isLogged && isDefined(currentUser) && currentUser.onboardingStatus === OnboardingStatus.COMPLETED && !shouldDestroyEventStream && @@ -59,7 +59,7 @@ export const SSEEventStreamEffect = () => { }, [ isCreatingSseEventStream, triggerEventStreamCreation, - hasAccessTokenPair, + isLogged, currentUser, isDestroyingEventStream, triggerEventStreamDestroy, diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts index a08acf2a92..83c7292636 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts @@ -1,3 +1,4 @@ +import { isCookieAuthActiveState } from '@/auth/states/isCookieAuthActiveState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { ensureTokenRenewed } from '@/auth/utils/ensureTokenRenewed'; import { SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS } from '@/sse-db-event/constants/SseConnectionRetryMaxWaitTimeInMs'; @@ -5,12 +6,23 @@ import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE } from '@/sse-db-even import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsToAvoidRaceConditions'; import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState'; import { sseClientState } from '@/sse-db-event/states/sseClientState'; +import { type Client } from 'graphql-sse'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { getIsDevelopmentEnvironment } from '~/utils/getIsDevelopmentEnvironment'; import { sleep } from '~/utils/sleep'; +const destroyStream = async ( + store: ReturnType, + sseClient: Client, +) => { + await sleep(SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS); + sseClient.dispose(); + store.set(shouldDestroyEventStreamState.atom, true); + store.set(sseClientState.atom, null); +}; + export const useHandleSseClientConnectionRetry = () => { const store = useStore(); @@ -25,33 +37,31 @@ export const useHandleSseClientConnectionRetry = () => { return; } - const tokenPair = store.get(tokenPairState.atom); - const accessToken = tokenPair?.accessOrWorkspaceAgnosticToken; - - if (!isDefined(accessToken) || retryCount > 10) { - await sleep( - SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS, - ); - sseClient.dispose(); - store.set(shouldDestroyEventStreamState.atom, true); - store.set(sseClientState.atom, null); + if (retryCount > 10) { + await destroyStream(store, sseClient); return; } - const isTokenExpired = new Date(accessToken.expiresAt) <= new Date(); + // In cookie mode the session cookie is the credential: there is no token + // pair to find missing and nothing to renew, so an absent one must not + // be read as a lost credential. + if (!store.get(isCookieAuthActiveState.atom)) { + const tokenPair = store.get(tokenPairState.atom); + const accessToken = tokenPair?.accessOrWorkspaceAgnosticToken; - if (isTokenExpired) { - const renewed = await ensureTokenRenewed(store); - - if (!renewed) { - await sleep( - SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS, - ); - sseClient.dispose(); - store.set(shouldDestroyEventStreamState.atom, true); - store.set(sseClientState.atom, null); + if (!isDefined(accessToken)) { + await destroyStream(store, sseClient); return; } + + if (new Date(accessToken.expiresAt) <= new Date()) { + const renewed = await ensureTokenRenewed(store); + + if (!renewed) { + await destroyStream(store, sseClient); + return; + } + } } const randomWaitTimeInMsToSpaceAllClientsReconnection = Math.round( diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 36da1d46d5..79df03d09b 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -35,6 +35,7 @@ export const USER_QUERY_FRAGMENT = gql` currentUserWorkspace { id permissionFlags + isImpersonating objectsPermissions { ...ObjectPermissionFragment } diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index 2007dbe957..2ad434f880 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -82,6 +82,7 @@ export const useLoadCurrentUser = () => { (user.currentUserWorkspace.objectsPermissions as Array< ObjectPermissions & { objectMetadataId: string } >) ?? [], + isImpersonating: user.currentUserWorkspace.isImpersonating ?? false, }); } diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index ea0d6db6be..5d1fe28d84 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -2,7 +2,7 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLo import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { useAuth } from '@/auth/hooks/useAuth'; -import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer'; import { currentUserState } from '@/auth/states/currentUserState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; @@ -97,7 +97,7 @@ export const PasswordReset = () => { const [isTargetUserPasswordSet, setIsTargetUserPasswordSet] = useState(false); const passwordResetToken = useParams().passwordResetToken; - const hasAccessTokenPair = useHasAccessTokenPair(); + const isLogged = useIsLogged(); const { control, handleSubmit } = useForm
({ mode: 'onChange', @@ -178,7 +178,7 @@ export const PasswordReset = () => { currentUser ? { ...currentUser, hasPassword: true } : currentUser, ); - if (hasAccessTokenPair) { + if (isLogged) { enqueueSuccessSnackBar({ message: successMessage, }); diff --git a/packages/twenty-front/src/pages/settings/profile/SettingsProfile.tsx b/packages/twenty-front/src/pages/settings/profile/SettingsProfile.tsx index 0f97adda08..1fc4b97ee8 100644 --- a/packages/twenty-front/src/pages/settings/profile/SettingsProfile.tsx +++ b/packages/twenty-front/src/pages/settings/profile/SettingsProfile.tsx @@ -3,6 +3,7 @@ import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SetOrChangePassword } from '@/settings/profile/components/SetOrChangePassword'; import { DeleteAccount } from '@/settings/profile/components/DeleteAccount'; +import { SettingsProfileDevicesSection } from '@/settings/profile/devices/components/SettingsProfileDevicesSection'; import { EmailField } from '@/settings/profile/components/EmailField'; import { NameFields } from '@/settings/profile/components/NameFields'; import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/components/WorkspaceMemberPictureUploader'; @@ -97,6 +98,7 @@ export const SettingsProfile = () => { )} +
diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index f0046b9a18..fdeb4e6d88 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -5,6 +5,7 @@ export const mockedClientConfig: ClientConfig = { aiModels: [], signInPrefilled: true, isMultiWorkspaceEnabled: false, + isCookieSessionEnabled: false, isEmailVerificationRequired: false, authProviders: { google: true, diff --git a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts index 342b42da48..e9686705e8 100644 --- a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts +++ b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts @@ -15,6 +15,8 @@ const STRUCTURAL_EXEMPTIONS = new Set([ // nullable workspaceId — both rows support instance-level and per-workspace use 'KeyValuePairEntity', 'UpgradeMigrationEntity', + // user-scoped auth sessions; workspaceId is null for workspace-agnostic sessions + 'UserSessionEntity', 'ApplicationVariableEntity', 'BillingMeterEntity', diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 6067dc21cc..4c92df2bc0 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -26,5 +26,6 @@ declare module 'express-serve-static-core' { authProvider?: AuthProviderEnum | null; impersonationContext?: RawAuthContext['impersonationContext']; tokenType?: JwtTokenTypeEnum; + authenticatedAt?: Date; } } diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts index ada929e25b..604332370a 100644 --- a/packages/twenty-server/src/app.module.ts +++ b/packages/twenty-server/src/app.module.ts @@ -26,8 +26,11 @@ import { WorkspaceAuthContextMiddleware } from 'src/engine/core-modules/auth/mid import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { DataloaderModule } from 'src/engine/dataloaders/dataloader.module'; import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module'; +import { CookieSessionCsrfMiddleware } from 'src/engine/middlewares/cookie-session-csrf.middleware'; import { GraphQLHydrateRequestFromTokenMiddleware } from 'src/engine/middlewares/graphql-hydrate-request-from-token.middleware'; import { MiddlewareModule } from 'src/engine/middlewares/middleware.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { RestCoreMiddleware } from 'src/engine/middlewares/rest-core.middleware'; import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @@ -72,6 +75,8 @@ const MIGRATED_REST_METHODS = [ RestApiModule, McpModule, MiddlewareModule, + JwtModule, + UserSessionModule, WorkspaceMetadataVersionModule, // I18n module for translations I18nModule, @@ -120,6 +125,17 @@ export class AppModule { } configure(consumer: MiddlewareConsumer) { + // Before any middleware that authenticates from the session cookie. + consumer + .apply(CookieSessionCsrfMiddleware) + // A cross-origin form post from the identity provider, authenticated on the + // assertion rather than the cookie. + .exclude({ + path: 'auth/saml/callback/:identityProviderId', + method: RequestMethod.POST, + }) + .forRoutes({ path: '*path', method: RequestMethod.ALL }); + consumer .apply( GraphQLHydrateRequestFromTokenMiddleware, diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index ef2d777d0c..9e6f2120b2 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -14,6 +14,7 @@ import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/ import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command'; import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCleanupCronCommand } from 'src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command'; import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command'; import { WebhookSubscriptionRenewalCronCommand } from 'src/modules/connected-account/webhook-subscription-manager/crons/commands/webhook-subscription-renewal.cron.command'; import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; @@ -73,6 +74,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand, private readonly pendingFileCleanupCronCommand: PendingFileCleanupCronCommand, private readonly billingReminderCronCommand: BillingReminderCronCommand, + private readonly userSessionCleanupCronCommand: UserSessionCleanupCronCommand, private readonly twentyConfigService: TwentyConfigService, ) { super(); @@ -207,6 +209,10 @@ export class CronRegisterAllCommand extends CommandRunner { command: this.billingReminderCronCommand, isEnabled: isBillingEnabled, }, + { + name: 'UserSessionCleanup', + command: this.userSessionCleanupCronCommand, + }, ]; let successCount = 0; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 3b57304240..17a7e3368b 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -32,6 +32,7 @@ import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/ import { FileModule } from 'src/engine/core-modules/file/file.module'; import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command'; import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -99,6 +100,7 @@ import { WorkflowCoreConsistencyModule } from 'src/modules/workflow/workflow-cor WorkspaceVersionModule, UpgradeModule, SecretEncryptionRotationModule, + UserSessionModule, ], providers: [ DataSeedWorkspaceCommand, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table.ts new file mode 100644 index 0000000000..bf4c34e821 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table.ts @@ -0,0 +1,63 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.27.0', 1785518325511) +export class CreateUserSessionCoreTableFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "core"."userSession" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "tokenHash" text NOT NULL, + "userId" uuid NOT NULL, + "workspaceId" uuid, + "userWorkspaceId" uuid, + "authProvider" text NOT NULL, + "isImpersonating" boolean NOT NULL DEFAULT false, + "impersonatorUserWorkspaceId" uuid, + "impersonatedUserWorkspaceId" uuid, + "userAgent" text, + "ipAddress" text, + "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "lastActiveAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "revokedAt" TIMESTAMP WITH TIME ZONE, + "revokedReason" text, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_userSession_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_USER_SESSION_USER_ID" FOREIGN KEY ("userId") + REFERENCES "core"."user"("id") ON DELETE CASCADE, + CONSTRAINT "FK_USER_SESSION_WORKSPACE_ID" FOREIGN KEY ("workspaceId") + REFERENCES "core"."workspace"("id") ON DELETE CASCADE, + CONSTRAINT "FK_USER_SESSION_USER_WORKSPACE_ID" FOREIGN KEY ("userWorkspaceId") + REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE + )`, + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_USER_SESSION_TOKEN_HASH_UNIQUE" ON "core"."userSession" ("tokenHash")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_USER_SESSION_USER_ID" ON "core"."userSession" ("userId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_USER_SESSION_WORKSPACE_ID" ON "core"."userSession" ("workspaceId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_USER_SESSION_USER_WORKSPACE_ID" ON "core"."userSession" ("userWorkspaceId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_USER_SESSION_EXPIRES_AT" ON "core"."userSession" ("expiresAt")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_USER_SESSION_REVOKED_AT" ON "core"."userSession" ("revokedAt")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "core"."userSession"`); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index a22b72794b..bbfcd67962 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -132,6 +132,7 @@ import { AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand } fro import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message'; import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index'; import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata'; +import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -266,4 +267,5 @@ export const INSTANCE_COMMANDS = [ AddIsHiddenToAgentMessageFastInstanceCommand, AddConnectedAccountHandleProviderIndexFastInstanceCommand, AddOpenRecordInToObjectMetadataFastInstanceCommand, + CreateUserSessionCoreTableFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/stop-impersonation.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/stop-impersonation.dto.ts new file mode 100644 index 0000000000..023a26cf72 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/stop-impersonation.dto.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('StopImpersonation') +export class StopImpersonationDTO { + @Field(() => Boolean) + canRestoreImpersonatorSession: boolean; +} 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 368efc7692..ce77e06f3b 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 @@ -56,6 +56,7 @@ import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/ import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -132,6 +133,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; FileModule, ConnectedAccountTokenEncryptionModule, EmailAliasManagerModule, + UserSessionModule, ], controllers: [ GoogleAuthController, 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 a96b1f1912..00a7fc0606 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 @@ -24,6 +24,8 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service'; @@ -92,6 +94,14 @@ describe('AuthResolver', () => { provide: FileCorePictureService, useValue: {}, }, + { + provide: UserSessionService, + useValue: {}, + }, + { + provide: UserSessionCookieService, + useValue: {}, + }, { provide: UserWorkspaceService, useValue: {}, 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 7f23cff3b4..f670de1b22 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 @@ -3,6 +3,7 @@ import { Args, Context, Mutation, Query } from '@nestjs/graphql'; import { InjectRepository } from '@nestjs/typeorm'; import bytes from 'bytes'; +import { type Request } from 'express'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import omit from 'lodash.omit'; import { PermissionFlagType } from 'twenty-shared/constants'; @@ -77,6 +78,8 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input'; import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter'; import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -157,6 +160,8 @@ export class AuthResolver { private readonly impersonationAuthorizationService: ImpersonationAuthorizationService, private readonly subdomainManagerService: SubdomainManagerService, private readonly fileCorePictureService: FileCorePictureService, + private readonly userSessionService: UserSessionService, + private readonly userSessionCookieService: UserSessionCookieService, ) {} @UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard) @@ -240,6 +245,7 @@ export class AuthResolver { async signIn( @Args() userCredentials: UserCredentialsInput, + @Context() context: { req: Request }, ): Promise { const user = await this.authService.validateLoginWithPassword(userCredentials); @@ -249,7 +255,7 @@ export class AuthResolver { user.email, ); - return { + const result = { availableWorkspaces: await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( availableWorkspaces, @@ -271,6 +277,14 @@ export class AuthResolver { }), }, }; + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: result.tokens, + request: context.req, + origin: 'sign_in', + }); + + return result; } @Mutation(() => VerifyEmailAndGetLoginTokenDTO) @@ -324,6 +338,7 @@ export class AuthResolver { @Args() getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput, @AuthProvider() authProvider: AuthProviderEnum, + @Context() context: { req: Request }, ) { const appToken = await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow( @@ -349,7 +364,7 @@ export class AuthResolver { user.email, ); - return { + const result = { availableWorkspaces: await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( availableWorkspaces, @@ -371,6 +386,14 @@ export class AuthResolver { }), }, }; + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: result.tokens, + request: context.req, + origin: 'sign_in', + }); + + return result; } @Mutation(() => AuthTokens) @@ -379,6 +402,7 @@ export class AuthResolver { @Args() twoFactorAuthenticationVerificationInput: TwoFactorAuthenticationVerificationInput, @Args('origin') origin: string, + @Context() context: { req: Request }, ): Promise { const { sub: email, @@ -399,13 +423,26 @@ export class AuthResolver { TwoFactorAuthenticationStrategy.TOTP, ); - return await this.authService.verify(email, workspace.id, authProvider); + const authTokens = await this.authService.verify( + email, + workspace.id, + authProvider, + ); + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: authTokens.tokens, + request: context.req, + origin: 'sign_in', + }); + + return authTokens; } @Mutation(() => AvailableWorkspacesAndAccessTokensDTO) @UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard) async signUp( @Args() signUpInput: UserCredentialsInput, + @Context() context: { req: Request }, ): Promise { const user = await this.signInUpService.signUpWithoutWorkspace( { @@ -432,7 +469,7 @@ export class AuthResolver { verificationTrigger: EmailVerificationTrigger.SIGN_UP, }); - return { + const result = { availableWorkspaces: await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( availableWorkspaces, @@ -454,6 +491,14 @@ export class AuthResolver { }), }, }; + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: result.tokens, + request: context.req, + origin: 'sign_in', + }); + + return result; } @Mutation(() => SignUpDTO) @@ -636,6 +681,7 @@ export class AuthResolver { async getAuthTokensFromLoginToken( @Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput, @Args('origin') origin: string, + @Context() context: { req: Request }, ): Promise { const tokenPayload = await this.validateAndDecodeLoginToken( getAuthTokensFromLoginTokenInput.loginToken, @@ -651,6 +697,8 @@ export class AuthResolver { tokenPayload.workspaceId, ); + let authTokens: AuthTokens; + if (tokenPayload.authProvider === AuthProviderEnum.Impersonation) { const { workspaceId, @@ -664,24 +712,31 @@ export class AuthResolver { user.email, ); - return await this.authService.generateImpersonationAccessTokenAndRefreshToken( - { + authTokens = + await this.authService.generateImpersonationAccessTokenAndRefreshToken({ workspaceId, impersonatorUserWorkspaceId, impersonatedUserWorkspaceId, _impersonatorUserId: impersonatorUserId, impersonatedUserId, - }, - ); + }); } else { await this.validateRegularAuthentication(workspace, userWorkspace); - return await this.authService.verify( + authTokens = await this.authService.verify( user.email, workspace.id, tokenPayload.authProvider, ); } + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: authTokens.tokens, + request: context.req, + origin: 'sign_in', + }); + + return authTokens; } @Mutation(() => AuthTokens) @@ -689,13 +744,14 @@ export class AuthResolver { async getAuthTokensFromSSOExchangeToken( @Args() { ssoExchangeToken }: GetAuthTokensFromSSOExchangeTokenInput, + @Context() context: { req: Request }, ): Promise { const { userId, authProvider } = await this.ssoExchangeTokenService.validateAndConsumeSSOExchangeTokenOrThrow( ssoExchangeToken, ); - return { + const authTokens = { tokens: { accessOrWorkspaceAgnosticToken: await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( @@ -711,6 +767,14 @@ export class AuthResolver { }), }, }; + + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: authTokens.tokens, + request: context.req, + origin: 'sign_in', + }); + + return authTokens; } private async validateAndDecodeLoginToken( @@ -886,14 +950,51 @@ export class AuthResolver { @Mutation(() => AuthTokens) @UseGuards(PublicEndpointGuard, NoPermissionGuard) - async renewToken(@Args() args: AppTokenInput): Promise { + async renewToken( + @Args() args: AppTokenInput, + @Context() context: { req: Request }, + ): Promise { const tokens = await this.renewTokenService.generateTokensFromRefreshToken( args.appToken, ); + await this.userSessionService.issueSessionForTokenPair({ + tokenPair: tokens, + request: context.req, + origin: 'renewal_bridge', + }); + return { tokens: tokens }; } + @Mutation(() => Boolean) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + async signOut( + @Context() context: { req: Request }, + @Args('refreshToken', { nullable: true }) refreshToken?: string, + ): Promise { + try { + await this.userSessionService.signOut({ + sessionToken: + this.userSessionCookieService.extractSessionTokenFromRequest( + context.req, + ), + refreshToken, + }); + } finally { + // This mutation is public and SameSite=Lax keeps the cookie off cross-site + // POSTs, so clearing unconditionally would let any site sign a visitor out. + if ( + isDefined(context.req.res) && + this.userSessionCookieService.hasSessionCookie(context.req) + ) { + this.userSessionCookieService.clearSessionCookie(context.req.res); + } + } + + return true; + } + @UseGuards( WorkspaceAuthGuard, RequireAccessTokenGuard, diff --git a/packages/twenty-server/src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant.ts b/packages/twenty-server/src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant.ts new file mode 100644 index 0000000000..df610fce49 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW = '10m'; 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 e0cae930ff..56bac3d507 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 @@ -34,6 +34,8 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @@ -41,6 +43,11 @@ import { AuthService } from './auth.service'; jest.mock('bcrypt'); +jest.mock('twenty-emails', () => ({ + ...jest.requireActual('twenty-emails'), + renderEmail: jest.fn().mockResolvedValue('rendered-email'), +})); + const twentyConfigServiceGetMock = jest.fn(); describe('AuthService', () => { @@ -53,6 +60,7 @@ describe('AuthService', () => { let workspaceInvitationService: WorkspaceInvitationService; let permissionsService: PermissionsService; let refreshTokenService: RefreshTokenService; + let userSessionService: UserSessionService; let signInUpServiceMock: jest.Mocked< Pick >; @@ -71,6 +79,7 @@ describe('AuthService', () => { provide: getRepositoryToken(UserEntity), useValue: { findOne: jest.fn(), + update: jest.fn(), }, }, { @@ -82,6 +91,7 @@ describe('AuthService', () => { where: jest.fn().mockReturnThis(), getOne: jest.fn().mockImplementation(() => null), }), + update: jest.fn(), }, }, { @@ -95,6 +105,7 @@ describe('AuthService', () => { { provide: DomainServerConfigService, useValue: { + getBaseUrl: jest.fn(() => new URL('https://app.twenty.com')), buildBaseUrl: jest.fn(({ pathname, searchParams, hash }) => buildUrlWithPathnameAndSearchParams({ baseUrl: new URL('https://app.twenty.com'), @@ -134,7 +145,9 @@ describe('AuthService', () => { }, { provide: EmailService, - useValue: {}, + useValue: { + send: jest.fn(), + }, }, { provide: AccessTokenService, @@ -212,6 +225,12 @@ describe('AuthService', () => { .mockResolvedValue(undefined), }, }, + { + provide: UserSessionService, + useValue: { + revokeAllSessionsForUser: jest.fn().mockResolvedValue(0), + }, + }, ], }).compile(); @@ -234,6 +253,7 @@ describe('AuthService', () => { signInUpServiceMock = module.get(SignInUpService) as jest.Mocked< Pick >; + userSessionService = module.get(UserSessionService); }); beforeEach(() => { @@ -749,4 +769,45 @@ describe('AuthService', () => { expect(signInUpServiceMock.signUpWithoutWorkspace).not.toHaveBeenCalled(); }); }); + describe('updatePassword', () => { + const buildUserWithWorkspace = (userId: string) => + ({ + id: userId, + email: 'tim@twenty.com', + firstName: 'Tim', + lastName: 'Apple', + userWorkspaces: [{ locale: 'en' }], + }) as unknown as UserEntity; + + it('should revoke every session for the user after a password change', async () => { + const userId = 'e2c1a1a2-0000-4000-8000-000000000001'; + + jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(buildUserWithWorkspace(userId)); + + await service.updatePassword(userId, 'Str0ngPassw0rd!'); + + expect(userSessionService.revokeAllSessionsForUser).toHaveBeenCalledWith({ + userId, + reason: UserSessionRevokedReason.PasswordChanged, + }); + }); + + it('should not revoke sessions when the new password is rejected', async () => { + const userId = 'e2c1a1a2-0000-4000-8000-000000000002'; + + jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(buildUserWithWorkspace(userId)); + + await expect(service.updatePassword(userId, 'weak')).rejects.toThrow( + AuthException, + ); + + expect( + userSessionService.revokeAllSessionsForUser, + ).not.toHaveBeenCalled(); + }); + }); }); 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 69cf0be929..ecbd215d3b 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 @@ -56,6 +56,8 @@ import { } from 'src/engine/core-modules/auth/types/signInUp.type'; import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util'; import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { WorkspaceDomainConfig } from 'src/engine/core-modules/domain/workspace-domains/types/workspace-domain-config.type'; import { EmailService } from 'src/engine/core-modules/email/email.service'; @@ -104,6 +106,7 @@ export class AuthService { private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly featureFlagService: FeatureFlagService, private readonly createSSOConnectedAccountService: CreateSSOConnectedAccountService, + private readonly userSessionService: UserSessionService, ) {} private async checkAccessAndUseInvitationOrThrow( @@ -713,6 +716,11 @@ export class AuthService { }, ); + await this.userSessionService.revokeAllSessionsForUser({ + userId, + reason: UserSessionRevokedReason.PasswordChanged, + }); + const emailTemplate = PasswordUpdateNotifyEmail({ userName: `${user.firstName} ${user.lastName}`, email: user.email, 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 index 382d7f543c..865d193c53 100644 --- 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 @@ -13,6 +13,8 @@ import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.aut import { EmailService } from 'src/engine/core-modules/email/email.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @@ -29,6 +31,7 @@ describe('AccessTokenService', () => { let workspaceRepository: Repository; let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager; let userWorkspaceRepository: Repository; + let userSessionService: UserSessionService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -76,6 +79,13 @@ describe('AccessTokenService', () => { provide: EmailService, useValue: {}, }, + { + provide: UserSessionService, + useValue: { + resolveSession: jest.fn(), + }, + }, + UserSessionCookieService, { provide: GlobalWorkspaceOrmManager, useValue: { @@ -103,6 +113,7 @@ describe('AccessTokenService', () => { userWorkspaceRepository = module.get>( getRepositoryToken(UserWorkspaceEntity), ); + userSessionService = module.get(UserSessionService); }); it('should be defined', () => { @@ -288,10 +299,92 @@ describe('AccessTokenService', () => { jest .spyOn(jwtWrapperService, 'extractJwtFromRequest') .mockReturnValue(() => null); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( AuthException, ); }); + + it('should reject session tokens presented as bearer tokens', async () => { + const mockSessionToken = 'sess_opaque-session-token'; + const mockRequest = { + headers: { + authorization: `Bearer ${mockSessionToken}`, + }, + } as Request; + + jest + .spyOn(jwtWrapperService, 'extractJwtFromRequest') + .mockReturnValue(() => mockSessionToken); + + await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( + 'Session tokens are only accepted from the session cookie', + ); + }); + + it('should resolve the session cookie when cookie sessions are enabled', async () => { + const mockSessionToken = 'sess_opaque-session-token'; + const mockRequest = { + headers: { + cookie: `twenty-session=${mockSessionToken}`, + }, + } as Request; + const mockPayload = { + sub: 'user-id', + userId: 'user-id', + workspaceId: 'workspace-id', + userWorkspaceId: 'user-workspace-id', + }; + const mockAuthContext = { + user: { id: 'user-id' }, + workspace: { id: 'workspace-id' }, + workspaceMember: { id: 'workspace-member-id' }, + }; + + jest + .spyOn(jwtWrapperService, 'extractJwtFromRequest') + .mockReturnValue(() => null); + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => + key === 'SERVER_URL' ? 'http://localhost:3000' : true, + ); + jest.spyOn(userSessionService, 'resolveSession').mockResolvedValue({ + payload: mockPayload as any, + authenticatedAt: new Date(), + expiresAt: new Date(), + }); + jest + .spyOn(service['jwtStrategy'], 'validate') + .mockResolvedValue(mockAuthContext as any); + + const result = await service.validateTokenByRequest(mockRequest); + + expect(userSessionService.resolveSession).toHaveBeenCalledWith( + mockSessionToken, + ); + expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(mockPayload); + expect(result.workspaceMemberId).toEqual('workspace-member-id'); + }); + + it('should ignore the session cookie when cookie sessions are disabled', async () => { + const mockRequest = { + headers: { + cookie: 'twenty-session=sess_opaque-session-token', + }, + } as Request; + + jest + .spyOn(jwtWrapperService, 'extractJwtFromRequest') + .mockReturnValue(() => null); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); + jest.spyOn(userSessionService, 'resolveSession'); + + await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( + 'Missing authentication token', + ); + expect(userSessionService.resolveSession).not.toHaveBeenCalled(); + }); }); }); 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 index e1b1b222fd..afa10b8124 100644 --- 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 @@ -21,6 +21,9 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-t import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { isUserSessionToken } from 'src/engine/core-modules/user-session/utils/is-user-session-token.util'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceNotFoundDefaultError } from 'src/engine/core-modules/user-workspace/user-workspace.exception'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -44,6 +47,8 @@ export class AccessTokenService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, + private readonly userSessionService: UserSessionService, + private readonly userSessionCookieService: UserSessionCookieService, ) {} private async resolveTokenSubject( @@ -194,13 +199,45 @@ export class AccessTokenService { async validateTokenByRequest(request: Request): Promise { const token = this.jwtWrapperService.extractJwtFromRequest()(request); - if (!token) { - throw new AuthException( - 'Missing authentication token', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + if (token) { + if (isUserSessionToken(token)) { + // Session tokens are cookie-only by design: accepting them as Bearer would + // reopen the XSS-exfiltration surface cookie sessions close. + throw new AuthException( + 'Session tokens are only accepted from the session cookie', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + return this.validateToken(token); } - return this.validateToken(token); + const sessionToken = + this.userSessionCookieService.extractSessionTokenFromRequest(request); + + if (sessionToken) { + return this.validateSessionToken(sessionToken); + } + + throw new AuthException( + 'Missing authentication token', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + private async validateSessionToken( + sessionToken: string, + ): Promise { + const { payload, authenticatedAt } = + await this.userSessionService.resolveSession(sessionToken); + + const context = await this.jwtStrategy.validate(payload); + + return { + ...context, + workspaceMemberId: + context.workspaceMemberId ?? context.workspaceMember?.id, + authenticatedAt, + }; } } 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 6d8699dc74..b02c0c8155 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 @@ -14,6 +14,7 @@ import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/serv import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -36,6 +37,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceCacheModule, CoreEntityCacheModule, ImpersonationAuthorizationModule, + UserSessionModule, ], providers: [ RenewTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts index 268c4474d6..0f31e1e6bc 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts @@ -22,4 +22,7 @@ export type RawAuthContext = { impersonatedUserWorkspaceId?: string; }; tokenType?: JwtTokenTypeEnum; + // When the user last proved their identity. Only sessions can express this: + // a JWT's iat is its renewal time, not the moment the user authenticated. + authenticatedAt?: Date; }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/can-credential-auto-login-into-workspaces.util.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/can-credential-auto-login-into-workspaces.util.spec.ts new file mode 100644 index 0000000000..36a97e619a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/can-credential-auto-login-into-workspaces.util.spec.ts @@ -0,0 +1,92 @@ +import { canCredentialAutoLoginIntoWorkspaces } from 'src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util'; + +const now = new Date('2026-01-01T12:00:00.000Z'); +const autoLoginWindow = '10m'; + +describe('canCredentialAutoLoginIntoWorkspaces', () => { + it('should allow a workspace-scoped credential whatever its age', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: true, + authenticatedAt: new Date('2025-01-01T12:00:00.000Z'), + autoLoginWindow, + now, + }), + ).toBe(true); + }); + + it('should allow a user-level credential inside the window', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2026-01-01T11:55:00.000Z'), + autoLoginWindow, + now, + }), + ).toBe(true); + }); + + it('should refuse a user-level credential past the window', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2026-01-01T11:49:00.000Z'), + autoLoginWindow, + now, + }), + ).toBe(false); + }); + + it('should keep the legacy behavior for credentials without an authentication time', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: undefined, + autoLoginWindow, + now, + }), + ).toBe(true); + }); + + it('should fall back to the default window when it is unparseable', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2026-01-01T11:55:00.000Z'), + autoLoginWindow: 'not-a-duration', + now, + }), + ).toBe(true); + + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2020-01-01T00:00:00.000Z'), + autoLoginWindow: 'not-a-duration', + now, + }), + ).toBe(false); + }); + + it('should fall back to the default window when it is negative', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2026-01-01T11:55:00.000Z'), + autoLoginWindow: '-1d', + now, + }), + ).toBe(true); + }); + + it('should honour a zero window as a deliberate disable', () => { + expect( + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: false, + authenticatedAt: new Date('2026-01-01T11:59:59.000Z'), + autoLoginWindow: '0s', + now, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util.ts new file mode 100644 index 0000000000..89207fef67 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util.ts @@ -0,0 +1,47 @@ +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { isDefined } from 'twenty-shared/utils'; + +import { DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW } from 'src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant'; + +// A workspace-agnostic session outlives a sign-out performed on a workspace +// subdomain, since the workspace cannot clear a cookie it does not own, so +// converting it into workspace access would hand the workspace back. +const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW_MS = ms( + DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW, +); + +export const canCredentialAutoLoginIntoWorkspaces = ({ + isWorkspaceScopedCredential, + authenticatedAt, + autoLoginWindow, + now, +}: { + isWorkspaceScopedCredential: boolean; + authenticatedAt: Date | undefined; + autoLoginWindow: string; + now: Date; +}): boolean => { + if (isWorkspaceScopedCredential) { + return true; + } + + // Legacy JWT pairs carry no authentication time, so they keep the + // pre-session behavior until the cutover retires them. + if (!isDefined(authenticatedAt)) { + return true; + } + + const parsedWindowMs = ms(autoLoginWindow); + + // An unparseable or negative window would silently drop the boundary or lock + // everyone out. Zero is kept, since it deliberately turns the bridge off. + const isUsableWindow = + Number.isFinite(parsedWindowMs) && (parsedWindowMs as number) >= 0; + + const autoLoginWindowMs = isUsableWindow + ? (parsedWindowMs as number) + : DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW_MS; + + return addMilliseconds(authenticatedAt, autoLoginWindowMs) > now; +}; diff --git a/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts b/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts index c0397c6725..587c89eff1 100644 --- a/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts @@ -6,6 +6,7 @@ export enum CacheStorageNamespace { ModuleWorkflow = 'module:workflow', EngineWorkspace = 'engine:workspace', EngineCoreEntity = 'engine:core-entity', + EngineAuthSession = 'engine:auth-session', EngineLock = 'engine:lock', EngineHealth = 'engine:health', EngineMetrics = 'engine:metrics', diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 49699e1948..756b3eaef9 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -96,6 +96,7 @@ describe('ClientConfigController', () => { analyticsEnabled: false, canManageFeatureFlags: true, publicFeatureFlags: [], + isCookieSessionEnabled: false, isMicrosoftMessagingEnabled: false, isMicrosoftCalendarEnabled: false, isGoogleMessagingEnabled: false, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index ab17464146..0fd72b96da 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -309,6 +309,9 @@ export class ClientConfig { @Field(() => [PublicFeatureFlag]) publicFeatureFlags: PublicFeatureFlag[]; + @Field(() => Boolean) + isCookieSessionEnabled: boolean; + @Field(() => Boolean) isMicrosoftMessagingEnabled: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 4c809afe37..38dce08ec4 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -197,6 +197,30 @@ describe('ClientConfigService', () => { }); }); + it('should advertise cookie sessions when the flag is on', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => + key === 'AUTH_COOKIE_SESSIONS_ENABLED' ? true : undefined, + ); + + const result = await service.getClientConfig(); + + expect(result.isCookieSessionEnabled).toBe(true); + }); + + it('should not advertise cookie sessions when the flag is off', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => + key === 'AUTH_COOKIE_SESSIONS_ENABLED' ? false : undefined, + ); + + const result = await service.getClientConfig(); + + expect(result.isCookieSessionEnabled).toBe(false); + }); + it('should handle production environment correctly', async () => { jest .spyOn(twentyConfigService, 'get') diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index f63f6f93fb..288471d4a9 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -251,6 +251,9 @@ export class ClientConfigService { NodeEnvironment.DEVELOPMENT || this.twentyConfigService.get('IS_BILLING_ENABLED'), publicFeatureFlags: PUBLIC_FEATURE_FLAGS, + isCookieSessionEnabled: this.twentyConfigService.get( + 'AUTH_COOKIE_SESSIONS_ENABLED', + ), isMicrosoftMessagingEnabled: this.twentyConfigService.get( 'MESSAGING_PROVIDER_MICROSOFT_ENABLED', ), diff --git a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts index 448fd83a2b..e3fd25209a 100644 --- a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts +++ b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts @@ -26,6 +26,10 @@ import { type LOGIC_FUNCTION_EXECUTED_EVENT, type LogicFunctionExecutedTrackEvent, } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed'; +import { + type AUTH_SESSION_EVENT, + type AuthSessionTrackEvent, +} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session'; import { type IMPERSONATION_EVENT, type ImpersonationTrackEvent, @@ -52,6 +56,7 @@ import { } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/workspace/workspace-created'; export type TrackEventName = + | typeof AUTH_SESSION_EVENT | typeof CUSTOM_DOMAIN_ACTIVATED_EVENT | typeof CUSTOM_DOMAIN_DEACTIVATED_EVENT | typeof LOGIC_FUNCTION_EXECUTED_EVENT @@ -67,6 +72,7 @@ export type TrackEventName = | typeof SERVER_ADMIN_ACCESS_CHANGED_EVENT; export interface TrackEvents { + [AUTH_SESSION_EVENT]: AuthSessionTrackEvent; [CUSTOM_DOMAIN_ACTIVATED_EVENT]: CustomDomainActivatedTrackEvent; [CUSTOM_DOMAIN_DEACTIVATED_EVENT]: CustomDomainDeactivatedTrackEvent; [LOGIC_FUNCTION_EXECUTED_EVENT]: LogicFunctionExecutedTrackEvent; diff --git a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session.ts b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session.ts new file mode 100644 index 0000000000..c118e8db2b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { registerEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track'; + +export const AUTH_SESSION_EVENT = 'AuthSession' as const; + +export const authSessionSchema = z.strictObject({ + event: z.literal(AUTH_SESSION_EVENT), + properties: z.strictObject({ + action: z.enum(['user_signed_in', 'user_signed_out', 'session_revoked']), + message: z.string().optional(), + }), +}); + +export type AuthSessionTrackEvent = z.infer; + +registerEvent(AUTH_SESSION_EVENT, authSessionSchema); diff --git a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation.ts b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation.ts index a5a952c227..264b171eab 100644 --- a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation.ts +++ b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation.ts @@ -12,6 +12,7 @@ export const impersonationSchema = z.strictObject({ 'attempt', 'attempted', 'issued', + 'ended', 'login_token_attempt', 'login_token_generated', 'login_token_failed', diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts index 741ceff15c..d10aa2c4a4 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts @@ -8,12 +8,15 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @@ -22,6 +25,14 @@ const UserWorkspaceFindOneMock = jest.fn(); const LoginTokenServiceGenerateLoginTokenMock = jest.fn(); const PermissionsServiceUserHasWorkspaceSettingPermissionMock = jest.fn(); const TwentyConfigServiceGetMock = jest.fn(); +const UserSessionCreateSessionMock = jest.fn(); +const UserSessionRevokeByTokenMock = jest.fn(); +const UserSessionResolveSessionMock = jest.fn(); +const CookieAttachMock = jest.fn(); +const CookieClearMock = jest.fn(); +const CookieExtractMock = jest.fn(); +const CookieExtractImpersonatorMock = jest.fn(); +const CookieClearImpersonatorMock = jest.fn(); describe('ImpersonationService', () => { let service: ImpersonationService; @@ -62,6 +73,25 @@ describe('ImpersonationService', () => { get: TwentyConfigServiceGetMock, }, }, + { + provide: UserSessionService, + useValue: { + createSession: UserSessionCreateSessionMock, + revokeSessionByToken: UserSessionRevokeByTokenMock, + resolveSession: UserSessionResolveSessionMock, + }, + }, + { + provide: UserSessionCookieService, + useValue: { + attachSessionTokenToResponse: CookieAttachMock, + clearSessionCookie: CookieClearMock, + extractSessionTokenFromRequest: CookieExtractMock, + extractImpersonatorSessionTokenFromRequest: + CookieExtractImpersonatorMock, + clearImpersonatorSessionCookie: CookieClearImpersonatorMock, + }, + }, { provide: WorkspaceDomainsService, useValue: { @@ -821,4 +851,150 @@ describe('ImpersonationService', () => { }); }); }); + describe('stopImpersonation', () => { + const buildRequest = () => + ({ + headers: {}, + res: {}, + }) as unknown as Parameters< + ImpersonationService['stopImpersonation'] + >[0]['request']; + + const IMPERSONATOR_USER_WORKSPACE_ID = 'impersonator-user-workspace-id'; + + const stopImpersonating = () => { + UserWorkspaceFindOneMock.mockResolvedValue({ + id: IMPERSONATOR_USER_WORKSPACE_ID, + userId: 'impersonator-user-id', + workspaceId: 'workspace-id', + }); + + return service.stopImpersonation({ + impersonationContext: { + impersonatorUserWorkspaceId: IMPERSONATOR_USER_WORKSPACE_ID, + impersonatedUserWorkspaceId: 'impersonated-user-workspace-id', + }, + workspaceId: 'workspace-id', + request: buildRequest(), + }); + }; + + const parkedSession = ( + overrides: Record = {}, + ): Record => ({ + payload: { + type: JwtTokenTypeEnum.ACCESS, + userWorkspaceId: IMPERSONATOR_USER_WORKSPACE_ID, + isImpersonating: false, + ...overrides, + }, + authenticatedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2026-07-01T00:00:00.000Z'), + }); + + it('should hand back the parked impersonator session without minting one', async () => { + CookieExtractMock.mockReturnValue('sess_impersonation'); + CookieExtractImpersonatorMock.mockReturnValue('sess_impersonator'); + UserSessionResolveSessionMock.mockResolvedValue(parkedSession()); + + const result = await stopImpersonating(); + + expect(result).toEqual({ canRestoreImpersonatorSession: true }); + expect(UserSessionCreateSessionMock).not.toHaveBeenCalled(); + expect(CookieAttachMock).toHaveBeenCalledWith( + expect.anything(), + 'sess_impersonator', + new Date('2026-07-01T00:00:00.000Z'), + ); + expect(CookieClearMock).not.toHaveBeenCalled(); + expect(CookieClearImpersonatorMock).toHaveBeenCalled(); + }); + + it('should refuse to restore a session belonging to someone else', async () => { + CookieExtractMock.mockReturnValue('sess_impersonation'); + CookieExtractImpersonatorMock.mockReturnValue('sess_someone_else'); + UserSessionResolveSessionMock.mockResolvedValue( + parkedSession({ userWorkspaceId: 'another-user-workspace-id' }), + ); + + const result = await stopImpersonating(); + + expect(result).toEqual({ canRestoreImpersonatorSession: false }); + expect(CookieAttachMock).not.toHaveBeenCalled(); + expect(CookieClearMock).toHaveBeenCalled(); + }); + + it('should refuse to restore a session that is itself impersonating', async () => { + CookieExtractMock.mockReturnValue('sess_impersonation'); + CookieExtractImpersonatorMock.mockReturnValue('sess_nested'); + UserSessionResolveSessionMock.mockResolvedValue( + parkedSession({ isImpersonating: true }), + ); + + const result = await stopImpersonating(); + + expect(result).toEqual({ canRestoreImpersonatorSession: false }); + expect(CookieAttachMock).not.toHaveBeenCalled(); + }); + + it('should refuse to restore a session revoked while impersonating', async () => { + CookieExtractMock.mockReturnValue('sess_impersonation'); + CookieExtractImpersonatorMock.mockReturnValue('sess_revoked'); + UserSessionResolveSessionMock.mockRejectedValue( + new AuthException('nope', AuthExceptionCode.UNAUTHENTICATED), + ); + + const result = await stopImpersonating(); + + expect(result).toEqual({ canRestoreImpersonatorSession: false }); + expect(CookieAttachMock).not.toHaveBeenCalled(); + expect(CookieClearMock).toHaveBeenCalled(); + }); + + it('should sign out when nothing was parked, as on a cross-workspace host', async () => { + CookieExtractMock.mockReturnValue('sess_impersonation'); + CookieExtractImpersonatorMock.mockReturnValue(undefined); + + const result = await stopImpersonating(); + + expect(result).toEqual({ canRestoreImpersonatorSession: false }); + expect(UserSessionResolveSessionMock).not.toHaveBeenCalled(); + expect(UserSessionCreateSessionMock).not.toHaveBeenCalled(); + expect(CookieAttachMock).not.toHaveBeenCalled(); + expect(CookieClearMock).toHaveBeenCalled(); + }); + + it('should revoke the presented impersonation session', async () => { + UserWorkspaceFindOneMock.mockResolvedValue({ + id: 'impersonator-user-workspace-id', + userId: 'impersonator-user-id', + workspaceId: 'workspace-id', + }); + CookieExtractMock.mockReturnValue('sess_presented'); + + await service.stopImpersonation({ + impersonationContext: { + impersonatorUserWorkspaceId: 'impersonator-user-workspace-id', + impersonatedUserWorkspaceId: 'impersonated-user-workspace-id', + }, + workspaceId: 'workspace-id', + request: buildRequest(), + }); + + expect(UserSessionRevokeByTokenMock).toHaveBeenCalledWith( + 'sess_presented', + expect.anything(), + ); + }); + + it('should refuse when the request is not impersonating', async () => { + await expect( + service.stopImpersonation({ + impersonationContext: undefined, + workspaceId: 'workspace-id', + request: buildRequest(), + }), + ).rejects.toThrow(AuthException); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts index 48bb1b4ab1..0ef057fea8 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts @@ -8,6 +8,7 @@ import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impers import { ImpersonationResolver } from 'src/engine/core-modules/impersonation/impersonation.resolver'; import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -31,6 +32,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role. ]), WorkspaceDomainsModule, PermissionsModule, + UserSessionModule, ], providers: [ImpersonationService, ImpersonationResolver], exports: [ImpersonationService], diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts index 0613144e8f..ed3738d735 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts @@ -1,21 +1,29 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; -import { Args, Mutation } from '@nestjs/graphql'; +import { Args, Context, Mutation } from '@nestjs/graphql'; + +import { type Request } from 'express'; import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input'; import { ImpersonateDTO } from 'src/engine/core-modules/admin-panel/dtos/impersonate.dto'; +import { StopImpersonationDTO } from 'src/engine/core-modules/admin-panel/dtos/stop-impersonation.dto'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { AuthImpersonationContext } from 'src/engine/decorators/auth/auth-impersonation-context.decorator'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { ImpersonatePermissionGuard } from 'src/engine/guards/impersonate-permission.guard'; import { NoImpersonationGuard } from 'src/engine/guards/no-impersonation.guard'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -55,4 +63,19 @@ export class ImpersonationResolver { impersonatorUserWorkspaceId, ); } + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard) + @Mutation(() => StopImpersonationDTO) + async stopImpersonation( + @AuthImpersonationContext() + impersonationContext: AuthContext['impersonationContext'], + @AuthWorkspace() workspace: WorkspaceEntity, + @Context() context: { req: Request }, + ): Promise { + return await this.impersonationService.stopImpersonation({ + impersonationContext, + workspaceId: workspace.id, + request: context.req, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts index cb593af885..0f998d9bca 100644 --- a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { type Request } from 'express'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; @@ -9,11 +10,17 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant'; import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @@ -26,6 +33,9 @@ export class ImpersonationService { @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, private readonly impersonationAuthorizationService: ImpersonationAuthorizationService, + private readonly twentyConfigService: TwentyConfigService, + private readonly userSessionService: UserSessionService, + private readonly userSessionCookieService: UserSessionCookieService, ) {} async impersonate( @@ -87,6 +97,121 @@ export class ImpersonationService { ); } + // Hands the impersonator back the session parked when impersonation started. + // Nothing is minted on the strength of the impersonated user's cookie. + async stopImpersonation({ + impersonationContext, + workspaceId, + request, + }: { + impersonationContext: AuthContext['impersonationContext']; + workspaceId: string; + request: Request; + }): Promise<{ canRestoreImpersonatorSession: boolean }> { + if (!isDefined(impersonationContext)) { + throw new AuthException( + 'Not currently impersonating', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const impersonatorUserWorkspace = + await this.userWorkspaceRepository.findOne({ + where: { id: impersonationContext.impersonatorUserWorkspaceId }, + relations: ['user', 'workspace'], + }); + + if (!isDefined(impersonatorUserWorkspace)) { + throw new AuthException( + 'Impersonator user workspace not found', + AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, + ); + } + + const presentedSessionToken = + this.userSessionCookieService.extractSessionTokenFromRequest(request); + + if (isDefined(presentedSessionToken)) { + await this.userSessionService.revokeSessionByToken( + presentedSessionToken, + UserSessionRevokedReason.ImpersonationEnded, + ); + } + + const eventLogContext = this.eventLogEmitterService.createContext({ + workspaceId, + userId: impersonatorUserWorkspace.userId, + }); + + void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, { + level: 'workspace', + action: 'ended', + message: `Impersonation ended by impersonatorUserWorkspaceId=${impersonationContext.impersonatorUserWorkspaceId}; workspaceId=${workspaceId}`, + }); + + if (!isDefined(request.res)) { + return { canRestoreImpersonatorSession: false }; + } + + const canRestoreImpersonatorSession = await this.restoreImpersonatorSession( + request, + impersonatorUserWorkspace.id, + ); + + if (!canRestoreImpersonatorSession) { + this.userSessionCookieService.clearSessionCookie(request.res); + } + + this.userSessionCookieService.clearImpersonatorSessionCookie(request.res); + + return { canRestoreImpersonatorSession }; + } + + // The parked token is evidence of nothing on its own, so it is re-resolved + // and checked against the impersonator the impersonation session names. + private async restoreImpersonatorSession( + request: Request, + impersonatorUserWorkspaceId: string, + ): Promise { + const response = request.res; + + if (!isDefined(response)) { + return false; + } + + const impersonatorSessionToken = + this.userSessionCookieService.extractImpersonatorSessionTokenFromRequest( + request, + ); + + if (!isDefined(impersonatorSessionToken)) { + return false; + } + + try { + const { payload, expiresAt } = + await this.userSessionService.resolveSession(impersonatorSessionToken); + + if ( + payload.type !== JwtTokenTypeEnum.ACCESS || + payload.isImpersonating === true || + payload.userWorkspaceId !== impersonatorUserWorkspaceId + ) { + return false; + } + + this.userSessionCookieService.attachSessionTokenToResponse( + response, + impersonatorSessionToken, + expiresAt, + ); + + return true; + } catch { + return false; + } + } + async generateImpersonationLoginToken( impersonatorUserWorkspace: UserWorkspaceEntity, toImpersonateUserWorkspace: UserWorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 35575b3c9a..c35f282b4b 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -46,7 +46,9 @@ import { CastToTypeORMLogLevelArray } from 'src/engine/core-modules/twenty-confi import { CastToUpperSnakeCase } from 'src/engine/core-modules/twenty-config/decorators/cast-to-upper-snake-case.decorator'; import { ConfigVariablesMetadata } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { IsAWSRegion } from 'src/engine/core-modules/twenty-config/decorators/is-aws-region.decorator'; -import { IsDuration } from 'src/engine/core-modules/twenty-config/decorators/is-duration.decorator'; +import { DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW } from 'src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant'; +import { IsNonNegativeDuration } from 'src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator'; +import { IsPositiveDuration } from 'src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator'; import { IsOptionalOrEmptyString } from 'src/engine/core-modules/twenty-config/decorators/is-optional-or-empty-string.decorator'; import { IsStrictlyLowerThan } from 'src/engine/core-modules/twenty-config/decorators/is-strictly-lower-than.decorator'; import { IsTwentySemVer } from 'src/engine/core-modules/twenty-config/decorators/is-twenty-semver.decorator'; @@ -115,7 +117,7 @@ export class ConfigVariables { description: 'Duration for which the email verification token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h'; @@ -124,7 +126,7 @@ export class ConfigVariables { description: 'Duration for which the password reset token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() PASSWORD_RESET_TOKEN_EXPIRES_IN = '5m'; @@ -329,7 +331,7 @@ export class ConfigVariables { description: 'Duration for which the access token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; @@ -338,7 +340,7 @@ export class ConfigVariables { description: 'Duration for which the workspace agnostic token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN = '30m'; @@ -356,16 +358,74 @@ export class ConfigVariables { 'Grace period allowing concurrent refresh token use (e.g. two tabs refreshing simultaneously). Reuse after this window triggers suspicious activity detection.', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsNonNegativeDuration() @IsOptional() REFRESH_TOKEN_REUSE_GRACE_PERIOD = '1m'; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Enable cookie-based user sessions for the web app (dual-stack with token pairs during the migration)', + type: ConfigVariableType.BOOLEAN, + }) + @IsOptional() + AUTH_COOKIE_SESSIONS_ENABLED = false; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.TOKENS_DURATION, + description: + 'Absolute lifetime of a cookie-based user session, set at sign-in and never extended', + type: ConfigVariableType.STRING, + }) + @IsPositiveDuration() + @IsOptional() + SESSION_ABSOLUTE_LIFETIME = '180d'; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.TOKENS_DURATION, + description: + 'Duration of inactivity after which a cookie-based user session expires', + type: ConfigVariableType.STRING, + }) + @IsPositiveDuration() + @IsOptional() + SESSION_IDLE_TIMEOUT = '30d'; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.TOKENS_DURATION, + description: + 'Window after authenticating on the workspace-agnostic domain during which a user-level session can still auto-login into a workspace without re-authenticating', + type: ConfigVariableType.STRING, + }) + @IsNonNegativeDuration() + @IsOptional() + WORKSPACE_AUTO_LOGIN_WINDOW: string = DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'SameSite attribute of the user session cookie. Use none only for split-origin deployments, behind https', + type: ConfigVariableType.STRING, + }) + @IsIn(['lax', 'strict', 'none']) + @IsOptional() + AUTH_COOKIE_SAME_SITE: 'lax' | 'strict' | 'none' = 'lax'; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Comma-separated list of extra origins allowed to send credentialed cross-origin requests (split-origin deployments)', + type: ConfigVariableType.STRING, + }) + @IsOptional() + AUTH_COOKIE_ALLOWED_ORIGINS = ''; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TOKENS_DURATION, description: 'Duration for which the login token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() LOGIN_TOKEN_EXPIRES_IN = '15m'; @@ -374,7 +434,7 @@ export class ConfigVariables { description: 'Duration for which the file token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() FILE_TOKEN_EXPIRES_IN = '1d'; @@ -383,7 +443,7 @@ export class ConfigVariables { description: 'Duration for which the invitation token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() INVITATION_TOKEN_EXPIRES_IN = '30d'; @@ -399,7 +459,7 @@ export class ConfigVariables { description: 'Duration for which an application access token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() APPLICATION_ACCESS_TOKEN_EXPIRES_IN = '30m'; @@ -408,7 +468,7 @@ export class ConfigVariables { description: 'Duration for which an application refresh token is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() APPLICATION_REFRESH_TOKEN_EXPIRES_IN = '60d'; @@ -418,7 +478,7 @@ export class ConfigVariables { 'Duration for which a playground token (in-app REST/GraphQL playground bearer) is valid', type: ConfigVariableType.STRING, }) - @IsDuration() + @IsPositiveDuration() @IsOptional() PLAYGROUND_TOKEN_EXPIRES_IN = '2h'; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-duration.decorator.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-duration.decorator.ts deleted file mode 100644 index b99e9bf104..0000000000 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-duration.decorator.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - registerDecorator, - type ValidationOptions, - ValidatorConstraint, - type ValidatorConstraintInterface, -} from 'class-validator'; - -@ValidatorConstraint({ async: true }) -export class IsDurationConstraint implements ValidatorConstraintInterface { - validate(duration: string) { - const regex = - /^-?[0-9]+(.[0-9]+)?(m(illiseconds?)?|s(econds?)?|h((ou)?rs?)?|d(ays?)?|w(eeks?)?|M(onths?)?|y(ears?)?)?$/; - - return regex.test(duration); // Returns true if duration matches regex - } -} - -export const IsDuration = - (validationOptions?: ValidationOptions) => - (object: object, propertyName: string) => { - registerDecorator({ - target: object.constructor, - propertyName: propertyName, - options: validationOptions, - constraints: [], - validator: IsDurationConstraint, - }); - }; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator.ts new file mode 100644 index 0000000000..ccee8e77f6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator.ts @@ -0,0 +1,17 @@ +import { registerDecorator, type ValidationOptions } from 'class-validator'; + +import { PositiveDurationConstraint } from 'src/engine/core-modules/twenty-config/validators/positive-duration.validator'; + +const IS_ZERO_ALLOWED = true; + +export const IsNonNegativeDuration = + (validationOptions?: ValidationOptions) => + (object: object, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [IS_ZERO_ALLOWED], + validator: PositiveDurationConstraint, + }); + }; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator.ts new file mode 100644 index 0000000000..06c3e33321 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator.ts @@ -0,0 +1,17 @@ +import { registerDecorator, type ValidationOptions } from 'class-validator'; + +import { PositiveDurationConstraint } from 'src/engine/core-modules/twenty-config/validators/positive-duration.validator'; + +const IS_ZERO_ALLOWED = false; + +export const IsPositiveDuration = + (validationOptions?: ValidationOptions) => + (object: object, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [IS_ZERO_ALLOWED], + validator: PositiveDurationConstraint, + }); + }; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/parse-config-duration.util.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/parse-config-duration.util.ts new file mode 100644 index 0000000000..90057072a2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/parse-config-duration.util.ts @@ -0,0 +1,20 @@ +import ms from 'ms'; + +// Parsed by the same library the consumers call, so the accepted units cannot +// drift from them: "1M" and "1Month" look interchangeable but ms reads them as +// one minute and as nothing at all. +export const parseConfigDuration = (duration: unknown): number | undefined => { + if (typeof duration !== 'string') { + return undefined; + } + + try { + const parsedDuration = ms(duration as Parameters[0]); + + return typeof parsedDuration === 'number' && Number.isFinite(parsedDuration) + ? parsedDuration + : undefined; + } catch { + return undefined; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/validators/__tests__/positive-duration.validator.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/validators/__tests__/positive-duration.validator.spec.ts new file mode 100644 index 0000000000..b73ff0b44f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/validators/__tests__/positive-duration.validator.spec.ts @@ -0,0 +1,46 @@ +import { type ValidationArguments } from 'class-validator'; + +import { PositiveDurationConstraint } from 'src/engine/core-modules/twenty-config/validators/positive-duration.validator'; + +describe('PositiveDurationConstraint', () => { + const constraint = new PositiveDurationConstraint(); + + const validate = (duration: unknown, isZeroAllowed: boolean) => + constraint.validate(duration, { + constraints: [isZeroAllowed], + } as ValidationArguments); + + describe.each([ + ['zero disallowed', false], + ['zero allowed', true], + ])('%s', (_label, isZeroAllowed) => { + it.each(['30d', '12h', '10m', '1s', '0.5ms'])( + 'should accept the positive duration %s', + (duration) => { + expect(validate(duration, isZeroAllowed)).toBe(true); + }, + ); + + it.each(['-1s', '-10m'])( + 'should reject the negative duration %s', + (duration) => { + expect(validate(duration, isZeroAllowed)).toBe(false); + }, + ); + + it.each(['1Month', 'not-a-duration', '', undefined, null, 42])( + 'should reject %s, which ms cannot parse', + (duration) => { + expect(validate(duration, isZeroAllowed)).toBe(false); + }, + ); + }); + + it('should reject zero when zero is not allowed', () => { + expect(validate('0s', false)).toBe(false); + }); + + it('should accept zero when zero is allowed', () => { + expect(validate('0s', true)).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/validators/positive-duration.validator.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/validators/positive-duration.validator.ts new file mode 100644 index 0000000000..28907c268d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/validators/positive-duration.validator.ts @@ -0,0 +1,29 @@ +import { + type ValidationArguments, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; + +import { parseConfigDuration } from 'src/engine/core-modules/twenty-config/utils/parse-config-duration.util'; + +@ValidatorConstraint() +export class PositiveDurationConstraint implements ValidatorConstraintInterface { + validate(duration: unknown, args: ValidationArguments) { + const [isZeroAllowed] = args.constraints as [boolean]; + const parsedDuration = parseConfigDuration(duration); + + if (parsedDuration === undefined) { + return false; + } + + return isZeroAllowed ? parsedDuration >= 0 : parsedDuration > 0; + } + + defaultMessage(args: ValidationArguments) { + const [isZeroAllowed] = args.constraints as [boolean]; + + return isZeroAllowed + ? '$property must be a duration ms can parse into zero or more milliseconds, e.g. 10m or 0s' + : '$property must be a duration ms can parse into a positive number of milliseconds, e.g. 30d, 12h or 10m'; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant.ts new file mode 100644 index 0000000000..3d68bc5975 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant.ts @@ -0,0 +1 @@ +export const USER_SESSION_CLEANUP_CRON_PATTERN = '0 3 * * *'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cookie-name.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cookie-name.constant.ts new file mode 100644 index 0000000000..d865faa9f9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-cookie-name.constant.ts @@ -0,0 +1 @@ +export const USER_SESSION_COOKIE_NAME = 'twenty-session'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-cookie-name.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-cookie-name.constant.ts new file mode 100644 index 0000000000..4fec85117b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-cookie-name.constant.ts @@ -0,0 +1,2 @@ +export const USER_SESSION_IMPERSONATOR_COOKIE_NAME = + 'twenty-impersonator-session'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-secure-cookie-name.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-secure-cookie-name.constant.ts new file mode 100644 index 0000000000..c39e4404c5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-impersonator-secure-cookie-name.constant.ts @@ -0,0 +1,2 @@ +export const USER_SESSION_IMPERSONATOR_SECURE_COOKIE_NAME = + '__Host-twenty-impersonator-session'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant.ts new file mode 100644 index 0000000000..b77cbecf98 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant.ts @@ -0,0 +1,3 @@ +// __Host- enforces Secure, Path=/ and no Domain attribute at the browser +// level, so the cookie can never be widened to sibling subdomains. +export const USER_SESSION_SECURE_COOKIE_NAME = '__Host-twenty-session'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-token-prefix.constant.ts b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-token-prefix.constant.ts new file mode 100644 index 0000000000..bd57b7990c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/constants/user-session-token-prefix.constant.ts @@ -0,0 +1,3 @@ +// Makes opaque session tokens self-describing so the bearer-token dispatcher +// can route them without attempting JWT verification first. +export const USER_SESSION_TOKEN_PREFIX = 'sess_'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command.ts b/packages/twenty-server/src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command.ts new file mode 100644 index 0000000000..a3b2d3d028 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command.ts @@ -0,0 +1,33 @@ +import { Command, CommandRunner } from 'nest-commander'; + +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { USER_SESSION_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant'; +import { UserSessionCleanupCronJob } from 'src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job'; + +@Command({ + name: 'cron:user-session:cleanup', + description: + 'Starts a daily cron job that deletes user sessions and refresh tokens that expired or were revoked more than the retention period ago', +}) +export class UserSessionCleanupCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: UserSessionCleanupCronJob.name, + data: undefined, + options: { + repeat: { + pattern: USER_SESSION_CLEANUP_CRON_PATTERN, + }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job.ts b/packages/twenty-server/src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job.ts new file mode 100644 index 0000000000..8fe09aa0e0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job.ts @@ -0,0 +1,93 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { USER_SESSION_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/user-session/constants/user-session-cleanup-cron-pattern.constant'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; + +// Rows outlive their usability so the sessions UI and audits can still show +// recently ended sessions. +const ENDED_SESSION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +const DELETE_BATCH_SIZE = 10000; + +@Injectable() +@Processor(MessageQueue.cronQueue) +export class UserSessionCleanupCronJob { + private readonly logger = new Logger(UserSessionCleanupCronJob.name); + + constructor( + @InjectRepository(UserSessionEntity) + private readonly userSessionRepository: Repository, + @InjectRepository(AppTokenEntity) + private readonly appTokenRepository: Repository, + ) {} + + @Process(UserSessionCleanupCronJob.name) + @SentryCronMonitor( + UserSessionCleanupCronJob.name, + USER_SESSION_CLEANUP_CRON_PATTERN, + ) + async handle(): Promise { + const threshold = new Date(Date.now() - ENDED_SESSION_RETENTION_MS); + + const deletedSessionCount = await this.deleteInBatches(() => + this.userSessionRepository + .createQueryBuilder() + .delete() + .where( + `"id" IN ( + SELECT "id" FROM "core"."userSession" + WHERE "expiresAt" < :threshold OR "revokedAt" < :threshold + LIMIT :batchSize + )`, + { threshold, batchSize: DELETE_BATCH_SIZE }, + ) + .execute() + .then((result) => result.affected ?? 0), + ); + + const deletedRefreshTokenCount = await this.deleteInBatches(() => + this.appTokenRepository + .createQueryBuilder() + .delete() + .where( + `"id" IN ( + SELECT "id" FROM "core"."appToken" + WHERE "type" = 'REFRESH_TOKEN' + AND ("expiresAt" < :threshold OR "revokedAt" < :threshold) + LIMIT :batchSize + )`, + { threshold, batchSize: DELETE_BATCH_SIZE }, + ) + .execute() + .then((result) => result.affected ?? 0), + ); + + if (deletedSessionCount > 0 || deletedRefreshTokenCount > 0) { + this.logger.log( + `Deleted ${deletedSessionCount} ended sessions and ${deletedRefreshTokenCount} stale refresh tokens`, + ); + } + } + + private async deleteInBatches( + deleteBatch: () => Promise, + ): Promise { + let totalDeleted = 0; + let affected = 0; + + do { + affected = await deleteBatch(); + totalDeleted += affected; + } while (affected === DELETE_BATCH_SIZE); + + return totalDeleted; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/dtos/user-session.dto.ts b/packages/twenty-server/src/engine/core-modules/user-session/dtos/user-session.dto.ts new file mode 100644 index 0000000000..be5f20e8e7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/dtos/user-session.dto.ts @@ -0,0 +1,36 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('UserSession') +export class UserSessionDTO { + @Field(() => UUIDScalarType) + id: string; + + @Field(() => UUIDScalarType, { nullable: true }) + workspaceId: string | null; + + @Field(() => String) + authProvider: string; + + @Field(() => Boolean) + isImpersonating: boolean; + + @Field(() => String, { nullable: true }) + userAgent: string | null; + + @Field(() => String, { nullable: true }) + ipAddress: string | null; + + @Field(() => Date) + createdAt: Date; + + @Field(() => Date) + lastActiveAt: Date; + + @Field(() => Date) + expiresAt: Date; + + @Field(() => Boolean) + isCurrent: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/__tests__/user-session-cookie.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/__tests__/user-session-cookie.service.spec.ts new file mode 100644 index 0000000000..f06bd6d21a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/services/__tests__/user-session-cookie.service.spec.ts @@ -0,0 +1,60 @@ +import { type Request } from 'express'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; + +describe('UserSessionCookieService', () => { + const buildService = ( + config: Record = { AUTH_COOKIE_SESSIONS_ENABLED: true }, + ) => + new UserSessionCookieService({ + get: jest.fn((key: string) => config[key]), + } as unknown as TwentyConfigService); + + const buildRequest = (cookieHeader?: string): Request => + ({ headers: cookieHeader ? { cookie: cookieHeader } : {} }) as Request; + + describe('hasSessionCookie', () => { + it('should detect the secure cookie name', () => { + expect( + buildService().hasSessionCookie( + buildRequest('__Host-twenty-session=sess_abc'), + ), + ).toBe(true); + }); + + it('should detect the legacy plain cookie name', () => { + expect( + buildService().hasSessionCookie( + buildRequest('twenty-session=sess_abc'), + ), + ).toBe(true); + }); + + it('should ignore a cookie header carrying only unrelated cookies', () => { + expect( + buildService().hasSessionCookie(buildRequest('other=1; another=2')), + ).toBe(false); + }); + + it('should report no cookie when the request carries none', () => { + expect(buildService().hasSessionCookie(buildRequest())).toBe(false); + }); + + it('should report no cookie when cookie sessions are disabled', () => { + expect( + buildService({ AUTH_COOKIE_SESSIONS_ENABLED: false }).hasSessionCookie( + buildRequest('__Host-twenty-session=sess_abc'), + ), + ).toBe(false); + }); + + it('should not match a cookie whose name merely ends with ours', () => { + expect( + buildService().hasSessionCookie( + buildRequest('not-twenty-session=sess_abc'), + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session-cookie.service.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session-cookie.service.ts new file mode 100644 index 0000000000..1a86d42440 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session-cookie.service.ts @@ -0,0 +1,165 @@ +import { Injectable } from '@nestjs/common'; + +import { isNonEmptyString } from '@sniptt/guards'; + +import { type CookieOptions, type Request, type Response } from 'express'; + +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; +import { USER_SESSION_IMPERSONATOR_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-impersonator-cookie-name.constant'; +import { USER_SESSION_IMPERSONATOR_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-impersonator-secure-cookie-name.constant'; +import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant'; +import { extractUserSessionTokenFromRequestCookie } from 'src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +const isHttpsUrl = (url: string | undefined): boolean => { + if (!isNonEmptyString(url)) { + return false; + } + + try { + return new URL(url).protocol === 'https:'; + } catch { + return false; + } +}; + +@Injectable() +export class UserSessionCookieService { + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + private isSecureDeployment(): boolean { + const serverUrl = this.twentyConfigService.get('SERVER_URL'); + const sameSite = this.twentyConfigService.get('AUTH_COOKIE_SAME_SITE'); + + // SameSite=None is rejected by browsers without Secure, so it forces it. + return isHttpsUrl(serverUrl) || sameSite === 'none'; + } + + private areCookieSessionsEnabled(): boolean { + return this.twentyConfigService.get('AUTH_COOKIE_SESSIONS_ENABLED'); + } + + extractSessionTokenFromRequest(request: Request): string | undefined { + if (!this.areCookieSessionsEnabled()) { + return undefined; + } + + return extractUserSessionTokenFromRequestCookie(request, { + secureCookieName: USER_SESSION_SECURE_COOKIE_NAME, + insecureCookieName: USER_SESSION_COOKIE_NAME, + allowInsecureCookieName: !this.isSecureDeployment(), + }); + } + + extractImpersonatorSessionTokenFromRequest( + request: Request, + ): string | undefined { + if (!this.areCookieSessionsEnabled()) { + return undefined; + } + + return extractUserSessionTokenFromRequestCookie(request, { + secureCookieName: USER_SESSION_IMPERSONATOR_SECURE_COOKIE_NAME, + insecureCookieName: USER_SESSION_IMPERSONATOR_COOKIE_NAME, + allowInsecureCookieName: !this.isSecureDeployment(), + }); + } + + private resolveCookieOptions(): CookieOptions { + return { + httpOnly: true, + secure: this.isSecureDeployment(), + sameSite: this.twentyConfigService.get('AUTH_COOKIE_SAME_SITE'), + path: '/', + }; + } + + private resolveCookieSettings(): { + cookieName: string; + options: CookieOptions; + } { + return { + cookieName: this.isSecureDeployment() + ? USER_SESSION_SECURE_COOKIE_NAME + : USER_SESSION_COOKIE_NAME, + options: this.resolveCookieOptions(), + }; + } + + attachSessionTokenToResponse( + response: Response, + sessionToken: string, + expiresAt: Date, + ): void { + if (!this.areCookieSessionsEnabled()) { + return; + } + + const { cookieName, options } = this.resolveCookieSettings(); + + response.cookie(cookieName, sessionToken, { + ...options, + expires: expiresAt, + }); + } + + attachImpersonatorSessionTokenToResponse( + response: Response, + sessionToken: string, + ): void { + if (!this.areCookieSessionsEnabled()) { + return; + } + + response.cookie( + this.isSecureDeployment() + ? USER_SESSION_IMPERSONATOR_SECURE_COOKIE_NAME + : USER_SESSION_IMPERSONATOR_COOKIE_NAME, + sessionToken, + this.resolveCookieOptions(), + ); + } + + clearImpersonatorSessionCookie(response: Response): void { + if (!this.areCookieSessionsEnabled()) { + return; + } + + const options = this.resolveCookieOptions(); + + response.clearCookie(USER_SESSION_IMPERSONATOR_SECURE_COOKIE_NAME, options); + response.clearCookie(USER_SESSION_IMPERSONATOR_COOKIE_NAME, options); + } + + hasSessionCookie(request: Request): boolean { + if (!this.areCookieSessionsEnabled()) { + return false; + } + + const cookieHeader = request.headers.cookie; + + if (!isNonEmptyString(cookieHeader)) { + return false; + } + + return [USER_SESSION_SECURE_COOKIE_NAME, USER_SESSION_COOKIE_NAME].some( + (cookieName) => + cookieHeader + .split(';') + .some((cookiePart) => cookiePart.trim().startsWith(`${cookieName}=`)), + ); + } + + clearSessionCookie(response: Response): void { + if (!this.areCookieSessionsEnabled()) { + return; + } + + const { options } = this.resolveCookieSettings(); + + // Both names, so an instance that switched to https drops the cookie it + // issued under the old one. + response.clearCookie(USER_SESSION_SECURE_COOKIE_NAME, options); + response.clearCookie(USER_SESSION_COOKIE_NAME, options); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts new file mode 100644 index 0000000000..f66cb77da5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts @@ -0,0 +1,878 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { randomUUID } from 'crypto'; + +import { Repository } from 'typeorm'; + +import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; +import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +describe('UserSessionService', () => { + let service: UserSessionService; + let userSessionRepository: Repository; + let appTokenRepository: Repository; + let jwtWrapperService: JwtWrapperService; + + const cacheStorageService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + mdel: jest.fn(), + }; + + const insertWorkspaceEvent = jest.fn(); + + const mockConfig: Record = { + SESSION_ABSOLUTE_LIFETIME: '180d', + SESSION_IDLE_TIMEOUT: '30d', + SERVER_URL: 'http://crm.example.com', + FRONTEND_URL: 'http://crm.example.com', + AUTH_COOKIE_ALLOWED_ORIGINS: '', + }; + + const buildActiveSession = ( + overrides: Partial = {}, + ): UserSessionEntity => + ({ + id: randomUUID(), + tokenHash: 'token-hash', + userId: randomUUID(), + workspaceId: randomUUID(), + userWorkspaceId: randomUUID(), + authProvider: AuthProviderEnum.Password, + isImpersonating: false, + impersonatorUserWorkspaceId: null, + impersonatedUserWorkspaceId: null, + userAgent: null, + ipAddress: null, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + lastActiveAt: new Date(), + revokedAt: null, + revokedReason: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as UserSessionEntity; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserSessionService, + UserSessionCookieService, + { + provide: getRepositoryToken(UserSessionEntity), + useClass: Repository, + }, + { + provide: getRepositoryToken(AppTokenEntity), + useClass: Repository, + }, + { + provide: CacheStorageNamespace.EngineAuthSession, + useValue: cacheStorageService, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn((key: string) => mockConfig[key]), + }, + }, + { + provide: JwtWrapperService, + useValue: { + verifyJwtToken: jest.fn(), + decode: jest.fn(), + }, + }, + { + provide: EventLogEmitterService, + useValue: { + createContext: jest.fn(() => ({ insertWorkspaceEvent })), + }, + }, + ], + }).compile(); + + service = module.get(UserSessionService); + userSessionRepository = module.get>( + getRepositoryToken(UserSessionEntity), + ); + appTokenRepository = module.get>( + getRepositoryToken(AppTokenEntity), + ); + jwtWrapperService = module.get(JwtWrapperService); + }); + + describe('createSession', () => { + it('should create a workspace session and emit a sign-in event', async () => { + const sessionInput = { + userId: randomUUID(), + workspaceId: randomUUID(), + userWorkspaceId: randomUUID(), + authProvider: AuthProviderEnum.Password, + origin: 'sign_in' as const, + }; + + jest + .spyOn(userSessionRepository, 'create') + .mockImplementation((entity) => entity as UserSessionEntity); + jest + .spyOn(userSessionRepository, 'save') + .mockImplementation(async (entity) => + buildActiveSession(entity as Partial), + ); + + const { sessionToken, session } = + await service.createSession(sessionInput); + + expect(sessionToken).toMatch(/^sess_/); + expect(session.tokenHash).toEqual(hashUserSessionToken(sessionToken)); + expect(session.userId).toEqual(sessionInput.userId); + expect(insertWorkspaceEvent).toHaveBeenCalledWith( + 'AuthSession', + expect.objectContaining({ action: 'user_signed_in' }), + ); + }); + + it('should not emit a sign-in event for renewal-bridge sessions', async () => { + jest + .spyOn(userSessionRepository, 'create') + .mockImplementation((entity) => entity as UserSessionEntity); + jest + .spyOn(userSessionRepository, 'save') + .mockImplementation(async (entity) => + buildActiveSession(entity as Partial), + ); + + await service.createSession({ + userId: randomUUID(), + workspaceId: randomUUID(), + userWorkspaceId: randomUUID(), + authProvider: AuthProviderEnum.Password, + origin: 'renewal_bridge', + }); + + expect(insertWorkspaceEvent).not.toHaveBeenCalled(); + }); + + it('should cap impersonation sessions to a short lifetime', async () => { + jest + .spyOn(userSessionRepository, 'create') + .mockImplementation((entity) => entity as UserSessionEntity); + jest + .spyOn(userSessionRepository, 'save') + .mockImplementation(async (entity) => entity as UserSessionEntity); + + const { session } = await service.createSession({ + userId: randomUUID(), + workspaceId: randomUUID(), + userWorkspaceId: randomUUID(), + authProvider: AuthProviderEnum.Impersonation, + isImpersonating: true, + impersonatorUserWorkspaceId: randomUUID(), + impersonatedUserWorkspaceId: randomUUID(), + origin: 'sign_in', + }); + + const oneDayFromNow = Date.now() + 24 * 60 * 60 * 1000; + + expect(session.expiresAt.getTime()).toBeLessThanOrEqual( + oneDayFromNow + 1000, + ); + }); + + it('should reject a workspace session without a user workspace', async () => { + await expect( + service.createSession({ + userId: randomUUID(), + workspaceId: randomUUID(), + authProvider: AuthProviderEnum.Password, + origin: 'sign_in', + }), + ).rejects.toThrow(AuthException); + }); + }); + + const mockPostCachingRevocationCheck = ( + session: UserSessionEntity | null, + ) => { + jest.spyOn(userSessionRepository, 'findOne').mockResolvedValue(session); + }; + + describe('resolveSession', () => { + afterEach(() => { + mockConfig.SESSION_IDLE_TIMEOUT = '30d'; + }); + + it('should resolve an access payload from the database on cache miss', async () => { + const session = buildActiveSession(); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck(session); + + const { payload } = await service.resolveSession('sess_token'); + + expect(payload).toEqual( + expect.objectContaining({ + type: JwtTokenTypeEnum.ACCESS, + userId: session.userId, + workspaceId: session.workspaceId, + userWorkspaceId: session.userWorkspaceId, + }), + ); + expect(cacheStorageService.set).toHaveBeenCalled(); + }); + + it('should resolve a workspace-agnostic payload for sessions without workspace', async () => { + const session = buildActiveSession({ + workspaceId: null, + userWorkspaceId: null, + }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck(session); + + const { payload } = await service.resolveSession('sess_token'); + + expect(payload.type).toEqual(JwtTokenTypeEnum.WORKSPACE_AGNOSTIC); + }); + + it('should resolve from cache without hitting the database', async () => { + const session = buildActiveSession(); + + cacheStorageService.get.mockResolvedValue({ + sessionId: session.id, + userId: session.userId, + workspaceId: session.workspaceId, + userWorkspaceId: session.userWorkspaceId, + authProvider: session.authProvider, + isImpersonating: false, + impersonatorUserWorkspaceId: null, + impersonatedUserWorkspaceId: null, + expiresAt: session.expiresAt.toISOString(), + lastActiveAt: new Date().toISOString(), + authenticatedAt: session.createdAt.toISOString(), + }); + const findOneBySpy = jest.spyOn(userSessionRepository, 'findOneBy'); + + const { payload } = await service.resolveSession('sess_token'); + + expect(payload.userId).toEqual(session.userId); + expect(findOneBySpy).not.toHaveBeenCalled(); + }); + + it('should reject a revoked session', async () => { + cacheStorageService.get.mockResolvedValue(undefined); + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue(buildActiveSession({ revokedAt: new Date() })); + + await expect(service.resolveSession('sess_token')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + }); + + it('should reject a session past its absolute expiry', async () => { + cacheStorageService.get.mockResolvedValue(undefined); + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue( + buildActiveSession({ expiresAt: new Date(Date.now() - 1000) }), + ); + + await expect(service.resolveSession('sess_token')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + }); + + it('should reject an idle-expired session', async () => { + const idleExpiredLastActiveAt = new Date( + Date.now() - 31 * 24 * 60 * 60 * 1000, + ); + + cacheStorageService.get.mockResolvedValue(undefined); + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue( + buildActiveSession({ lastActiveAt: idleExpiredLastActiveAt }), + ); + + await expect(service.resolveSession('sess_token')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + }); + + it('should reject an unknown session token', async () => { + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.resolveSession('sess_unknown')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + }); + + it('should touch lastActiveAt when the touch interval elapsed', async () => { + const staleLastActiveAt = new Date(Date.now() - 10 * 60 * 1000); + const session = buildActiveSession({ lastActiveAt: staleLastActiveAt }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck(session); + const updateSpy = jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + + await service.resolveSession('sess_token'); + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: session.id }), + expect.objectContaining({ lastActiveAt: expect.any(Date) }), + ); + }); + + it('should touch lastActiveAt within an idle timeout shorter than the throttle', async () => { + mockConfig.SESSION_IDLE_TIMEOUT = '1m'; + + const session = buildActiveSession({ + lastActiveAt: new Date(Date.now() - 40 * 1000), + }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck(session); + const updateSpy = jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + + await service.resolveSession('sess_token'); + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: session.id }), + expect.objectContaining({ lastActiveAt: expect.any(Date) }), + ); + }); + + it('should not touch lastActiveAt on every request', async () => { + const session = buildActiveSession({ lastActiveAt: new Date() }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck(session); + const updateSpy = jest.spyOn(userSessionRepository, 'update'); + + await service.resolveSession('sess_token'); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('should reject and drop the cache entry when the touch hits a revoked session', async () => { + const staleLastActiveAt = new Date(Date.now() - 10 * 60 * 1000); + const session = buildActiveSession({ lastActiveAt: staleLastActiveAt }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 0 } as never); + + await expect(service.resolveSession('sess_token')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + expect(cacheStorageService.del).toHaveBeenCalled(); + }); + + it('should reject when a revocation raced the cache write', async () => { + const session = buildActiveSession(); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + mockPostCachingRevocationCheck( + buildActiveSession({ id: session.id, revokedAt: new Date() }), + ); + + await expect(service.resolveSession('sess_token')).rejects.toThrow( + 'Session is invalid or has expired.', + ); + expect(cacheStorageService.del).toHaveBeenCalled(); + }); + }); + + describe('revokeSessionByToken', () => { + it('should revoke the session, drop the cache entry and emit an event', async () => { + const session = buildActiveSession(); + + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + + const wasRevoked = await service.revokeSessionByToken( + 'sess_token', + UserSessionRevokedReason.UserSignOut, + ); + + expect(wasRevoked).toBe(true); + expect(cacheStorageService.del).toHaveBeenCalledWith(session.tokenHash); + expect(insertWorkspaceEvent).toHaveBeenCalledWith( + 'AuthSession', + expect.objectContaining({ action: 'user_signed_out' }), + ); + }); + + it('should be a no-op for an unknown token', async () => { + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(null); + + const wasRevoked = await service.revokeSessionByToken( + 'sess_unknown', + UserSessionRevokedReason.UserSignOut, + ); + + expect(wasRevoked).toBe(false); + }); + }); + + describe('revokeSessionByIdForUser', () => { + it('should scope the lookup to the requesting user', async () => { + const sessionId = randomUUID(); + const userId = randomUUID(); + const findOneBySpy = jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.revokeSessionByIdForUser({ + sessionId, + userId, + reason: UserSessionRevokedReason.UserRevoked, + }), + ).rejects.toThrow('Session not found'); + + expect(findOneBySpy).toHaveBeenCalledWith({ id: sessionId, userId }); + }); + + it('should revoke a session the user owns and drop its cache entry', async () => { + const userId = randomUUID(); + const session = buildActiveSession({ userId, tokenHash: 'owned-hash' }); + + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session); + jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + + const wasRevoked = await service.revokeSessionByIdForUser({ + sessionId: session.id, + userId, + reason: UserSessionRevokedReason.UserRevoked, + }); + + expect(wasRevoked).toBe(true); + expect(cacheStorageService.del).toHaveBeenCalledWith('owned-hash'); + expect(insertWorkspaceEvent).toHaveBeenCalledWith( + 'AuthSession', + expect.objectContaining({ action: 'session_revoked' }), + ); + }); + }); + + describe('revokeAllSessionsForUser', () => { + const mockRevokingQueryBuilder = (revokedSessions: UserSessionEntity[]) => { + const queryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + raw: revokedSessions.map( + ({ id, tokenHash, userId, workspaceId }) => ({ + id, + tokenHash, + userId, + workspaceId, + }), + ), + }), + }; + + jest + .spyOn(userSessionRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as never); + + return queryBuilder; + }; + + it('should revoke every active session except the excluded one', async () => { + const userId = randomUUID(); + const exceptSessionId = randomUUID(); + const sessions = [ + buildActiveSession({ userId, tokenHash: 'hash-1' }), + buildActiveSession({ userId, tokenHash: 'hash-2' }), + ]; + + const queryBuilder = mockRevokingQueryBuilder(sessions); + + const revokedCount = await service.revokeAllSessionsForUser({ + userId, + exceptSessionId, + reason: UserSessionRevokedReason.PasswordChanged, + }); + + expect(revokedCount).toBe(2); + expect(queryBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ + revokedReason: UserSessionRevokedReason.PasswordChanged, + }), + ); + expect(queryBuilder.where).toHaveBeenCalledWith(expect.any(String), { + userId, + }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith(expect.any(String), { + exceptSessionId, + }); + expect(cacheStorageService.mdel).toHaveBeenCalledWith([ + 'hash-1', + 'hash-2', + ]); + }); + + it('should scope the update to the user rather than to pre-read ids', async () => { + const userId = randomUUID(); + const queryBuilder = mockRevokingQueryBuilder([ + buildActiveSession({ userId, tokenHash: 'hash' }), + ]); + + const findSpy = jest.spyOn(userSessionRepository, 'find'); + + await service.revokeAllSessionsForUser({ + userId, + reason: UserSessionRevokedReason.PasswordChanged, + }); + + expect(findSpy).not.toHaveBeenCalled(); + expect(queryBuilder.andWhere).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ exceptSessionId: expect.anything() }), + ); + }); + + it('should report nothing revoked when no session matched', async () => { + mockRevokingQueryBuilder([]); + + const revokedCount = await service.revokeAllSessionsForUser({ + userId: randomUUID(), + reason: UserSessionRevokedReason.PasswordChanged, + }); + + expect(revokedCount).toBe(0); + expect(insertWorkspaceEvent).not.toHaveBeenCalled(); + expect(cacheStorageService.mdel).not.toHaveBeenCalled(); + }); + }); + + describe('issueSessionForTokenPair', () => { + const buildTokenPair = () => ({ + accessOrWorkspaceAgnosticToken: { + token: 'jwt-access-token', + expiresAt: new Date(), + }, + refreshToken: { token: 'jwt-refresh-token', expiresAt: new Date() }, + }); + + const buildRequest = (cookieHeader?: string, origin?: string) => + ({ + headers: { + ...(cookieHeader === undefined ? {} : { cookie: cookieHeader }), + ...(origin === undefined ? {} : { origin }), + 'user-agent': 'jest', + }, + protocol: 'http', + get: () => 'crm.example.com', + ip: '127.0.0.1', + res: { cookie: jest.fn(), clearCookie: jest.fn() }, + }) as never; + + const mockAccessPayload = () => { + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({ + sub: 'user-id', + userId: 'user-id', + workspaceId: 'workspace-id', + userWorkspaceId: 'user-workspace-id', + authProvider: AuthProviderEnum.Password, + type: JwtTokenTypeEnum.ACCESS, + }); + }; + + beforeEach(() => { + mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = true; + }); + + it('should be a no-op when cookie sessions are disabled', async () => { + mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = false; + + const createSessionSpy = jest.spyOn(service, 'createSession'); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest(), + origin: 'sign_in', + }); + + expect(createSessionSpy).not.toHaveBeenCalled(); + }); + + it('should mint a session and set the cookie on sign-in', async () => { + mockAccessPayload(); + + const session = buildActiveSession(); + const request = buildRequest(); + + jest + .spyOn(service, 'createSession') + .mockResolvedValue({ sessionToken: 'sess_new', session }); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request, + origin: 'sign_in', + }); + + expect(service.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-id', + workspaceId: 'workspace-id', + userWorkspaceId: 'user-workspace-id', + origin: 'sign_in', + userAgent: 'jest', + ipAddress: '127.0.0.1', + }), + ); + expect( + (request as { res: { cookie: jest.Mock } }).res.cookie, + ).toHaveBeenCalled(); + }); + + it('should not set the cookie for a disallowed origin', async () => { + mockAccessPayload(); + + const createSessionSpy = jest.spyOn(service, 'createSession'); + const request = buildRequest(undefined, 'https://evil.example.org'); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request, + origin: 'sign_in', + }); + + expect(createSessionSpy).not.toHaveBeenCalled(); + expect( + (request as { res: { cookie: jest.Mock } }).res.cookie, + ).not.toHaveBeenCalled(); + }); + + it('should set the cookie for the origin the request arrived on', async () => { + mockAccessPayload(); + + jest.spyOn(service, 'createSession').mockResolvedValue({ + sessionToken: 'sess_new', + session: buildActiveSession(), + }); + + const request = buildRequest(undefined, 'http://crm.example.com'); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request, + origin: 'sign_in', + }); + + expect( + (request as { res: { cookie: jest.Mock } }).res.cookie, + ).toHaveBeenCalled(); + }); + + it('should supersede the presented session on sign-in', async () => { + mockAccessPayload(); + + const presentedSession = buildActiveSession(); + + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue(presentedSession); + jest + .spyOn(userSessionRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + jest.spyOn(service, 'createSession').mockResolvedValue({ + sessionToken: 'sess_new', + session: buildActiveSession(), + }); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest('twenty-session=sess_old'), + origin: 'sign_in', + }); + + expect(userSessionRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ id: presentedSession.id }), + expect.objectContaining({ + revokedReason: UserSessionRevokedReason.Superseded, + }), + ); + expect(service.createSession).toHaveBeenCalled(); + }); + + it('should not mint a new session on renewal when a valid one is presented', async () => { + mockAccessPayload(); + + const presentedSession = buildActiveSession({ + userId: 'user-id', + workspaceId: 'workspace-id', + userWorkspaceId: 'user-workspace-id', + }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue(presentedSession); + jest + .spyOn(userSessionRepository, 'findOne') + .mockResolvedValue(presentedSession); + const createSessionSpy = jest.spyOn(service, 'createSession'); + const revokeSpy = jest.spyOn(service, 'revokeSessionByToken'); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest('twenty-session=sess_current'), + origin: 'renewal_bridge', + }); + + expect(createSessionSpy).not.toHaveBeenCalled(); + expect(revokeSpy).not.toHaveBeenCalled(); + }); + + it('should supersede a presented session scoped to another workspace', async () => { + mockAccessPayload(); + + const presentedSession = buildActiveSession({ + userId: 'user-id', + workspaceId: 'another-workspace-id', + userWorkspaceId: 'another-user-workspace-id', + }); + + cacheStorageService.get.mockResolvedValue(undefined); + jest + .spyOn(userSessionRepository, 'findOneBy') + .mockResolvedValue(presentedSession); + jest + .spyOn(userSessionRepository, 'findOne') + .mockResolvedValue(presentedSession); + jest + .spyOn(service, 'revokeSessionByToken') + .mockResolvedValue(true as never); + const createSessionSpy = jest + .spyOn(service, 'createSession') + .mockResolvedValue({ + sessionToken: 'sess_new', + session: buildActiveSession(), + } as never); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest('twenty-session=sess_current'), + origin: 'renewal_bridge', + }); + + expect(service.revokeSessionByToken).toHaveBeenCalled(); + expect(createSessionSpy).toHaveBeenCalled(); + }); + + it('should mint a session on renewal when the presented one is invalid', async () => { + mockAccessPayload(); + + cacheStorageService.get.mockResolvedValue(undefined); + jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(service, 'createSession').mockResolvedValue({ + sessionToken: 'sess_new', + session: buildActiveSession(), + }); + + await service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest('twenty-session=sess_stale'), + origin: 'renewal_bridge', + }); + + expect(service.createSession).toHaveBeenCalledWith( + expect.objectContaining({ origin: 'renewal_bridge' }), + ); + }); + + it('should never throw when session creation fails', async () => { + mockAccessPayload(); + + jest + .spyOn(service, 'createSession') + .mockRejectedValue(new Error('database is down')); + + await expect( + service.issueSessionForTokenPair({ + tokenPair: buildTokenPair(), + request: buildRequest(), + origin: 'sign_in', + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('signOut', () => { + it('should revoke the presented refresh token by jti', async () => { + const refreshTokenId = randomUUID(); + + jest + .spyOn(jwtWrapperService, 'verifyJwtToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({ + type: JwtTokenTypeEnum.REFRESH, + jti: refreshTokenId, + }); + const updateSpy = jest + .spyOn(appTokenRepository, 'update') + .mockResolvedValue({ affected: 1 } as never); + + await service.signOut({ refreshToken: 'refresh-token' }); + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: refreshTokenId }), + expect.objectContaining({ revokedAt: expect.any(Date) }), + ); + }); + + it('should swallow invalid refresh tokens', async () => { + jest + .spyOn(jwtWrapperService, 'verifyJwtToken') + .mockRejectedValue(new Error('expired')); + const updateSpy = jest.spyOn(appTokenRepository, 'update'); + + await expect( + service.signOut({ refreshToken: 'expired-token' }), + ).resolves.toBeUndefined(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts new file mode 100644 index 0000000000..fd5950048d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts @@ -0,0 +1,753 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { addMilliseconds } from 'date-fns'; +import { type Request } from 'express'; +import ms from 'ms'; +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, MoreThan, Repository } from 'typeorm'; + +import { + AppTokenEntity, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { type AuthTokenPair } from 'src/engine/core-modules/auth/dto/auth-token-pair.dto'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type'; +import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; +import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; +import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; +import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; +import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { AUTH_SESSION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/auth-session/auth-session'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { type CachedUserSession } from 'src/engine/core-modules/user-session/types/cached-user-session.type'; +import { type CreateUserSessionInput } from 'src/engine/core-modules/user-session/types/create-user-session-input.type'; +import { type UserSessionCreationOrigin } from 'src/engine/core-modules/user-session/types/user-session-creation-origin.type'; +import { isRequestOriginAllowed } from 'src/engine/core-modules/user-session/utils/is-request-origin-allowed.util'; +import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +const USER_SESSION_CACHE_TTL_MS = 60 * 1000; +const USER_SESSION_MAX_TOUCH_INTERVAL_MS = 5 * 60 * 1000; + +const IMPERSONATION_SESSION_LIFETIME = '1d'; + +const buildInvalidSessionException = () => + new AuthException( + 'Session is invalid or has expired.', + AuthExceptionCode.UNAUTHENTICATED, + ); + +@Injectable() +export class UserSessionService { + private readonly logger = new Logger(UserSessionService.name); + + constructor( + @InjectRepository(UserSessionEntity) + private readonly userSessionRepository: Repository, + @InjectRepository(AppTokenEntity) + private readonly appTokenRepository: Repository, + @InjectCacheStorage(CacheStorageNamespace.EngineAuthSession) + private readonly cacheStorageService: CacheStorageService, + private readonly twentyConfigService: TwentyConfigService, + private readonly jwtWrapperService: JwtWrapperService, + private readonly eventLogEmitterService: EventLogEmitterService, + private readonly userSessionCookieService: UserSessionCookieService, + ) {} + + // Best effort by design: while token pairs remain the primary credential, a + // session failure must never break an otherwise successful sign-in. + async issueSessionForTokenPair({ + tokenPair, + request, + origin, + }: { + tokenPair: AuthTokenPair; + request: Request; + origin: UserSessionCreationOrigin; + }): Promise { + if (!this.twentyConfigService.get('AUTH_COOKIE_SESSIONS_ENABLED')) { + return; + } + + const response = request.res; + + if (!isDefined(response)) { + return; + } + + // Cannot live in the CSRF middleware, which cannot know a request is about + // to issue a cookie. + if (!this.isRequestAllowedToReceiveSessionCookie(request)) { + this.logger.warn( + `Refused to issue a session cookie to origin ${request.headers.origin}`, + ); + + return; + } + + try { + const sessionInput = this.buildCreateSessionInputFromTokenPair( + tokenPair, + request, + origin, + ); + + if (!isDefined(sessionInput)) { + return; + } + + const presentedSessionToken = + this.userSessionCookieService.extractSessionTokenFromRequest(request); + + if (isDefined(presentedSessionToken)) { + if (origin === 'renewal_bridge') { + try { + const { payload: presentedPayload } = await this.resolveSession( + presentedSessionToken, + ); + + if ( + this.isSessionScopeMatchingRenewal(presentedPayload, sessionInput) + ) { + return; + } + + await this.revokeSessionByToken( + presentedSessionToken, + UserSessionRevokedReason.Superseded, + ); + } catch (error) { + if ( + !(error instanceof AuthException) || + error.code !== AuthExceptionCode.UNAUTHENTICATED + ) { + throw error; + } + } + } else if (sessionInput.isImpersonating === true) { + // The one sign-in that must not revoke what it replaces: parking the + // impersonator's session lets stopImpersonation hand back the credential + // they already held. + this.userSessionCookieService.attachImpersonatorSessionTokenToResponse( + response, + presentedSessionToken, + ); + } else { + await this.revokeSessionByToken( + presentedSessionToken, + UserSessionRevokedReason.Superseded, + ); + } + } + + const { sessionToken, session } = await this.createSession(sessionInput); + + this.userSessionCookieService.attachSessionTokenToResponse( + response, + sessionToken, + session.expiresAt, + ); + } catch (error) { + // Sign-in only: the presented cookie may be the previous account's. On + // renewal it is this user's own live session, which a transient failure + // must not kill. + if (origin === 'sign_in') { + try { + this.userSessionCookieService.clearSessionCookie(response); + } catch {} + } + + this.logger.error( + `Failed to issue a session alongside the token pair: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + // No Origin means no browser to plant a cookie in, so scripted sign-ins keep + // working. Browsers always send one on the unsafe requests these arrive as. + private isRequestAllowedToReceiveSessionCookie(request: Request): boolean { + const origin = request.headers.origin; + + if (!isNonEmptyString(origin)) { + return true; + } + + return isRequestOriginAllowed({ + origin, + request, + twentyConfigService: this.twentyConfigService, + }); + } + + private isSessionScopeMatchingRenewal( + presentedPayload: AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload, + sessionInput: CreateUserSessionInput, + ): boolean { + if (presentedPayload.type !== JwtTokenTypeEnum.ACCESS) { + return ( + presentedPayload.userId === sessionInput.userId && + !isDefined(sessionInput.workspaceId) && + sessionInput.isImpersonating !== true + ); + } + + return ( + presentedPayload.userId === sessionInput.userId && + (presentedPayload.workspaceId ?? null) === + (sessionInput.workspaceId ?? null) && + (presentedPayload.isImpersonating === true) === + (sessionInput.isImpersonating === true) && + (presentedPayload.userWorkspaceId ?? null) === + (sessionInput.userWorkspaceId ?? null) && + (presentedPayload.impersonatorUserWorkspaceId ?? null) === + (sessionInput.impersonatorUserWorkspaceId ?? null) && + (presentedPayload.impersonatedUserWorkspaceId ?? null) === + (sessionInput.impersonatedUserWorkspaceId ?? null) + ); + } + + private buildCreateSessionInputFromTokenPair( + tokenPair: AuthTokenPair, + request: Request, + origin: UserSessionCreationOrigin, + ): CreateUserSessionInput | undefined { + const payload = this.jwtWrapperService.decode< + AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload + >(tokenPair.accessOrWorkspaceAgnosticToken.token, { json: true }); + + if (!isDefined(payload)) { + return undefined; + } + + const requestMetadata = { + userAgent: request.headers['user-agent'] ?? null, + ipAddress: request.ip ?? null, + }; + + if (payload.type === JwtTokenTypeEnum.ACCESS) { + return { + userId: payload.userId ?? payload.sub, + workspaceId: payload.workspaceId, + userWorkspaceId: payload.userWorkspaceId, + authProvider: payload.authProvider, + isImpersonating: payload.isImpersonating === true, + impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId, + impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId, + origin, + ...requestMetadata, + }; + } + + if (payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) { + return { + userId: payload.userId ?? payload.sub, + authProvider: payload.authProvider, + origin, + ...requestMetadata, + }; + } + + return undefined; + } + + async createSession(input: CreateUserSessionInput): Promise<{ + sessionToken: string; + session: UserSessionEntity; + }> { + if (isDefined(input.workspaceId) && !isDefined(input.userWorkspaceId)) { + throw new AuthException( + 'Cannot create a workspace session without a user workspace', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!isDefined(input.workspaceId) && isDefined(input.userWorkspaceId)) { + throw new AuthException( + 'Cannot create a workspace-agnostic session for a user workspace', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if ( + input.isImpersonating === true && + (!isDefined(input.workspaceId) || + !isDefined(input.impersonatorUserWorkspaceId) || + !isDefined(input.impersonatedUserWorkspaceId)) + ) { + throw new AuthException( + 'Cannot create an impersonation session without a workspace and both user workspaces', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const lifetime = + input.isImpersonating === true + ? IMPERSONATION_SESSION_LIFETIME + : this.twentyConfigService.get('SESSION_ABSOLUTE_LIFETIME'); + + const now = new Date(); + const sessionToken = generateUserSessionToken(); + + const session = await this.userSessionRepository.save( + this.userSessionRepository.create({ + tokenHash: hashUserSessionToken(sessionToken), + userId: input.userId, + workspaceId: input.workspaceId ?? null, + userWorkspaceId: input.userWorkspaceId ?? null, + authProvider: input.authProvider, + isImpersonating: input.isImpersonating === true, + impersonatorUserWorkspaceId: + input.isImpersonating === true + ? (input.impersonatorUserWorkspaceId ?? null) + : null, + impersonatedUserWorkspaceId: + input.isImpersonating === true + ? (input.impersonatedUserWorkspaceId ?? null) + : null, + userAgent: input.userAgent ?? null, + ipAddress: input.ipAddress ?? null, + expiresAt: addMilliseconds(now, ms(lifetime)), + lastActiveAt: now, + }), + ); + + if (input.origin === 'sign_in' && isDefined(session.workspaceId)) { + this.emitAuthSessionEvent(session, 'user_signed_in'); + } + + return { sessionToken, session }; + } + + async resolveSession(sessionToken: string): Promise<{ + payload: AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload; + authenticatedAt: Date; + expiresAt: Date; + }> { + const tokenHash = hashUserSessionToken(sessionToken); + + const cachedSession = + await this.cacheStorageService.get(tokenHash); + + if (isDefined(cachedSession)) { + if (!this.isCachedSessionActive(cachedSession)) { + await this.cacheStorageService.del(tokenHash); + + throw buildInvalidSessionException(); + } + + await this.touchSessionIfDue(tokenHash, cachedSession); + + return this.toResolvedSession(cachedSession); + } + + const session = await this.userSessionRepository.findOneBy({ tokenHash }); + + if (!isDefined(session) || !this.isSessionActive(session)) { + throw buildInvalidSessionException(); + } + + const refreshedCachedSession = this.toCachedSession(session); + + const wasCachedByTouch = await this.touchSessionIfDue( + tokenHash, + refreshedCachedSession, + ); + + if (!wasCachedByTouch) { + await this.cacheStorageService.set( + tokenHash, + refreshedCachedSession, + USER_SESSION_CACHE_TTL_MS, + ); + await this.assertNotRevokedAfterCaching(session.id, tokenHash); + } + + return this.toResolvedSession(refreshedCachedSession); + } + + private toResolvedSession(cachedSession: CachedUserSession): { + payload: AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload; + authenticatedAt: Date; + expiresAt: Date; + } { + return { + payload: this.buildPayloadFromCachedSession(cachedSession), + authenticatedAt: new Date(cachedSession.authenticatedAt), + expiresAt: new Date(cachedSession.expiresAt), + }; + } + + // A revocation racing the write above may have had its cache delete land + // first, resurrecting the session for a full TTL. Re-checking afterwards + // closes it: anything committing later deletes what we just wrote. + private async assertNotRevokedAfterCaching( + sessionId: string, + tokenHash: string, + ): Promise { + const session = await this.userSessionRepository.findOne({ + where: { id: sessionId }, + select: { id: true, revokedAt: true }, + }); + + if (!isDefined(session) || isDefined(session.revokedAt)) { + await this.cacheStorageService.del(tokenHash); + + throw buildInvalidSessionException(); + } + } + + async findSessionByToken( + sessionToken: string, + ): Promise { + return this.userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(sessionToken), + }); + } + + async findActiveSessionsForUser( + userId: string, + ): Promise { + const now = new Date(); + const idleTimeoutMs = this.getIdleTimeoutMs(); + + return this.userSessionRepository.find({ + where: { + userId, + revokedAt: IsNull(), + expiresAt: MoreThan(now), + lastActiveAt: MoreThan(addMilliseconds(now, -idleTimeoutMs)), + }, + order: { lastActiveAt: 'DESC' }, + }); + } + + async revokeSessionByToken( + sessionToken: string, + reason: UserSessionRevokedReason, + ): Promise { + const session = await this.findSessionByToken(sessionToken); + + if (!isDefined(session)) { + return false; + } + + return await this.revokeSessionEntity(session, reason); + } + + async revokeSessionByIdForUser({ + sessionId, + userId, + reason, + }: { + sessionId: string; + userId: string; + reason: UserSessionRevokedReason; + }): Promise { + const session = await this.userSessionRepository.findOneBy({ + id: sessionId, + userId, + }); + + if (!isDefined(session)) { + throw new AuthException( + 'Session not found', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return await this.revokeSessionEntity(session, reason); + } + + async revokeAllSessionsForUser({ + userId, + reason, + exceptSessionId, + }: { + userId: string; + reason: UserSessionRevokedReason; + exceptSessionId?: string; + }): Promise { + // Applying the predicate in the UPDATE rather than to ids read beforehand + // narrows but does not close the window: a sign-in committing after this + // statement's snapshot survives, which would need a generation counter. + const revokingQuery = this.userSessionRepository + .createQueryBuilder() + .update(UserSessionEntity) + .set({ revokedAt: new Date(), revokedReason: reason }) + .where('"userId" = :userId', { userId }) + .andWhere('"revokedAt" IS NULL'); + + if (isDefined(exceptSessionId)) { + revokingQuery.andWhere('"id" != :exceptSessionId', { exceptSessionId }); + } + + const { raw } = await revokingQuery + .returning(['id', 'tokenHash', 'userId', 'workspaceId', 'authProvider']) + .execute(); + + const revokedSessions = raw as Pick< + UserSessionEntity, + 'id' | 'tokenHash' | 'userId' | 'workspaceId' | 'authProvider' + >[]; + + if (revokedSessions.length === 0) { + return 0; + } + + for (const revokedSession of revokedSessions) { + this.emitAuthSessionEvent( + revokedSession as UserSessionEntity, + 'session_revoked', + ); + } + + await this.cacheStorageService.mdel( + revokedSessions.map((revokedSession) => revokedSession.tokenHash), + ); + + return revokedSessions.length; + } + + async signOut({ + sessionToken, + refreshToken, + }: { + sessionToken?: string; + refreshToken?: string; + }): Promise { + try { + if (isNonEmptyString(sessionToken)) { + await this.revokeSessionByToken( + sessionToken, + UserSessionRevokedReason.UserSignOut, + ); + } + } finally { + if (isNonEmptyString(refreshToken)) { + await this.revokePresentedRefreshToken(refreshToken); + } + } + } + + private async revokePresentedRefreshToken( + refreshToken: string, + ): Promise { + let payload: RefreshTokenJwtPayload | undefined; + + try { + await this.jwtWrapperService.verifyJwtToken(refreshToken); + + payload = this.jwtWrapperService.decode( + refreshToken, + { json: true }, + ); + } catch { + return; + } + + if ( + payload?.type !== JwtTokenTypeEnum.REFRESH || + !isNonEmptyString(payload.jti) + ) { + return; + } + + // Outside the catch: a storage failure here leaves a usable refresh token + // behind, so it must surface rather than report success. + await this.appTokenRepository.update( + { + id: payload.jti, + type: AppTokenType.RefreshToken, + revokedAt: IsNull(), + }, + { revokedAt: new Date() }, + ); + } + + private async revokeSessionEntity( + session: UserSessionEntity, + reason: UserSessionRevokedReason, + ): Promise { + const { affected } = await this.userSessionRepository.update( + { id: session.id, revokedAt: IsNull() }, + { revokedAt: new Date(), revokedReason: reason }, + ); + + await this.cacheStorageService.del(session.tokenHash); + + const wasRevoked = affected === 1; + + if (wasRevoked) { + this.emitAuthSessionEvent( + session, + reason === UserSessionRevokedReason.UserSignOut + ? 'user_signed_out' + : 'session_revoked', + ); + } + + return wasRevoked; + } + + private isSessionActive(session: UserSessionEntity): boolean { + return ( + !isDefined(session.revokedAt) && + this.isCachedSessionActive(this.toCachedSession(session)) + ); + } + + private isCachedSessionActive(cachedSession: CachedUserSession): boolean { + const now = Date.now(); + + return ( + new Date(cachedSession.expiresAt).getTime() > now && + new Date(cachedSession.lastActiveAt).getTime() + this.getIdleTimeoutMs() > + now + ); + } + + private getIdleTimeoutMs(): number { + return ms(this.twentyConfigService.get('SESSION_IDLE_TIMEOUT')); + } + + // Must stay well inside the idle timeout, or a continuously active user goes + // idle between two touches. + private getTouchIntervalMs(): number { + return Math.min( + USER_SESSION_MAX_TOUCH_INTERVAL_MS, + Math.floor(this.getIdleTimeoutMs() / 2), + ); + } + + private async touchSessionIfDue( + tokenHash: string, + cachedSession: CachedUserSession, + ): Promise { + const now = new Date(); + + if ( + now.getTime() - new Date(cachedSession.lastActiveAt).getTime() < + this.getTouchIntervalMs() + ) { + return false; + } + + let affected: number | null | undefined; + + try { + ({ affected } = await this.userSessionRepository.update( + { id: cachedSession.sessionId, revokedAt: IsNull() }, + { lastActiveAt: now }, + )); + } catch (error) { + this.logger.warn( + `Failed to touch session ${cachedSession.sessionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + return false; + } + + if (affected === 0) { + await this.cacheStorageService.del(tokenHash); + + throw buildInvalidSessionException(); + } + + cachedSession.lastActiveAt = now.toISOString(); + + await this.cacheStorageService.set( + tokenHash, + cachedSession, + USER_SESSION_CACHE_TTL_MS, + ); + await this.assertNotRevokedAfterCaching(cachedSession.sessionId, tokenHash); + + return true; + } + + private toCachedSession(session: UserSessionEntity): CachedUserSession { + return { + sessionId: session.id, + userId: session.userId, + workspaceId: session.workspaceId, + userWorkspaceId: session.userWorkspaceId, + authProvider: session.authProvider, + isImpersonating: session.isImpersonating, + impersonatorUserWorkspaceId: session.impersonatorUserWorkspaceId, + impersonatedUserWorkspaceId: session.impersonatedUserWorkspaceId, + expiresAt: session.expiresAt.toISOString(), + lastActiveAt: session.lastActiveAt.toISOString(), + authenticatedAt: session.createdAt.toISOString(), + }; + } + + private buildPayloadFromCachedSession( + cachedSession: CachedUserSession, + ): AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload { + if (!isDefined(cachedSession.workspaceId)) { + return { + sub: cachedSession.userId, + userId: cachedSession.userId, + authProvider: cachedSession.authProvider, + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }; + } + + if (!isDefined(cachedSession.userWorkspaceId)) { + throw buildInvalidSessionException(); + } + + return { + sub: cachedSession.userId, + userId: cachedSession.userId, + workspaceId: cachedSession.workspaceId, + userWorkspaceId: cachedSession.userWorkspaceId, + authProvider: cachedSession.authProvider, + type: JwtTokenTypeEnum.ACCESS, + isImpersonating: cachedSession.isImpersonating === true, + impersonatorUserWorkspaceId: + cachedSession.isImpersonating === true + ? (cachedSession.impersonatorUserWorkspaceId ?? undefined) + : undefined, + impersonatedUserWorkspaceId: + cachedSession.isImpersonating === true + ? (cachedSession.impersonatedUserWorkspaceId ?? undefined) + : undefined, + }; + } + + private emitAuthSessionEvent( + session: UserSessionEntity, + action: 'user_signed_in' | 'user_signed_out' | 'session_revoked', + ): void { + if (!isDefined(session.workspaceId)) { + return; + } + + const eventLogContext = this.eventLogEmitterService.createContext({ + workspaceId: session.workspaceId, + userId: session.userId, + }); + + void eventLogContext.insertWorkspaceEvent(AUTH_SESSION_EVENT, { + action, + message: `sessionId=${session.id}; authProvider=${session.authProvider}`, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/types/cached-user-session.type.ts b/packages/twenty-server/src/engine/core-modules/user-session/types/cached-user-session.type.ts new file mode 100644 index 0000000000..e6144ca525 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/types/cached-user-session.type.ts @@ -0,0 +1,17 @@ +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +// Cached under the token hash in Redis; dates as ISO strings so the payload +// survives JSON serialization. +export type CachedUserSession = { + sessionId: string; + userId: string; + workspaceId: string | null; + userWorkspaceId: string | null; + authProvider: AuthProviderEnum; + isImpersonating: boolean; + impersonatorUserWorkspaceId: string | null; + impersonatedUserWorkspaceId: string | null; + expiresAt: string; + lastActiveAt: string; + authenticatedAt: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/types/create-user-session-input.type.ts b/packages/twenty-server/src/engine/core-modules/user-session/types/create-user-session-input.type.ts new file mode 100644 index 0000000000..2096111e5a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/types/create-user-session-input.type.ts @@ -0,0 +1,15 @@ +import { type UserSessionCreationOrigin } from 'src/engine/core-modules/user-session/types/user-session-creation-origin.type'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +export type CreateUserSessionInput = { + userId: string; + workspaceId?: string | null; + userWorkspaceId?: string | null; + authProvider: AuthProviderEnum; + isImpersonating?: boolean; + impersonatorUserWorkspaceId?: string | null; + impersonatedUserWorkspaceId?: string | null; + userAgent?: string | null; + ipAddress?: string | null; + origin: UserSessionCreationOrigin; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-creation-origin.type.ts b/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-creation-origin.type.ts new file mode 100644 index 0000000000..e831348b8c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-creation-origin.type.ts @@ -0,0 +1 @@ +export type UserSessionCreationOrigin = 'sign_in' | 'renewal_bridge'; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-revoked-reason.type.ts b/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-revoked-reason.type.ts new file mode 100644 index 0000000000..ec69989dba --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/types/user-session-revoked-reason.type.ts @@ -0,0 +1,7 @@ +export enum UserSessionRevokedReason { + UserSignOut = 'USER_SIGN_OUT', + UserRevoked = 'USER_REVOKED', + Superseded = 'SUPERSEDED', + PasswordChanged = 'PASSWORD_CHANGED', + ImpersonationEnded = 'IMPERSONATION_ENDED', +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/user-session.entity.ts b/packages/twenty-server/src/engine/core-modules/user-session/user-session.entity.ts new file mode 100644 index 0000000000..4d3421b229 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/user-session.entity.ts @@ -0,0 +1,106 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Entity({ name: 'userSession', schema: 'core' }) +export class UserSessionEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index('IDX_USER_SESSION_TOKEN_HASH_UNIQUE', { unique: true }) + @Column({ type: 'text' }) + tokenHash: string; + + @ManyToOne(() => UserEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userId', + foreignKeyConstraintName: 'FK_USER_SESSION_USER_ID', + }) + user: Relation; + + @Index('IDX_USER_SESSION_USER_ID') + @Column({ type: 'uuid' }) + userId: string; + + @ManyToOne(() => WorkspaceEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'workspaceId', + foreignKeyConstraintName: 'FK_USER_SESSION_WORKSPACE_ID', + }) + workspace: Relation | null; + + @Index('IDX_USER_SESSION_WORKSPACE_ID') + @Column({ type: 'uuid', nullable: true }) + workspaceId: string | null; + + // Removing someone from a workspace deletes the membership, not the + // workspace, so without the cascade a dead session lingers until expiry. + @ManyToOne(() => UserWorkspaceEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userWorkspaceId', + foreignKeyConstraintName: 'FK_USER_SESSION_USER_WORKSPACE_ID', + }) + userWorkspace: Relation | null; + + @Index('IDX_USER_SESSION_USER_WORKSPACE_ID') + @Column({ type: 'uuid', nullable: true }) + userWorkspaceId: string | null; + + @Column({ type: 'text' }) + authProvider: AuthProviderEnum; + + @Column({ type: 'boolean', default: false }) + isImpersonating: boolean; + + @Column({ type: 'uuid', nullable: true }) + impersonatorUserWorkspaceId: string | null; + + @Column({ type: 'uuid', nullable: true }) + impersonatedUserWorkspaceId: string | null; + + @Column({ type: 'text', nullable: true }) + userAgent: string | null; + + @Column({ type: 'text', nullable: true }) + ipAddress: string | null; + + @Index('IDX_USER_SESSION_EXPIRES_AT') + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'timestamptz' }) + lastActiveAt: Date; + + @Index('IDX_USER_SESSION_REVOKED_AT') + @Column({ type: 'timestamptz', nullable: true }) + revokedAt: Date | null; + + @Column({ type: 'text', nullable: true }) + revokedReason: UserSessionRevokedReason | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/user-session.module.ts b/packages/twenty-server/src/engine/core-modules/user-session/user-session.module.ts new file mode 100644 index 0000000000..a73c59b098 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/user-session.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { UserSessionCleanupCronCommand } from 'src/engine/core-modules/user-session/crons/commands/user-session-cleanup.cron.command'; +import { UserSessionCleanupCronJob } from 'src/engine/core-modules/user-session/crons/jobs/user-session-cleanup.cron.job'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionResolver } from 'src/engine/core-modules/user-session/user-session.resolver'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([UserSessionEntity, AppTokenEntity]), + JwtModule, + EventLogEmitterModule, + ], + providers: [ + UserSessionService, + UserSessionCookieService, + UserSessionResolver, + UserSessionCleanupCronJob, + UserSessionCleanupCronCommand, + ], + exports: [ + UserSessionService, + UserSessionCookieService, + UserSessionCleanupCronCommand, + ], +}) +export class UserSessionModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts new file mode 100644 index 0000000000..e25b612ab4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts @@ -0,0 +1,133 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Context, Int, Mutation, Query } from '@nestjs/graphql'; + +import { type Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { UserSessionDTO } from 'src/engine/core-modules/user-session/dtos/user-session.dto'; +import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service'; +import { type UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; + +@UsePipes(ResolverValidationPipe) +@UseFilters(AuthGraphqlApiExceptionFilter) +@MetadataResolver() +export class UserSessionResolver { + constructor( + private readonly userSessionService: UserSessionService, + private readonly userSessionCookieService: UserSessionCookieService, + ) {} + + @Query(() => [UserSessionDTO]) + @UseGuards(UserAuthGuard, NoPermissionGuard) + async currentUserSessions( + @AuthUser() user: AuthContextUser, + @Context() context: { req: Request }, + ): Promise { + const sessions = await this.userSessionService.findActiveSessionsForUser( + user.id, + ); + + const presentedSessionToken = + this.userSessionCookieService.extractSessionTokenFromRequest(context.req); + const presentedTokenHash = isDefined(presentedSessionToken) + ? hashUserSessionToken(presentedSessionToken) + : undefined; + + return sessions.map((session) => + this.toUserSessionDTO(session, presentedTokenHash), + ); + } + + @Mutation(() => Boolean) + @UseGuards(UserAuthGuard, NoPermissionGuard) + async revokeUserSession( + @AuthUser() user: AuthContextUser, + @Args('userSessionId', { type: () => UUIDScalarType }) + userSessionId: string, + @Context() context: { req: Request }, + ): Promise { + // Before revoking: afterwards it is no longer active and would not be found. + const currentSession = await this.resolveCurrentSession(context.req, user); + + const revoked = await this.userSessionService.revokeSessionByIdForUser({ + sessionId: userSessionId, + userId: user.id, + reason: UserSessionRevokedReason.UserRevoked, + }); + + if ( + revoked && + currentSession?.id === userSessionId && + isDefined(context.req.res) + ) { + this.userSessionCookieService.clearSessionCookie(context.req.res); + } + + return revoked; + } + + // A revoked or expired cookie must not decide which sessions survive. + private async resolveCurrentSession( + request: Request, + user: AuthContextUser, + ): Promise { + const presentedSessionToken = + this.userSessionCookieService.extractSessionTokenFromRequest(request); + + if (!isDefined(presentedSessionToken)) { + return undefined; + } + + const activeSessions = + await this.userSessionService.findActiveSessionsForUser(user.id); + const presentedTokenHash = hashUserSessionToken(presentedSessionToken); + + return activeSessions.find( + (session) => session.tokenHash === presentedTokenHash, + ); + } + + @Mutation(() => Int) + @UseGuards(UserAuthGuard, NoPermissionGuard) + async revokeAllOtherUserSessions( + @AuthUser() user: AuthContextUser, + @Context() context: { req: Request }, + ): Promise { + const currentSession = await this.resolveCurrentSession(context.req, user); + + return await this.userSessionService.revokeAllSessionsForUser({ + userId: user.id, + reason: UserSessionRevokedReason.UserRevoked, + exceptSessionId: currentSession?.id, + }); + } + + private toUserSessionDTO( + session: UserSessionEntity, + presentedTokenHash: string | undefined, + ): UserSessionDTO { + return { + id: session.id, + workspaceId: session.workspaceId, + authProvider: session.authProvider, + isImpersonating: session.isImpersonating, + userAgent: session.userAgent, + ipAddress: session.ipAddress, + createdAt: session.createdAt, + lastActiveAt: session.lastActiveAt, + expiresAt: session.expiresAt, + isCurrent: session.tokenHash === presentedTokenHash, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.spec.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.spec.ts new file mode 100644 index 0000000000..d21efd95f2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.spec.ts @@ -0,0 +1,68 @@ +import { type Request } from 'express'; + +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; +import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant'; +import { extractUserSessionTokenFromRequestCookie } from 'src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util'; + +const buildRequest = (cookieHeader?: string): Request => + ({ + headers: cookieHeader === undefined ? {} : { cookie: cookieHeader }, + }) as Request; + +const extractOnHttpDeployment = (cookieHeader?: string) => + extractUserSessionTokenFromRequestCookie(buildRequest(cookieHeader), { + secureCookieName: USER_SESSION_SECURE_COOKIE_NAME, + insecureCookieName: USER_SESSION_COOKIE_NAME, + allowInsecureCookieName: true, + }); + +const extractOnHttpsDeployment = (cookieHeader?: string) => + extractUserSessionTokenFromRequestCookie(buildRequest(cookieHeader), { + secureCookieName: USER_SESSION_SECURE_COOKIE_NAME, + insecureCookieName: USER_SESSION_COOKIE_NAME, + allowInsecureCookieName: false, + }); + +describe('extractUserSessionTokenFromRequestCookie', () => { + it('should return undefined without a cookie header', () => { + expect(extractOnHttpDeployment()).toBe(undefined); + }); + + it('should read the plain cookie name when the deployment cannot set Secure', () => { + expect( + extractOnHttpDeployment('foo=bar; twenty-session=sess_abc; other=1'), + ).toBe('sess_abc'); + }); + + it('should ignore the plain cookie name on a secure deployment', () => { + expect(extractOnHttpsDeployment('twenty-session=sess_tossed')).toBe( + undefined, + ); + }); + + it('should read the __Host- cookie name on a secure deployment', () => { + expect(extractOnHttpsDeployment('__Host-twenty-session=sess_abc')).toBe( + 'sess_abc', + ); + }); + + it('should prefer the __Host- cookie name', () => { + expect( + extractOnHttpDeployment( + 'twenty-session=sess_old; __Host-twenty-session=sess_new', + ), + ).toBe('sess_new'); + }); + + it('should ignore values without the session token prefix', () => { + expect(extractOnHttpDeployment('twenty-session=not-a-session-token')).toBe( + undefined, + ); + }); + + it('should ignore lookalike cookie names', () => { + expect(extractOnHttpDeployment('not-twenty-session=sess_abc')).toBe( + undefined, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.ts new file mode 100644 index 0000000000..0c4e333163 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util.ts @@ -0,0 +1,63 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { type Request } from 'express'; + +import { isUserSessionToken } from 'src/engine/core-modules/user-session/utils/is-user-session-token.util'; + +const readCookieValue = ( + cookieHeader: string, + cookieName: string, +): string | undefined => { + for (const cookiePart of cookieHeader.split(';')) { + const separatorIndex = cookiePart.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + if (cookiePart.slice(0, separatorIndex).trim() !== cookieName) { + continue; + } + + const value = cookiePart.slice(separatorIndex + 1).trim(); + + if (isNonEmptyString(value)) { + return value; + } + } + + return undefined; +}; + +// The plain name is only read on deployments that cannot set a __Host- cookie +// at all (plain http). Accepting it on an https deployment would let a +// sibling subdomain toss a session in and fixate the visitor. +export const extractUserSessionTokenFromRequestCookie = ( + request: Request, + { + secureCookieName, + insecureCookieName, + allowInsecureCookieName, + }: { + secureCookieName: string; + insecureCookieName: string; + allowInsecureCookieName: boolean; + }, +): string | undefined => { + const cookieHeader = request.headers.cookie; + + if (!isNonEmptyString(cookieHeader)) { + return undefined; + } + + const token = + readCookieValue(cookieHeader, secureCookieName) ?? + (allowInsecureCookieName + ? readCookieValue(cookieHeader, insecureCookieName) + : undefined); + + if (!isNonEmptyString(token) || !isUserSessionToken(token)) { + return undefined; + } + + return token; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/generate-user-session-token.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/generate-user-session-token.util.ts new file mode 100644 index 0000000000..559a6584bc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/generate-user-session-token.util.ts @@ -0,0 +1,7 @@ +import { randomBytes } from 'crypto'; + +import { USER_SESSION_TOKEN_PREFIX } from 'src/engine/core-modules/user-session/constants/user-session-token-prefix.constant'; + +export const generateUserSessionToken = (): string => { + return `${USER_SESSION_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/hash-user-session-token.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/hash-user-session-token.util.ts new file mode 100644 index 0000000000..c483209614 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/hash-user-session-token.util.ts @@ -0,0 +1,5 @@ +import { createHash } from 'crypto'; + +export const hashUserSessionToken = (token: string): string => { + return createHash('sha256').update(token).digest('hex'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/is-request-origin-allowed.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/is-request-origin-allowed.util.ts new file mode 100644 index 0000000000..c0786eac70 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/is-request-origin-allowed.util.ts @@ -0,0 +1,45 @@ +import { type Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { resolveAllowedCredentialedOrigins } from 'src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util'; +import { getRequestBaseUrl } from 'src/utils/get-request-base-url.util'; + +const toComparableOrigin = (value: string): string | undefined => { + try { + return new URL(value).origin.toLowerCase(); + } catch { + return undefined; + } +}; + +export const isRequestOriginAllowed = ({ + origin, + request, + twentyConfigService, +}: { + origin: string; + request: Request; + twentyConfigService: TwentyConfigService; +}): boolean => { + const normalizedOrigin = origin.toLowerCase(); + + // Compared through URL rather than as strings: browsers omit :443 and :80 + // from Origin while Host keeps whatever port the client spelled, so a genuine + // same-origin POST would otherwise 403 on the port alone. + const comparableOrigin = toComparableOrigin(normalizedOrigin); + const comparableRequestOrigin = toComparableOrigin( + getRequestBaseUrl(request), + ); + + if ( + isDefined(comparableOrigin) && + comparableOrigin === comparableRequestOrigin + ) { + return true; + } + + return resolveAllowedCredentialedOrigins(twentyConfigService).has( + normalizedOrigin, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/is-user-session-token.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/is-user-session-token.util.ts new file mode 100644 index 0000000000..8f7a9358ef --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/is-user-session-token.util.ts @@ -0,0 +1,5 @@ +import { USER_SESSION_TOKEN_PREFIX } from 'src/engine/core-modules/user-session/constants/user-session-token-prefix.constant'; + +export const isUserSessionToken = (token: string): boolean => { + return token.startsWith(USER_SESSION_TOKEN_PREFIX); +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util.ts new file mode 100644 index 0000000000..4e191cf3ed --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util.ts @@ -0,0 +1,100 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +// Opaque schemes (file:, data:) serialise to the literal "null" origin, which +// would otherwise allowlist every sandboxed document that sends Origin: null. +const toOrigin = (url: string): string | undefined => { + try { + const parsedUrl = new URL(url); + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return undefined; + } + + return parsedUrl.origin.toLowerCase(); + } catch { + return undefined; + } +}; + +// URL canonicalises [::ffff:127.0.0.1] to [::ffff:7f00:1], so only the hex +// spelling reaches here. All of 127.0.0.0/8 is loopback. +const IPV4_LOOPBACK_REGEX = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; +const IPV4_MAPPED_HEX_REGEX = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/; + +const isLoopbackHostname = (hostname: string): boolean => { + // A trailing DNS dot (localhost.) resolves the same but would not match. + const host = hostname + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') + .toLowerCase(); + + if (host === 'localhost' || host === '::1') { + return true; + } + + if (IPV4_LOOPBACK_REGEX.test(host)) { + return true; + } + + const mappedHex = IPV4_MAPPED_HEX_REGEX.exec(host); + + // The high byte of the first hextet is the first octet of the v4 address. + return mappedHex !== null && Number.parseInt(mappedHex[1], 16) >> 8 === 127; +}; + +const isLoopbackOrigin = (origin: string): boolean => { + try { + return isLoopbackHostname(new URL(origin).hostname); + } catch { + return false; + } +}; + +export const resolveAllowedCredentialedOrigins = ( + twentyConfigService: TwentyConfigService, +): Set => { + const allowedOrigins = new Set(); + + const derivedUrls = [ + twentyConfigService.get('SERVER_URL'), + twentyConfigService.get('FRONTEND_URL'), + ]; + + const explicitUrls = twentyConfigService + .get('AUTH_COOKIE_ALLOWED_ORIGINS') + .split(',') + .map((allowedOrigin) => allowedOrigin.trim()); + + // SERVER_URL defaults to http://localhost:3000, so a deployment that never + // set it would hand any local page on that port a credentialed origin. + // Explicit entries are still honoured, so dev setups keep working. + const isProduction = + twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION; + + for (const candidateUrl of [...derivedUrls, ...explicitUrls]) { + if (!isNonEmptyString(candidateUrl)) { + continue; + } + + const origin = toOrigin(candidateUrl); + + if (!isNonEmptyString(origin)) { + continue; + } + + if ( + isProduction && + isLoopbackOrigin(origin) && + !explicitUrls.includes(candidateUrl) + ) { + continue; + } + + allowedOrigins.add(origin); + } + + return allowedOrigins; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts index 9dc03a3f1b..cedf8ad224 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts @@ -102,4 +102,7 @@ export class UserWorkspaceEntity extends WorkspaceRelatedEntity { @Field(() => [TwoFactorAuthenticationMethodSummaryDTO], { nullable: true }) twoFactorAuthenticationMethodSummary?: TwoFactorAuthenticationMethodSummaryDTO[]; + + @Field(() => Boolean, { nullable: true }) + isImpersonating?: boolean; } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 2767c9a960..6c307f9feb 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { type DataSource, type Repository } from 'typeorm'; +import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; @@ -782,6 +783,158 @@ describe('UserWorkspaceService', () => { availableWorkspacesForSignUp: [{ workspace: workspace1 }], }); }); + + describe('workspaceDiscoverability', () => { + const email = 'test@example.com'; + + const buildWorkspace = ( + id: string, + workspaceDiscoverability: WorkspaceDiscoverability, + ) => + ({ + id, + displayName: id, + workspaceSSOIdentityProviders: [], + workspaceDiscoverability, + }) as unknown as WorkspaceEntity; + + const mockSources = ({ + memberships = [], + approvedAccessDomains = [], + invitations = [], + }: { + memberships?: WorkspaceEntity[]; + approvedAccessDomains?: WorkspaceEntity[]; + invitations?: WorkspaceEntity[]; + }) => { + jest.spyOn(userRepository, 'findOne').mockResolvedValue({ + email, + userWorkspaces: memberships.map((workspace) => ({ + workspaceId: workspace.id, + workspace, + })), + } as UserEntity); + + jest + .spyOn( + approvedAccessDomainService, + 'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain', + ) + .mockResolvedValue( + approvedAccessDomains.map( + (workspace) => + ({ + id: `domain-${workspace.id}`, + workspaceId: workspace.id, + workspace, + isValidated: true, + }) as unknown as ApprovedAccessDomainEntity, + ), + ); + + jest + .spyOn(workspaceInvitationService, 'findInvitationsByEmail') + .mockResolvedValue( + invitations.map( + (workspace) => + ({ + workspaceId: workspace.id, + workspace, + }) as unknown as AppTokenEntity, + ), + ); + }; + + it('should list a membership unless the workspace is hidden', async () => { + const publicWorkspace = buildWorkspace( + 'public', + WorkspaceDiscoverability.PUBLIC, + ); + const membersAndInviteesWorkspace = buildWorkspace( + 'members-and-invitees', + WorkspaceDiscoverability.MEMBERS_AND_INVITEES, + ); + + mockSources({ + memberships: [ + publicWorkspace, + membersAndInviteesWorkspace, + buildWorkspace('hidden', WorkspaceDiscoverability.HIDDEN), + ], + }); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(result.availableWorkspacesForSignIn).toEqual([ + { workspace: publicWorkspace }, + { workspace: membersAndInviteesWorkspace }, + ]); + }); + + it('should surface a workspace by email domain only when it is public', async () => { + const publicWorkspace = buildWorkspace( + 'public', + WorkspaceDiscoverability.PUBLIC, + ); + + mockSources({ + approvedAccessDomains: [ + publicWorkspace, + buildWorkspace( + 'members-and-invitees', + WorkspaceDiscoverability.MEMBERS_AND_INVITEES, + ), + buildWorkspace('hidden', WorkspaceDiscoverability.HIDDEN), + ], + }); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(result.availableWorkspacesForSignUp).toEqual([ + { workspace: publicWorkspace }, + ]); + }); + + it('should list an invitation unless the workspace is hidden', async () => { + const membersAndInviteesWorkspace = buildWorkspace( + 'members-and-invitees', + WorkspaceDiscoverability.MEMBERS_AND_INVITEES, + ); + + mockSources({ + invitations: [ + membersAndInviteesWorkspace, + buildWorkspace('hidden', WorkspaceDiscoverability.HIDDEN), + ], + }); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(result.availableWorkspacesForSignUp).toEqual([ + expect.objectContaining({ workspace: membersAndInviteesWorkspace }), + ]); + }); + + it('should keep a hidden workspace out of every source at once', async () => { + const hiddenWorkspace = buildWorkspace( + 'hidden', + WorkspaceDiscoverability.HIDDEN, + ); + + mockSources({ + memberships: [hiddenWorkspace], + approvedAccessDomains: [hiddenWorkspace], + invitations: [hiddenWorkspace], + }); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(result).toEqual({ + availableWorkspacesForSignIn: [], + availableWorkspacesForSignUp: [], + }); + }); + }); }); describe('findFirstWorkspaceByUserId', () => { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index f0af5b8e79..42f8d8d84e 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -630,6 +630,7 @@ export class UserWorkspaceService { }, user: Pick, authProvider: AuthProviderEnum, + canAutoLoginIntoWorkspaces = true, ) { const [availableWorkspacesForSignUp, availableWorkspacesForSignIn] = await Promise.all([ @@ -648,18 +649,17 @@ export class UserWorkspaceService { async ({ workspace }) => { return { ...(await this.castWorkspaceToAvailableWorkspace(workspace)), - loginToken: workspaceValidator.isAuthEnabled( - authProvider, - workspace, - ) - ? ( - await this.loginTokenService.generateLoginToken( - user.email, - workspace.id, - AuthProviderEnum.Password, - ) - ).token - : undefined, + loginToken: + canAutoLoginIntoWorkspaces && + workspaceValidator.isAuthEnabled(authProvider, workspace) + ? ( + await this.loginTokenService.generateLoginToken( + user.email, + workspace.id, + AuthProviderEnum.Password, + ) + ).token + : undefined, }; }, ), 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 0db5ee51c2..b2dfe47709 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 @@ -20,7 +20,10 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto'; -import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + type AuthContext, + type AuthContextUser, +} from 'src/engine/core-modules/auth/types/auth-context.type'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { OnboardingService, @@ -46,7 +49,10 @@ import { assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly } from 'src/engine/c import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator'; +import { AuthAuthenticatedAt } from 'src/engine/decorators/auth/auth-authenticated-at.decorator'; import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator'; +import { AuthImpersonationContext } from 'src/engine/decorators/auth/auth-impersonation-context.decorator'; +import { canCredentialAutoLoginIntoWorkspaces } from 'src/engine/core-modules/auth/utils/can-credential-auto-login-into-workspaces.util'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; @@ -125,6 +131,8 @@ export class UserResolver { async currentUser( @AuthUser() { id: userId }: AuthContextUser, @AuthWorkspace({ allowUndefined: true }) workspace: WorkspaceEntity, + @AuthImpersonationContext() + impersonationContext: AuthContext['impersonationContext'], ): Promise { const user = await this.userRepository.findOne({ where: { @@ -182,6 +190,7 @@ export class UserResolver { ...currentUserWorkspace, ...userWorkspacePermissions, twoFactorAuthenticationMethodSummary, + isImpersonating: isDefined(impersonationContext), }, currentWorkspace: refreshedWorkspace, }; @@ -597,6 +606,9 @@ export class UserResolver { async availableWorkspaces( @AuthUser() user: AuthContextUser, @AuthProvider() authProvider: AuthProviderEnum, + @AuthWorkspace({ allowUndefined: true }) + workspace: WorkspaceEntity | undefined, + @AuthAuthenticatedAt() authenticatedAt: Date | undefined, ): Promise { return this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( await this.userWorkspaceService.findAvailableWorkspacesByEmail( @@ -604,6 +616,14 @@ export class UserResolver { ), user, authProvider, + canCredentialAutoLoginIntoWorkspaces({ + isWorkspaceScopedCredential: isDefined(workspace), + authenticatedAt, + autoLoginWindow: this.twentyConfigService.get( + 'WORKSPACE_AUTO_LOGIN_WINDOW', + ), + now: new Date(), + }), ); } diff --git a/packages/twenty-server/src/engine/decorators/auth/auth-authenticated-at.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/auth-authenticated-at.decorator.ts new file mode 100644 index 0000000000..8f5311cd4c --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/auth-authenticated-at.decorator.ts @@ -0,0 +1,11 @@ +import { type ExecutionContext, createParamDecorator } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const AuthAuthenticatedAt = createParamDecorator( + (_: unknown, ctx: ExecutionContext): Date | undefined => { + const request = getRequest(ctx); + + return request.authenticatedAt; + }, +); diff --git a/packages/twenty-server/src/engine/decorators/auth/auth-impersonation-context.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/auth-impersonation-context.decorator.ts new file mode 100644 index 0000000000..6501bc4f43 --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/auth-impersonation-context.decorator.ts @@ -0,0 +1,11 @@ +import { type ExecutionContext, createParamDecorator } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const AuthImpersonationContext = createParamDecorator( + (_: unknown, ctx: ExecutionContext) => { + const request = getRequest(ctx); + + return request.impersonationContext; + }, +); diff --git a/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.spec.ts b/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.spec.ts new file mode 100644 index 0000000000..a695d0f16e --- /dev/null +++ b/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.spec.ts @@ -0,0 +1,310 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { type NextFunction, type Request, type Response } from 'express'; + +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { CookieSessionCsrfMiddleware } from 'src/engine/middlewares/cookie-session-csrf.middleware'; + +describe('CookieSessionCsrfMiddleware', () => { + let middleware: CookieSessionCsrfMiddleware; + + const defaultConfig: Record = { + AUTH_COOKIE_SESSIONS_ENABLED: true, + SERVER_URL: 'https://crm.example.com', + FRONTEND_URL: 'https://front.example.com', + AUTH_COOKIE_ALLOWED_ORIGINS: '', + }; + + let mockConfig: Record = { ...defaultConfig }; + + const buildRequest = (overrides: Partial = {}): Request => + ({ + method: 'POST', + protocol: 'https', + headers: {}, + get: jest.fn().mockReturnValue('crm.example.com'), + ...overrides, + }) as unknown as Request; + + const buildResponse = (): Response => { + const response = { + status: jest.fn(), + json: jest.fn(), + } as unknown as Response; + + (response.status as jest.Mock).mockReturnValue(response); + + return response; + }; + + let next: NextFunction; + + beforeEach(async () => { + mockConfig = { ...defaultConfig }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CookieSessionCsrfMiddleware, + UserSessionCookieService, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn((key: string) => mockConfig[key]), + }, + }, + { + provide: JwtWrapperService, + useValue: { + extractJwtFromRequest: () => (request: Request) => + /^Bearer\s+(\S+)/i.exec(request.headers.authorization ?? '')?.[1], + }, + }, + ], + }).compile(); + + middleware = module.get( + CookieSessionCsrfMiddleware, + ); + next = jest.fn(); + }); + + it('should skip safe methods', () => { + const request = buildRequest({ + method: 'GET', + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://evil.example.org', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should skip bearer-authenticated requests', () => { + const request = buildRequest({ + headers: { + authorization: 'Bearer some-jwt', + cookie: '__Host-twenty-session=sess_token', + origin: 'https://evil.example.org', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should skip requests without a session cookie', () => { + const request = buildRequest({ + headers: { + origin: 'https://evil.example.org', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should reject cookie requests without an Origin header', () => { + const request = buildRequest({ + headers: { + cookie: '__Host-twenty-session=sess_token', + }, + }); + const response = buildResponse(); + + middleware.use(request, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it('should still allow a request without an Origin header when it carries no session cookie', () => { + const request = buildRequest({ headers: {} }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow same-origin cookie requests', () => { + const request = buildRequest({ + get: jest.fn().mockReturnValue('api.example.com'), + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://api.example.com', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow allowlisted cross-origin cookie requests', () => { + const request = buildRequest({ + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://front.example.com', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow an origin listed only in AUTH_COOKIE_ALLOWED_ORIGINS', () => { + mockConfig.AUTH_COOKIE_ALLOWED_ORIGINS = 'https://split.example.net'; + + const request = buildRequest({ + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://split.example.net', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should treat a default port on the host as the same origin', () => { + const request = buildRequest({ + get: jest.fn().mockReturnValue('api.example.com:443'), + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://api.example.com', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should reject cookie requests from sibling subdomains', () => { + const request = buildRequest({ + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://other-workspace.example.com', + }, + }); + const response = buildResponse(); + + middleware.use(request, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'CSRF_ORIGIN_MISMATCH' }), + ); + }); + + it('should skip when cookie sessions are disabled', () => { + mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = false; + + const request = buildRequest({ + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://evil.example.com', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should still guard a cookie request carrying a non-Bearer authorization header', () => { + const request = buildRequest({ + headers: { + authorization: 'Basic dXNlcjpwYXNz', + cookie: '__Host-twenty-session=sess_token', + origin: 'https://evil.example.com', + }, + }); + const response = buildResponse(); + + middleware.use(request, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(403); + }); + + // The middleware guards every route, so callers that never hold a session + // cookie must pass without needing a per-route exclusion. + describe('callers that carry no session cookie', () => { + it('should allow a third-party webhook POST sending no Origin', () => { + const request = buildRequest({ + headers: { 'stripe-signature': 'v1=deadbeef' } as Request['headers'], + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow a workflow POST trigger authenticated by an api key', () => { + const request = buildRequest({ + headers: { + authorization: 'Bearer an-api-key', + origin: 'https://partner.example.org', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow an app-defined route POST from an arbitrary origin', () => { + const request = buildRequest({ + headers: { origin: 'https://partner.example.org' }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + }); + + describe('deployment topologies', () => { + it('should allow a workspace subdomain posting to its own host', () => { + const request = buildRequest({ + get: jest.fn().mockReturnValue('myworkspace.example.com'), + headers: { + cookie: '__Host-twenty-session=sess_token', + origin: 'https://myworkspace.example.com', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('should allow a front on a separate port from the api', () => { + mockConfig.SERVER_URL = 'http://localhost:3000'; + mockConfig.FRONTEND_URL = 'http://localhost:3001'; + + const request = buildRequest({ + protocol: 'http', + get: jest.fn().mockReturnValue('localhost:3000'), + headers: { + cookie: 'twenty-session=sess_token', + origin: 'http://localhost:3001', + }, + }); + + middleware.use(request, buildResponse(), next); + + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.ts b/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.ts new file mode 100644 index 0000000000..31b4445792 --- /dev/null +++ b/packages/twenty-server/src/engine/middlewares/cookie-session-csrf.middleware.ts @@ -0,0 +1,71 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { type NextFunction, type Request, type Response } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; +import { isRequestOriginAllowed } from 'src/engine/core-modules/user-session/utils/is-request-origin-allowed.util'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +// Only guards requests that would authenticate through the session cookie, +// since Bearer headers are never attached cross-site. SameSite=Lax already +// blocks cross-site POSTs; checking Origin closes the sibling-subdomain gap, +// which is same-site and so not covered by Lax. +@Injectable() +export class CookieSessionCsrfMiddleware implements NestMiddleware { + constructor( + private readonly twentyConfigService: TwentyConfigService, + private readonly userSessionCookieService: UserSessionCookieService, + private readonly jwtWrapperService: JwtWrapperService, + ) {} + + use(request: Request, response: Response, next: NextFunction): void { + if (SAFE_METHODS.has(request.method)) { + return next(); + } + + // Any other Authorization scheme still falls through to cookie auth, so it + // must not skip the check. + if ( + isNonEmptyString(this.jwtWrapperService.extractJwtFromRequest()(request)) + ) { + return next(); + } + + if ( + !isDefined( + this.userSessionCookieService.extractSessionTokenFromRequest(request), + ) + ) { + return next(); + } + + const origin = request.headers.origin; + + // Fails closed on a missing Origin: browsers send it on every unsafe + // request, so its absence is either a non-browser client, which belongs on + // a Bearer token, or a stripped header we cannot tell from a forgery. + if ( + isNonEmptyString(origin) && + isRequestOriginAllowed({ + origin, + request, + twentyConfigService: this.twentyConfigService, + }) + ) { + return next(); + } + + response.status(403).json({ + statusCode: 403, + messages: [ + 'Request origin is not allowed for cookie-authenticated requests', + ], + error: 'CSRF_ORIGIN_MISMATCH', + }); + } +} diff --git a/packages/twenty-server/src/engine/middlewares/middleware.module.ts b/packages/twenty-server/src/engine/middlewares/middleware.module.ts index 3d8adaa973..06236d069e 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.module.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.module.ts @@ -3,7 +3,9 @@ import { Module } from '@nestjs/common'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { CookieSessionCsrfMiddleware } from 'src/engine/middlewares/cookie-session-csrf.middleware'; import { MiddlewareService } from 'src/engine/middlewares/middleware.service'; +import { UserSessionModule } from 'src/engine/core-modules/user-session/user-session.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @Module({ @@ -12,8 +14,9 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ WorkspaceManyOrAllFlatEntityMapsCacheModule, TokenModule, JwtModule, + UserSessionModule, ], - providers: [MiddlewareService], - exports: [MiddlewareService], + providers: [MiddlewareService, CookieSessionCsrfMiddleware], + exports: [MiddlewareService, CookieSessionCsrfMiddleware], }) export class MiddlewareModule {} diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 0f16d8b329..274e1fa1b6 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -5,13 +5,17 @@ import { type Request, type Response } from 'express'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; -import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant'; @@ -23,6 +27,14 @@ import { import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { type CustomException } from 'src/utils/custom-exception'; +const DEAD_SESSION_COOKIE_EXCEPTION_CODES = new Set([ + AuthExceptionCode.UNAUTHENTICATED, + AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + AuthExceptionCode.USER_NOT_FOUND, + AuthExceptionCode.WORKSPACE_NOT_FOUND, +]); + @Injectable() export class MiddlewareService { constructor( @@ -31,12 +43,19 @@ export class MiddlewareService { private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly jwtWrapperService: JwtWrapperService, + private readonly userSessionCookieService: UserSessionCookieService, ) {} public isTokenPresent(request: Request): boolean { const token = this.jwtWrapperService.extractJwtFromRequest()(request); - return !!token; + if (token) { + return true; + } + + return isDefined( + this.userSessionCookieService.extractSessionTokenFromRequest(request), + ); } // oxlint-disable-next-line typescript/no-explicit-any @@ -115,6 +134,26 @@ export class MiddlewareService { bindDataToRequestObject(data, request, metadataVersion); } + private clearDeadSessionCookie(request: Request, error: unknown) { + const isCookieAuthenticated = !isNonEmptyString( + this.jwtWrapperService.extractJwtFromRequest()(request), + ); + + const isDeadCredential = + error instanceof AuthException && + DEAD_SESSION_COOKIE_EXCEPTION_CODES.has(error.code); + + if ( + !isCookieAuthenticated || + !isDeadCredential || + !isDefined(request.res) + ) { + return; + } + + this.userSessionCookieService.clearSessionCookie(request.res); + } + public async hydrateGraphqlRequest(request: Request) { if (!this.isTokenPresent(request)) { request.locale = @@ -124,7 +163,20 @@ export class MiddlewareService { return; } - const data = await this.accessTokenService.validateTokenByRequest(request); + let data; + + try { + data = await this.accessTokenService.validateTokenByRequest(request); + } catch (error) { + // Clearing is a response side effect, never a reason to swallow: letting + // the request continue unauthenticated builds the schema without the + // workspace, so the client gets "Cannot query field" instead of an auth + // error and never learns its session was revoked. + this.clearDeadSessionCookie(request, error); + + throw error; + } + const metadataVersion = data.workspace ? await this.getOrSeedMetadataVersion(data.workspace) : undefined; diff --git a/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts index 5c5150fc98..242aa8a99b 100644 --- a/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts +++ b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts @@ -21,6 +21,7 @@ export const bindDataToRequestObject = ( request.authProvider = data.authProvider; request.impersonationContext = data.impersonationContext; request.tokenType = data.tokenType; + request.authenticatedAt = data.authenticatedAt; request.locale = data.userWorkspace?.locale ?? diff --git a/packages/twenty-server/src/filters/unhandled-exception.filter.ts b/packages/twenty-server/src/filters/unhandled-exception.filter.ts index 106b362ec4..1c20269301 100644 --- a/packages/twenty-server/src/filters/unhandled-exception.filter.ts +++ b/packages/twenty-server/src/filters/unhandled-exception.filter.ts @@ -22,15 +22,20 @@ export class UnhandledExceptionFilter implements ExceptionFilter { } // TODO: Check if needed, remove otherwise. - response.header('Access-Control-Allow-Origin', '*'); - response.header( - 'Access-Control-Allow-Methods', - 'GET,HEAD,PUT,PATCH,POST,DELETE', - ); - response.header( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept', - ); + // Only for a response the CORS middleware never reached. Overwriting a + // reflected origin with the wildcard would make the browser reject a + // credentialed request that hit an exception, hiding the real error. + if (!response.getHeader('Access-Control-Allow-Origin')) { + response.header('Access-Control-Allow-Origin', '*'); + response.header( + 'Access-Control-Allow-Methods', + 'GET,HEAD,PUT,PATCH,POST,DELETE', + ); + response.header( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept', + ); + } const status = exception instanceof HttpException ? exception.getStatus() : 500; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 0521f2bda6..b34838a3fc 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -6,6 +6,7 @@ import { inspect } from 'util'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; +import { type NextFunction, type Request, type Response } from 'express'; import session from 'express-session'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; @@ -17,6 +18,7 @@ import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; +import { resolveAllowedCredentialedOrigins } from 'src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util'; import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util'; import { AppModule } from './app.module'; @@ -30,9 +32,6 @@ const bootstrap = async () => { setPgDateTypeParser(); const app = await NestFactory.create(AppModule, { - // Expose WWW-Authenticate so browser-based MCP clients can read the - // resource_metadata pointer on 401. Required by MCP authorization spec. - cors: { exposedHeaders: ['WWW-Authenticate'] }, bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true', rawBody: true, snapshot: process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT, @@ -67,6 +66,40 @@ const bootstrap = async () => { app.set('trust proxy', trustProxy); + // The cors package only emits Vary: Origin when it reflects one, so wildcard + // and reflected responses would share a cache entry and a credentialed + // request could be served the wildcard, which browsers reject. + app.use((_request: Request, response: Response, next: NextFunction) => { + response.vary('Origin'); + next(); + }); + + app.enableCors({ + // Resolved per request rather than once at boot: the origins derive from + // config the admin panel can change, and a snapshot would drift from the + // CSRF guard, which resolves them per request and would then disagree with + // CORS about the same origin. + origin: ( + origin: string | undefined, + callback: (error: Error | null, allow?: boolean | string) => void, + ) => { + if ( + origin && + resolveAllowedCredentialedOrigins(twentyConfigService).has( + origin.toLowerCase(), + ) + ) { + return callback(null, true); + } + + return callback(null, '*'); + }, + credentials: true, + // Expose WWW-Authenticate so browser-based MCP clients can read the + // resource_metadata pointer on 401. Required by MCP authorization spec. + exposedHeaders: ['WWW-Authenticate'], + }); + app.use(session(getSessionStorageOptions(twentyConfigService))); // Apply class-validator container so that we can use injection in validators diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/cookie-session/cookie-session.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/cookie-session/cookie-session.integration-spec.ts new file mode 100644 index 0000000000..9f128e0403 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/cookie-session/cookie-session.integration-spec.ts @@ -0,0 +1,266 @@ +import request from 'supertest'; +import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util'; +import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util'; + +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; + +const AUTH_COOKIE_SESSIONS_ENABLED_KEY = 'AUTH_COOKIE_SESSIONS_ENABLED'; +const SERVER_URL = `http://localhost:${APP_PORT}`; + +const buildAppleOrigin = (): string => { + const origin = new URL(SERVER_URL); + + origin.hostname = + process.env.IS_MULTIWORKSPACE_ENABLED === 'true' + ? `apple.${origin.hostname}` + : origin.hostname; + + return origin.origin; +}; + +// The app listens on localhost while the seeded workspace resolves from the +// apple subdomain. Without a matching Host the server sees a cross-origin +// request and refuses to issue the cookie, which is the behaviour under test +// everywhere except here. +const asWorkspaceRequest = ( + agent: request.Test, + originOverride?: string, +): request.Test => { + const host = new URL(origin).host; + + return originOverride === undefined + ? agent.set('Host', host).set('Origin', origin) + : agent.set('Host', host).set('Origin', originOverride); +}; + +const CURRENT_USER_QUERY = ` + query CurrentUser { + currentUser { + id + email + } + } +`; + +const SIGN_OUT_MUTATION = ` + mutation SignOut { + signOut + } +`; + +// A workspace object rather than currentUser: workspace types only exist in the +// schema once a workspace is bound, so a request that is allowed to continue +// unauthenticated fails with "Cannot query field" instead of an auth error. +const FIND_COMPANIES_QUERY = ` + query FindCompanies { + companies(first: 1) { + edges { + node { + id + } + } + } + } +`; + +const AUTH_ERROR_CODES = ['UNAUTHENTICATED', 'FORBIDDEN']; + +const extractSessionCookie = ( + setCookieHeader: string | string[] | undefined, +): string | undefined => { + const setCookies = + typeof setCookieHeader === 'string' + ? [setCookieHeader] + : (setCookieHeader ?? []); + + return setCookies + .find((cookie) => cookie.startsWith(`${USER_SESSION_COOKIE_NAME}=`)) + ?.split(';')[0]; +}; + +const origin = buildAppleOrigin(); + +const signInAndGetSessionCookie = async (): Promise => { + const loginResponse = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + ) + .send({ + query: ` + mutation GetLoginTokenFromCredentials( + $email: String! + $password: String! + $origin: String! + ) { + getLoginTokenFromCredentials( + email: $email + password: $password + origin: $origin + ) { + loginToken { + token + } + } + } + `, + variables: { + email: 'tim@apple.dev', + password: 'tim@apple.dev', + origin, + }, + }) + .expect(200); + + const loginToken = + loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token; + + const exchangeResponse = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + ) + .send({ + query: ` + mutation GetAuthTokensFromLoginToken( + $loginToken: String! + $origin: String! + ) { + getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) { + tokens { + accessOrWorkspaceAgnosticToken { + token + } + } + } + } + `, + variables: { loginToken, origin }, + }) + .expect(200); + + const sessionCookie = extractSessionCookie( + exchangeResponse.headers['set-cookie'], + ); + + expect(sessionCookie).toBeDefined(); + + return sessionCookie as string; +}; + +describe('Cookie sessions (integration)', () => { + let sessionCookie: string; + + beforeAll(async () => { + await updateConfigVariable({ + input: { key: AUTH_COOKIE_SESSIONS_ENABLED_KEY, value: true }, + }); + + sessionCookie = await signInAndGetSessionCookie(); + }); + + afterAll(async () => { + await deleteConfigVariable({ + input: { key: AUTH_COOKIE_SESSIONS_ENABLED_KEY }, + }); + }); + + it('should set a session cookie when exchanging a login token', () => { + expect(sessionCookie).toContain(`${USER_SESSION_COOKIE_NAME}=sess_`); + }); + + it('should authenticate a request carrying only the session cookie', async () => { + const response = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + ) + .set('Cookie', sessionCookie) + .send({ query: CURRENT_USER_QUERY }) + .expect(200); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.currentUser.email).toBe('tim@apple.dev'); + }); + + it('should reject a cookie-authenticated request from a foreign origin', async () => { + const response = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + 'https://evil.example.org', + ) + .set('Cookie', sessionCookie) + .send({ query: CURRENT_USER_QUERY }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); + }); + + it('should reject a cookie-authenticated request sending no origin', async () => { + const response = await request(SERVER_URL) + .post('/metadata') + .set('Host', new URL(origin).host) + .set('Cookie', sessionCookie) + .send({ query: CURRENT_USER_QUERY }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); + }); + + // Mints its own session rather than consuming the shared one, so revoking it + // cannot break the tests above whatever order they run in. + it('should stop authenticating the cookie once signed out', async () => { + const disposableSessionCookie = await signInAndGetSessionCookie(); + + const signOutResponse = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + ) + .set('Cookie', disposableSessionCookie) + .send({ query: SIGN_OUT_MUTATION }) + .expect(200); + + expect(signOutResponse.body.errors).toBeUndefined(); + + const afterSignOut = await asWorkspaceRequest( + request(SERVER_URL).post('/metadata'), + ) + .set('Cookie', disposableSessionCookie) + .send({ query: CURRENT_USER_QUERY }); + + expect(afterSignOut.body.data?.currentUser).toBeFalsy(); + expect(afterSignOut.body.errors).toBeDefined(); + expect( + afterSignOut.body.errors.map( + (graphQLError: { extensions?: { code?: string } }) => + graphQLError.extensions?.code, + ), + ).toEqual( + expect.arrayContaining([expect.stringMatching(/UNAUTHENTICATED/)]), + ); + }); + + it('should answer a revoked cookie with an auth error rather than a schema error', async () => { + const disposableSessionCookie = await signInAndGetSessionCookie(); + + await asWorkspaceRequest(request(SERVER_URL).post('/metadata')) + .set('Cookie', disposableSessionCookie) + .send({ query: SIGN_OUT_MUTATION }) + .expect(200); + + const response = await asWorkspaceRequest( + request(SERVER_URL).post('/graphql'), + ) + .set('Cookie', disposableSessionCookie) + .send({ query: FIND_COMPANIES_QUERY }); + + expect(response.body.errors).toBeDefined(); + + const messages = response.body.errors.map( + (graphQLError: { message: string }) => graphQLError.message, + ); + const codes = response.body.errors.map( + (graphQLError: { extensions?: { code?: string } }) => + graphQLError.extensions?.code, + ); + + // The client only signs out on an auth code. A missing workspace schema + // surfaces as "Cannot query field", which it cannot act on. + expect(messages.join(' ')).not.toContain('Cannot query field'); + expect(codes.some((code: string) => AUTH_ERROR_CODES.includes(code))).toBe( + true, + ); + }); +}); diff --git a/packages/twenty-ui/src/icon/components/TablerIcons.ts b/packages/twenty-ui/src/icon/components/TablerIcons.ts index fa85829c32..c4e947c7d7 100644 --- a/packages/twenty-ui/src/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/icon/components/TablerIcons.ts @@ -164,6 +164,7 @@ export { IconDatabase, IconDatabaseExport, IconDecimal, + IconDeviceDesktop, IconDeviceFloppy, IconDoorEnter, IconDotsVertical, diff --git a/packages/twenty-ui/src/icon/index.ts b/packages/twenty-ui/src/icon/index.ts index d50c61133c..0d2d043be0 100644 --- a/packages/twenty-ui/src/icon/index.ts +++ b/packages/twenty-ui/src/icon/index.ts @@ -157,6 +157,7 @@ export { IconColumnInsertRight, IconColumns, IconCommand, + IconMessageCircle, IconComment, IconCopy, IconCopyPlus, @@ -218,6 +219,7 @@ export { IconDatabase, IconDatabaseExport, IconDecimal, + IconDeviceDesktop, IconDeviceFloppy, IconDoorEnter, IconDotsVertical, @@ -336,7 +338,6 @@ export { IconMathXy, IconMaximize, IconMessage, - IconMessageCircle, IconMessageCirclePlus, IconMinus, IconMoneybag,