Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642)

This commit is contained in:
Félix Malfait
2026-08-03 19:54:54 +02:00
committed by GitHub
parent f8e3fd110d
commit 267ecb12db
141 changed files with 7339 additions and 1463 deletions
+2
View File
@@ -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
@@ -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!
@@ -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"')
File diff suppressed because it is too large Load Diff
@@ -375,3 +375,24 @@ LOGIC_FUNCTION_TYPE=DISABLED # default when NODE_ENV=production
<Note>
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.
</Note>
## 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
```
<Warning>
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.
</Warning>
If the two hosts are on different registrable domains (not just different
subdomains), also set `AUTH_COOKIE_SAME_SITE=none`, which requires HTTPS.
File diff suppressed because one or more lines are too long
@@ -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();
@@ -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;
}
@@ -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<Options> = {}) => {
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<Options> = {}) => {
},
onUnauthenticatedError: () => {
setTokenPair(null);
setIsCookieAuthActive(false);
setCurrentUser(null);
setCurrentWorkspaceMember(null);
setCurrentWorkspace(null);
@@ -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':
@@ -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`
@@ -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';
@@ -25,11 +25,13 @@ type StreamDirective = {
export class StreamingRestLink extends ApolloLink {
private readonly baseUri: string;
private readonly defaultHeaders: Record<string, string>;
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 } : {}),
};
}
}
@@ -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) => {
<ApolloProvider>
<BaseThemeProvider>
<ClientConfigProviderEffect />
<CookieSessionBootEffect />
<ClientConfigProvider>{children}</ClientConfigProvider>
</BaseThemeProvider>
</ApolloProvider>
@@ -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}</>
@@ -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
@@ -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<CookieSessionProbeResult> => {
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<boolean> => {
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;
};
@@ -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 }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
),
});
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();
});
});
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SIGN_OUT = gql`
mutation SignOut($refreshToken: String) {
signOut(refreshToken: $refreshToken)
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const STOP_IMPERSONATION = gql`
mutation StopImpersonation {
stopImpersonation {
canRestoreImpersonatorSession
}
}
`;
@@ -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);
});
});
@@ -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);
});
});
@@ -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 ({
@@ -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;
};
@@ -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;
@@ -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;
};
@@ -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]),
@@ -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' }),
);
});
});
@@ -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,
]);
@@ -4,7 +4,7 @@ import { type UserWorkspace } from '~/generated-metadata/graphql';
export type CurrentUserWorkspace = Pick<
UserWorkspace,
'permissionFlags' | 'twoFactorAuthenticationMethodSummary'
'permissionFlags' | 'twoFactorAuthenticationMethodSummary' | 'isImpersonating'
> & {
objectsPermissions: Array<ObjectPermissions & { objectMetadataId: string }>;
};
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isCookieAuthActiveState = createAtomState<boolean>({
key: 'isCookieAuthActiveState',
defaultValue: false,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -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<boolean>({
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;
},
});
@@ -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<boolean>({
key: 'isPendingServerSignOutState',
defaultValue: false,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -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,
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isCookieSessionEnabledState = createAtomState<boolean>({
key: 'isCookieSessionEnabled',
defaultValue: false,
});
@@ -33,6 +33,7 @@ export type ClientConfig = {
isMicrosoftCalendarEnabled: boolean;
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isCookieSessionEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailingDomainInDemoMode: boolean;
isCloudflareIntegrationEnabled: boolean;
@@ -68,6 +68,7 @@ describe('getClientConfig', () => {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
},
);
expect(result).toEqual(mockClientConfig);
@@ -7,6 +7,7 @@ export const getClientConfig = async (): Promise<ClientConfig> => {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
@@ -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,
@@ -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)) {
@@ -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,
@@ -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;
};
@@ -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`
@@ -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;
}
@@ -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 (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
accent="danger"
LeftIcon={IconLogout}
text={t`Log out`}
onClick={handleRevokeSession}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -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 (
<Section>
<H2Title
title={t`Devices`}
description={t`Devices with an active session on your account`}
/>
<StyledContainer>
<SettingsListCard
items={sessions}
isLoading={loading}
getItemLabel={getSessionLabel}
getItemDescription={getSessionDescription}
RowIcon={IconDeviceDesktop}
RowRightComponent={({ item: session }) => (
<>
{session.isImpersonating && (
<Status color="orange" text={t`Impersonation`} />
)}
{session.isCurrent ? (
<Status color="turquoise" text={t`This device`} />
) : (
<SettingsDeviceSessionRowDropdownMenu
userSessionId={session.id}
onRevoked={() => void refetch()}
/>
)}
</>
)}
/>
{hasOtherSessions && (
<StyledButtonContainer>
<Button
accent="danger"
variant="secondary"
title={t`Log out all other devices`}
Icon={IconLogout}
onClick={() => void handleRevokeAllOtherSessions()}
/>
</StyledButtonContainer>
)}
</StyledContainer>
</Section>
);
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const REVOKE_ALL_OTHER_USER_SESSIONS = gql`
mutation RevokeAllOtherUserSessions {
revokeAllOtherUserSessions
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const REVOKE_USER_SESSION = gql`
mutation RevokeUserSession($userSessionId: UUID!) {
revokeUserSession(userSessionId: $userSessionId)
}
`;
@@ -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
}
}
`;
@@ -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',
});
});
});
@@ -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,
};
};
@@ -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,
@@ -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,
@@ -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<typeof useStore>,
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(
@@ -35,6 +35,7 @@ export const USER_QUERY_FRAGMENT = gql`
currentUserWorkspace {
id
permissionFlags
isImpersonating
objectsPermissions {
...ObjectPermissionFragment
}
@@ -82,6 +82,7 @@ export const useLoadCurrentUser = () => {
(user.currentUserWorkspace.objectsPermissions as Array<
ObjectPermissions & { objectMetadataId: string }
>) ?? [],
isImpersonating: user.currentUserWorkspace.isImpersonating ?? false,
});
}
@@ -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<Form>({
mode: 'onChange',
@@ -178,7 +178,7 @@ export const PasswordReset = () => {
currentUser ? { ...currentUser, hasPassword: true } : currentUser,
);
if (hasAccessTokenPair) {
if (isLogged) {
enqueueSuccessSnackBar({
message: successMessage,
});
@@ -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 = () => {
<SetOrChangePassword />
</Section>
)}
<SettingsProfileDevicesSection />
<Section>
<DeleteAccount />
</Section>
@@ -5,6 +5,7 @@ export const mockedClientConfig: ClientConfig = {
aiModels: [],
signInPrefilled: true,
isMultiWorkspaceEnabled: false,
isCookieSessionEnabled: false,
isEmailVerificationRequired: false,
authProviders: {
google: true,
@@ -15,6 +15,8 @@ const STRUCTURAL_EXEMPTIONS = new Set<string>([
// 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',
+1
View File
@@ -26,5 +26,6 @@ declare module 'express-serve-static-core' {
authProvider?: AuthProviderEnum | null;
impersonationContext?: RawAuthContext['impersonationContext'];
tokenType?: JwtTokenTypeEnum;
authenticatedAt?: Date;
}
}
+16
View File
@@ -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,
@@ -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;
@@ -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,
@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "core"."userSession"`);
}
}
@@ -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,
];
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('StopImpersonation')
export class StopImpersonationDTO {
@Field(() => Boolean)
canRestoreImpersonatorSession: boolean;
}
@@ -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,
@@ -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: {},
@@ -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<AvailableWorkspacesAndAccessTokensDTO> {
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<AuthTokens> {
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<AvailableWorkspacesAndAccessTokensDTO> {
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<AuthTokens> {
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<AuthTokens> {
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<AuthTokens> {
async renewToken(
@Args() args: AppTokenInput,
@Context() context: { req: Request },
): Promise<AuthTokens> {
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<boolean> {
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,
@@ -0,0 +1 @@
export const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW = '10m';
@@ -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<SignInUpService, 'validatePassword' | 'signUpWithoutWorkspace'>
>;
@@ -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<SignInUpService, 'validatePassword' | 'signUpWithoutWorkspace'>
>;
userSessionService = module.get<UserSessionService>(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();
});
});
});
@@ -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,
@@ -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<WorkspaceEntity>;
let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
let userWorkspaceRepository: Repository<UserWorkspaceEntity>;
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<Repository<UserWorkspaceEntity>>(
getRepositoryToken(UserWorkspaceEntity),
);
userSessionService = module.get<UserSessionService>(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();
});
});
});
@@ -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<UserWorkspaceEntity>,
private readonly userSessionService: UserSessionService,
private readonly userSessionCookieService: UserSessionCookieService,
) {}
private async resolveTokenSubject(
@@ -194,13 +199,45 @@ export class AccessTokenService {
async validateTokenByRequest(request: Request): Promise<AuthContext> {
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<AuthContext> {
const { payload, authenticatedAt } =
await this.userSessionService.resolveSession(sessionToken);
const context = await this.jwtStrategy.validate(payload);
return {
...context,
workspaceMemberId:
context.workspaceMemberId ?? context.workspaceMember?.id,
authenticatedAt,
};
}
}
@@ -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,
@@ -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;
};
@@ -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);
});
});
@@ -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;
};
@@ -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',
@@ -96,6 +96,7 @@ describe('ClientConfigController', () => {
analyticsEnabled: false,
canManageFeatureFlags: true,
publicFeatureFlags: [],
isCookieSessionEnabled: false,
isMicrosoftMessagingEnabled: false,
isMicrosoftCalendarEnabled: false,
isGoogleMessagingEnabled: false,
@@ -309,6 +309,9 @@ export class ClientConfig {
@Field(() => [PublicFeatureFlag])
publicFeatureFlags: PublicFeatureFlag[];
@Field(() => Boolean)
isCookieSessionEnabled: boolean;
@Field(() => Boolean)
isMicrosoftMessagingEnabled: boolean;
@@ -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')
@@ -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',
),
@@ -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;
@@ -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<typeof authSessionSchema>;
registerEvent(AUTH_SESSION_EVENT, authSessionSchema);
@@ -12,6 +12,7 @@ export const impersonationSchema = z.strictObject({
'attempt',
'attempted',
'issued',
'ended',
'login_token_attempt',
'login_token_generated',
'login_token_failed',
@@ -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<string, unknown> = {},
): Record<string, unknown> => ({
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);
});
});
});
@@ -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],
@@ -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<StopImpersonationDTO> {
return await this.impersonationService.stopImpersonation({
impersonationContext,
workspaceId: workspace.id,
request: context.req,
});
}
}
@@ -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<UserWorkspaceEntity>,
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<boolean> {
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,
@@ -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';
@@ -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,
});
};
@@ -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,
});
};
@@ -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,
});
};
@@ -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<typeof ms>[0]);
return typeof parsedDuration === 'number' && Number.isFinite(parsedDuration)
? parsedDuration
: undefined;
} catch {
return undefined;
}
};
@@ -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);
});
});
@@ -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';
}
}
@@ -0,0 +1 @@
export const USER_SESSION_CLEANUP_CRON_PATTERN = '0 3 * * *';
@@ -0,0 +1 @@
export const USER_SESSION_COOKIE_NAME = 'twenty-session';

Some files were not shown because too many files have changed in this diff Show More