Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642)
This commit is contained in:
@@ -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
+255
-255
@@ -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;
|
||||
};
|
||||
+143
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+3
-3
@@ -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)) {
|
||||
|
||||
+8
-5
@@ -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`
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+146
@@ -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>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REVOKE_ALL_OTHER_USER_SESSIONS = gql`
|
||||
mutation RevokeAllOtherUserSessions {
|
||||
revokeAllOtherUserSessions
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REVOKE_USER_SESSION = gql`
|
||||
mutation RevokeUserSession($userSessionId: UUID!) {
|
||||
revokeUserSession(userSessionId: $userSessionId)
|
||||
}
|
||||
`;
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+47
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
@@ -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,
|
||||
|
||||
+31
-21
@@ -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
@@ -26,5 +26,6 @@ declare module 'express-serve-static-core' {
|
||||
authProvider?: AuthProviderEnum | null;
|
||||
impersonationContext?: RawAuthContext['impersonationContext'];
|
||||
tokenType?: JwtTokenTypeEnum;
|
||||
authenticatedAt?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+63
@@ -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"`);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+93
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+43
-6
@@ -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;
|
||||
};
|
||||
|
||||
+92
@@ -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);
|
||||
});
|
||||
});
|
||||
+47
@@ -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;
|
||||
};
|
||||
+1
@@ -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',
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+24
@@ -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')
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+17
@@ -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);
|
||||
+1
@@ -12,6 +12,7 @@ export const impersonationSchema = z.strictObject({
|
||||
'attempt',
|
||||
'attempted',
|
||||
'issued',
|
||||
'ended',
|
||||
'login_token_attempt',
|
||||
'login_token_generated',
|
||||
'login_token_failed',
|
||||
|
||||
+176
@@ -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],
|
||||
|
||||
+24
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -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';
|
||||
|
||||
|
||||
-28
@@ -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,
|
||||
});
|
||||
};
|
||||
+17
@@ -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,
|
||||
});
|
||||
};
|
||||
+17
@@ -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,
|
||||
});
|
||||
};
|
||||
+20
@@ -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;
|
||||
}
|
||||
};
|
||||
+46
@@ -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);
|
||||
});
|
||||
});
|
||||
+29
@@ -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';
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const USER_SESSION_CLEANUP_CRON_PATTERN = '0 3 * * *';
|
||||
+1
@@ -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
Reference in New Issue
Block a user