Compare commits

..
Author SHA1 Message Date
sonarly-bot 776f5afe3c fix(server): tolerate legacy plaintext connected account tokens
https://sonarly.com/issue/39451?type=bug

Message import failed for at least one production workspace because a legacy plaintext OAuth refresh token hit a decrypt path that now strictly requires encrypted envelopes.

Authored by Sonarly by autonomous analysis (run 44960).
2026-05-21 15:32:21 +00:00
Abdullah.andGitHub 8826d12a18 [Website] Host customer story hero images locally and fix multi-segment redirects (#20790)
Customer story page shows the following error, which I believe leads to
an internal server on the individual customer story pages.

<img width="636" height="75" alt="image"
src="https://github.com/user-attachments/assets/fc9ede75-fd3b-4538-8211-8182f4a99b9b"
/>

This PR replaces remote URLs of those images with local copies to avoid
a 404 issue. Will test once deployed on dev to confirm if the error is
resolved, but locally, I do not see console errors any longer after this
change.

There is some duplicated copy that I found upon audit which can be made
DRY, but I will resolve it in a separate PR to keep this PR
single-responsibility.
2026-05-21 14:10:24 +00:00
Paul RastoinandGitHub abf5902ab5 Connected account deprecation system build (#20810) 2026-05-21 16:14:56 +02:00
a78c4c9fe8 chore: bump version to 2.8.0 (#20813)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version

## Checklist

- [ ] Verify version constants are correct

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-05-21 16:10:08 +02:00
a9ff1a9d3c Fix server variable not shown (#20799)
## Before

<img width="1502" height="675" alt="image"
src="https://github.com/user-attachments/assets/b64b24b4-11a5-4f6b-a3aa-c77108f22e9d"
/>

## After

<img width="1262" height="593" alt="image"
src="https://github.com/user-attachments/assets/42d662b4-2ec6-4ad4-9d19-58f25f52475c"
/>

---------

Co-authored-by: ehconitin <nitinkoche03@gmail.com>
2026-05-21 13:43:05 +00:00
32 changed files with 410 additions and 266 deletions
@@ -1764,6 +1764,18 @@ type WorkspaceUrls {
subdomainUrl: String!
}
type ApplicationRegistrationVariableDTO {
id: UUID!
key: String!
value: String
description: String!
isSecret: Boolean!
isRequired: Boolean!
isFilled: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type BillingTrialPeriod {
duration: Float!
isCreditCardRequired: Boolean!
@@ -1968,18 +1980,6 @@ type RotateClientSecret {
clientSecret: String!
}
type ApplicationRegistrationVariableDTO {
id: UUID!
key: String!
value: String
description: String!
isSecret: Boolean!
isRequired: Boolean!
isFilled: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type Relation {
type: RelationType!
sourceObjectMetadata: Object!
@@ -1401,6 +1401,19 @@ export interface WorkspaceUrls {
__typename: 'WorkspaceUrls'
}
export interface ApplicationRegistrationVariableDTO {
id: Scalars['UUID']
key: Scalars['String']
value?: Scalars['String']
description: Scalars['String']
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
isFilled: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ApplicationRegistrationVariableDTO'
}
export interface BillingTrialPeriod {
duration: Scalars['Float']
isCreditCardRequired: Scalars['Boolean']
@@ -1609,19 +1622,6 @@ export interface RotateClientSecret {
__typename: 'RotateClientSecret'
}
export interface ApplicationRegistrationVariableDTO {
id: Scalars['UUID']
key: Scalars['String']
value?: Scalars['String']
description: Scalars['String']
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
isFilled: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ApplicationRegistrationVariableDTO'
}
export interface Relation {
type: RelationType
sourceObjectMetadata: Object
@@ -4332,6 +4332,20 @@ export interface WorkspaceUrlsGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationRegistrationVariableDTOGenqlSelection{
id?: boolean | number
key?: boolean | number
value?: boolean | number
description?: boolean | number
isSecret?: boolean | number
isRequired?: boolean | number
isFilled?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingTrialPeriodGenqlSelection{
duration?: boolean | number
isCreditCardRequired?: boolean | number
@@ -4553,20 +4567,6 @@ export interface RotateClientSecretGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationRegistrationVariableDTOGenqlSelection{
id?: boolean | number
key?: boolean | number
value?: boolean | number
description?: boolean | number
isSecret?: boolean | number
isRequired?: boolean | number
isFilled?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RelationGenqlSelection{
type?: boolean | number
sourceObjectMetadata?: ObjectGenqlSelection
@@ -7183,6 +7183,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
}
const BillingTrialPeriod_possibleTypes: string[] = ['BillingTrialPeriod']
export const isBillingTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingTrialPeriod => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingTrialPeriod"')
@@ -7367,14 +7375,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
}
const Relation_possibleTypes: string[] = ['Relation']
export const isRelation = (obj?: { __typename?: any } | null): obj is Relation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRelation"')
@@ -51,11 +51,11 @@ export default {
163,
167,
169,
173,
174,
181,
184,
187,
175,
182,
185,
188,
201,
226,
262,
@@ -3434,6 +3434,38 @@ export default {
1
]
},
"ApplicationRegistrationVariableDTO": {
"id": [
3
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"isRequired": [
6
],
"isFilled": [
6
],
"createdAt": [
4
],
"updatedAt": [
4
],
"__typename": [
1
]
},
"BillingTrialPeriod": {
"duration": [
11
@@ -3453,10 +3485,10 @@ export default {
1
],
"type": [
173
174
],
"status": [
174
175
],
"issuer": [
1
@@ -3469,7 +3501,7 @@ export default {
"SSOIdentityProviderStatus": {},
"AuthProviders": {
"sso": [
172
173
],
"google": [
6
@@ -3506,10 +3538,10 @@ export default {
3
],
"authProviders": [
175
176
],
"authBypassProviders": [
176
177
],
"logo": [
1
@@ -3557,7 +3589,7 @@ export default {
1
],
"modelFamily": [
181
182
],
"modelFamilyLabel": [
1
@@ -3572,7 +3604,7 @@ export default {
11
],
"nativeCapabilities": [
179
180
],
"isDeprecated": [
6
@@ -3608,7 +3640,7 @@ export default {
1
],
"trialPeriods": [
171
172
],
"__typename": [
1
@@ -3616,7 +3648,7 @@ export default {
},
"Support": {
"supportDriver": [
184
185
],
"supportFrontChatId": [
1
@@ -3642,7 +3674,7 @@ export default {
},
"Captcha": {
"provider": [
187
188
],
"siteKey": [
1
@@ -3679,7 +3711,7 @@ export default {
169
],
"metadata": [
189
190
],
"__typename": [
1
@@ -3704,13 +3736,13 @@ export default {
1
],
"authProviders": [
175
176
],
"billing": [
182
183
],
"aiModels": [
180
181
],
"signInPrefilled": [
6
@@ -3731,25 +3763,25 @@ export default {
6
],
"support": [
183
184
],
"isAttachmentPreviewEnabled": [
6
],
"sentry": [
185
],
"captcha": [
186
],
"captcha": [
187
],
"api": [
188
189
],
"canManageFeatureFlags": [
6
],
"publicFeatureFlags": [
190
191
],
"isMicrosoftMessagingEnabled": [
6
@@ -3788,7 +3820,7 @@ export default {
6
],
"maintenance": [
191
192
],
"__typename": [
1
@@ -3827,7 +3859,7 @@ export default {
1
],
"versionDistribution": [
194
195
],
"__typename": [
1
@@ -3872,38 +3904,6 @@ export default {
1
]
},
"ApplicationRegistrationVariableDTO": {
"id": [
3
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"isRequired": [
6
],
"isFilled": [
6
],
"createdAt": [
4
],
"updatedAt": [
4
],
"__typename": [
1
]
},
"Relation": {
"type": [
201
@@ -4095,7 +4095,7 @@ export default {
3
],
"type": [
173
174
],
"issuer": [
1
@@ -4104,7 +4104,7 @@ export default {
1
],
"status": [
174
175
],
"__typename": [
1
@@ -4123,7 +4123,7 @@ export default {
},
"FindAvailableSSOIDP": {
"type": [
173
174
],
"id": [
3
@@ -4135,7 +4135,7 @@ export default {
1
],
"status": [
174
175
],
"workspace": [
218
@@ -4149,7 +4149,7 @@ export default {
3
],
"type": [
173
174
],
"issuer": [
1
@@ -4158,7 +4158,7 @@ export default {
1
],
"status": [
174
175
],
"__typename": [
1
@@ -4166,7 +4166,7 @@ export default {
},
"SSOConnection": {
"type": [
173
174
],
"id": [
3
@@ -4178,7 +4178,7 @@ export default {
1
],
"status": [
174
175
],
"__typename": [
1
@@ -4529,13 +4529,13 @@ export default {
},
"UsageAnalytics": {
"usageByUser": [
193
194
],
"usageByOperationType": [
193
194
],
"usageByModel": [
193
194
],
"timeSeries": [
251
@@ -6293,7 +6293,7 @@ export default {
}
],
"findApplicationRegistrationByClientId": [
197,
198,
{
"clientId": [
1,
@@ -6323,7 +6323,7 @@ export default {
}
],
"findApplicationRegistrationStats": [
195,
196,
{
"id": [
1,
@@ -6332,7 +6332,7 @@ export default {
}
],
"findApplicationRegistrationVariables": [
199,
171,
{
"applicationRegistrationId": [
1,
@@ -6356,7 +6356,7 @@ export default {
75
],
"getPublicWorkspaceDataByDomain": [
177,
178,
{
"origin": [
1
@@ -6364,7 +6364,7 @@ export default {
}
],
"getPublicWorkspaceDataById": [
178,
179,
{
"id": [
3,
@@ -8197,7 +8197,7 @@ export default {
}
],
"createApplicationRegistration": [
196,
197,
{
"input": [
443,
@@ -8224,7 +8224,7 @@ export default {
}
],
"rotateApplicationRegistrationClientSecret": [
198,
199,
{
"id": [
1,
@@ -11010,7 +11010,7 @@ export default {
3
],
"status": [
174
175
],
"__typename": [
1
@@ -195,6 +195,19 @@ export enum ApplicationRegistrationSourceType {
TARBALL = 'TARBALL'
}
export type ApplicationRegistrationVariableDto = {
__typename?: 'ApplicationRegistrationVariableDTO';
createdAt: Scalars['DateTime'];
description: Scalars['String'];
id: Scalars['UUID'];
isFilled: Scalars['Boolean'];
isRequired: Scalars['Boolean'];
isSecret: Scalars['Boolean'];
key: Scalars['String'];
updatedAt: Scalars['DateTime'];
value?: Maybe<Scalars['String']>;
};
export enum ConfigSource {
DATABASE = 'DATABASE',
DEFAULT = 'DEFAULT',
@@ -495,6 +508,7 @@ export type Query = {
__typename?: 'Query';
adminPanelRecentUsers: Array<AdminPanelRecentUser>;
adminPanelTopWorkspaces: Array<AdminPanelTopWorkspace>;
findAdminApplicationRegistrationVariables: Array<ApplicationRegistrationVariableDto>;
findAllApplicationRegistrations: Array<ApplicationRegistration>;
findOneAdminApplicationRegistration: ApplicationRegistration;
getAdminAiModels: AdminAiModels;
@@ -531,6 +545,11 @@ export type QueryAdminPanelTopWorkspacesArgs = {
};
export type QueryFindAdminApplicationRegistrationVariablesArgs = {
applicationRegistrationId: Scalars['String'];
};
export type QueryFindOneAdminApplicationRegistrationArgs = {
id: Scalars['String'];
};
@@ -904,6 +923,13 @@ export type GetModelsDevSuggestionsQueryVariables = Exact<{
export type GetModelsDevSuggestionsQuery = { __typename?: 'Query', getModelsDevSuggestions: Array<{ __typename?: 'ModelsDevModelSuggestion', modelId: string, name: string, inputCostPerMillionTokens: number, outputCostPerMillionTokens: number, cachedInputCostPerMillionTokens?: number | null, cacheCreationCostPerMillionTokens?: number | null, contextWindowTokens: number, maxOutputTokens: number, modalities: Array<string>, supportsReasoning: boolean }> };
export type FindAdminApplicationRegistrationVariablesQueryVariables = Exact<{
applicationRegistrationId: Scalars['String'];
}>;
export type FindAdminApplicationRegistrationVariablesQuery = { __typename?: 'Query', findAdminApplicationRegistrationVariables: Array<{ __typename?: 'ApplicationRegistrationVariableDTO', id: string, key: string, value?: string | null, description: string, isSecret: boolean, isRequired: boolean, isFilled: boolean, createdAt: string, updatedAt: string }> };
export type FindAllApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1128,6 +1154,7 @@ export const GetAdminAiUsageByWorkspaceDocument = {"kind":"Document","definition
export const GetAiProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAiProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAiProviders"}}]}}]} as unknown as DocumentNode<GetAiProvidersQuery, GetAiProvidersQueryVariables>;
export const GetModelsDevProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}},{"kind":"Field","name":{"kind":"Name","value":"npm"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevProvidersQuery, GetModelsDevProvidersQueryVariables>;
export const GetModelsDevSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevSuggestions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevSuggestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cachedInputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cacheCreationCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"modalities"}},{"kind":"Field","name":{"kind":"Name","value":"supportsReasoning"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevSuggestionsQuery, GetModelsDevSuggestionsQueryVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
export const DeleteDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}]}]}}]} as unknown as DocumentNode<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
@@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const FIND_ADMIN_APPLICATION_REGISTRATION_VARIABLES = gql`
query FindAdminApplicationRegistrationVariables(
$applicationRegistrationId: String!
) {
findAdminApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
id
key
value
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -66,6 +66,7 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
return (
<SettingsApplicationRegistrationConfigTab
registration={registration}
fromAdmin
/>
);
case 'oauth':
@@ -1,31 +1,47 @@
import type { ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
import { useQuery } from '@apollo/client/react';
import { FindApplicationRegistrationVariablesDocument } from '~/generated-metadata/graphql';
import { FindAdminApplicationRegistrationVariablesDocument } from '~/generated-admin/graphql';
import { Section } from 'twenty-ui/layout';
import { H2Title, Status } from 'twenty-ui/display';
import { useLingui } from '@lingui/react/macro';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { ConfigVariableTable } from '@/settings/config-variables/components/ConfigVariableTable';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
export const SettingsApplicationRegistrationConfigTab = ({
registration,
fromAdmin,
}: {
registration: ApplicationRegistrationData;
fromAdmin?: boolean;
}) => {
const { t } = useLingui();
const apolloAdminClient = useApolloAdminClient();
const applicationRegistrationId = registration.id;
const { data: variablesData } = useQuery(
const { data: workspaceVariablesData } = useQuery(
FindApplicationRegistrationVariablesDocument,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
skip: !applicationRegistrationId || fromAdmin === true,
},
);
const variables = variablesData?.findApplicationRegistrationVariables ?? [];
const { data: adminVariablesData } = useQuery(
FindAdminApplicationRegistrationVariablesDocument,
{
client: apolloAdminClient,
variables: { applicationRegistrationId },
skip: !applicationRegistrationId || fromAdmin !== true,
},
);
const variables = fromAdmin
? (adminVariablesData?.findAdminApplicationRegistrationVariables ?? [])
: (workspaceVariablesData?.findApplicationRegistrationVariables ?? []);
const configVariables = variables.map((variable) => ({
name: variable.key,
@@ -44,6 +44,8 @@ import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
import { ApplicationRegistrationVariableDTO } from 'src/engine/core-modules/application/application-registration-variable/dtos/application-registration-variable.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
@@ -105,6 +107,7 @@ export class AdminPanelResolver {
private readonly adminPanelHealthService: AdminPanelHealthService,
private readonly adminPanelSigningKeyService: AdminPanelSigningKeyService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private adminPanelQueueService: AdminPanelQueueService,
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
@@ -712,6 +715,16 @@ export class AdminPanelResolver {
return this.applicationRegistrationService.findOneByIdGlobal(id);
}
@UseGuards(AdminPanelGuard)
@Query(() => [ApplicationRegistrationVariableDTO])
async findAdminApplicationRegistrationVariables(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableDTO[]> {
return this.applicationRegistrationVariableService.findVariablesWithObfuscatedValuesGlobal(
applicationRegistrationId,
);
}
@UseGuards(AdminPanelGuard)
@Query(() => InstanceAndAllWorkspacesUpgradeStatusDTO)
async getInstanceAndAllWorkspacesUpgradeStatus(): Promise<InstanceAndAllWorkspacesUpgradeStatusDTO> {
@@ -35,6 +35,14 @@ export class ApplicationRegistrationVariableService {
workspaceId,
);
return this.findVariablesWithObfuscatedValuesGlobal(
applicationRegistrationId,
);
}
async findVariablesWithObfuscatedValuesGlobal(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableDTO[]> {
const variables = await this.variableRepository.find({
where: { applicationRegistrationId },
order: { key: 'ASC' },
@@ -6,7 +6,6 @@ import { ApplicationConnectionsController } from 'src/engine/core-modules/applic
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { RefreshTokensManagerModule } from 'src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module';
@@ -23,7 +22,6 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
TokenModule,
WorkspaceCacheStorageModule,
RefreshTokensManagerModule,
ConnectedAccountTokenEncryptionModule,
],
providers: [ApplicationConnectionsListService],
controllers: [ApplicationConnectionsController],
@@ -9,7 +9,6 @@ import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
type ListArgs = {
@@ -39,7 +38,6 @@ export class ApplicationConnectionsListService {
constructor(
private readonly refreshTokensService: ConnectedAccountRefreshTokensService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(ConnectionProviderEntity)
@@ -209,7 +207,7 @@ export class ApplicationConnectionsListService {
}
try {
const encryptedTokens = await this.refreshTokensService.resolveTokens(
const tokens = await this.refreshTokensService.resolveTokens(
account,
workspaceId,
);
@@ -221,10 +219,7 @@ export class ApplicationConnectionsListService {
handle: account.handle,
visibility: account.visibility as 'user' | 'workspace',
userWorkspaceId: account.userWorkspaceId,
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
ciphertext: encryptedTokens.accessToken,
workspaceId,
}),
accessToken: tokens.accessToken,
scopes: account.scopes ?? provider.oauthConfig?.scopes ?? [],
authFailedAt: account.authFailedAt?.toISOString() ?? null,
};
@@ -7,4 +7,4 @@
* |___/
*/
export const TWENTY_CURRENT_VERSION = '2.7.0' as const;
export const TWENTY_CURRENT_VERSION = '2.8.0' as const;
@@ -8,5 +8,5 @@
*/
export const TWENTY_NEXT_VERSIONS = [
'2.8.0',
'2.9.0',
] as const;
@@ -18,4 +18,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
'2.4.0',
'2.5.0',
'2.6.0',
'2.7.0',
] as const;
@@ -12,6 +12,7 @@ import {
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
@@ -105,6 +106,15 @@ describe('ConnectedAccountRefreshTokensService', () => {
refreshTokens: jest.fn(),
},
},
{
provide: GlobalWorkspaceOrmManager,
useValue: {
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
},
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: {
@@ -139,7 +149,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
});
describe('resolveTokens', () => {
it('should reuse the cached encrypted tokens as-is when valid, skipping decrypt and the refresh call entirely', async () => {
it('should reuse the cached token, decrypt before returning to the caller, and skip the refresh call entirely', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.MICROSOFT,
@@ -154,19 +164,28 @@ describe('ConnectedAccountRefreshTokensService', () => {
);
expect(result).toEqual({
accessToken: mockEncryptedAccessToken,
refreshToken: mockEncryptedRefreshToken,
accessToken: mockAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).not.toHaveBeenCalled();
).toHaveBeenCalledWith({
ciphertext: mockEncryptedAccessToken,
workspaceId: mockWorkspaceId,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).toHaveBeenCalledWith({
ciphertext: mockEncryptedRefreshToken,
workspaceId: mockWorkspaceId,
});
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
it('should decrypt the stored refresh token before sending to Microsoft, persist the re-encrypted tokens, and return them encrypted', async () => {
it('should decrypt the stored refresh token before sending to Microsoft, then re-encrypt the rotated tokens before persisting', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.MICROSOFT,
@@ -175,41 +194,35 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountEntity;
const newPlaintextTokens = {
const newTokens = {
accessToken: mockNewAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
};
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newPlaintextTokens);
.mockResolvedValue(newTokens);
const result = await service.resolveTokens(
connectedAccount,
mockWorkspaceId,
);
const expectedEncryptedNewAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`;
const expectedEncryptedNewRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`;
expect(result).toEqual({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
});
expect(result).toEqual(newTokens);
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshTokenPlaintext);
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: mockConnectedAccountId, workspaceId: mockWorkspaceId },
expect.objectContaining({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`,
refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`,
lastCredentialsRefreshedAt: expect.any(Date),
}),
);
});
it('should decrypt the stored refresh token before sending to Google, persist the re-encrypted tokens, and return them encrypted', async () => {
it('should decrypt the stored refresh token before sending to Google, then re-encrypt the rotated tokens before persisting', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.GOOGLE,
@@ -218,35 +231,29 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountEntity;
const newPlaintextTokens = {
const newTokens = {
accessToken: mockNewAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
};
jest
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newPlaintextTokens);
.mockResolvedValue(newTokens);
const result = await service.resolveTokens(
connectedAccount,
mockWorkspaceId,
);
const expectedEncryptedNewAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`;
const expectedEncryptedNewRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`;
expect(result).toEqual({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
});
expect(result).toEqual(newTokens);
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshTokenPlaintext);
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: mockConnectedAccountId, workspaceId: mockWorkspaceId },
expect.objectContaining({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`,
refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`,
lastCredentialsRefreshedAt: expect.any(Date),
}),
);
@@ -261,41 +268,35 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: null,
} as ConnectedAccountEntity;
const newPlaintextTokens = {
const newTokens = {
accessToken: mockNewAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
};
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newPlaintextTokens);
.mockResolvedValue(newTokens);
const result = await service.resolveTokens(
connectedAccount,
mockWorkspaceId,
);
const expectedEncryptedNewAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`;
const expectedEncryptedNewRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`;
expect(result).toEqual({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
});
expect(result).toEqual(newTokens);
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshTokenPlaintext);
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: mockConnectedAccountId, workspaceId: mockWorkspaceId },
expect.objectContaining({
accessToken: expectedEncryptedNewAccessToken,
refreshToken: expectedEncryptedNewRefreshToken,
accessToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockNewAccessTokenPlaintext})`,
refreshToken: `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`,
lastCredentialsRefreshedAt: expect.any(Date),
}),
);
});
it('should return the encrypted access token and null refresh token when access token is valid but no refresh token exists', async () => {
it('should return decrypted access token and null refresh token when access token is valid but no refresh token exists', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.APP,
@@ -310,12 +311,18 @@ describe('ConnectedAccountRefreshTokensService', () => {
);
expect(result).toEqual({
accessToken: mockEncryptedAccessToken,
accessToken: mockAccessTokenPlaintext,
refreshToken: null,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).not.toHaveBeenCalled();
).toHaveBeenCalledWith({
ciphertext: mockEncryptedAccessToken,
workspaceId: mockWorkspaceId,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).toHaveBeenCalledTimes(1);
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
@@ -467,7 +474,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
});
describe('resolveTokens - OIDC/SAML', () => {
it('should return existing encrypted tokens for OIDC as-is without attempting a refresh', async () => {
it('should decrypt and return existing tokens for OIDC without attempting a refresh', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.OIDC,
@@ -482,12 +489,9 @@ describe('ConnectedAccountRefreshTokensService', () => {
);
expect(result).toEqual({
accessToken: mockEncryptedAccessToken,
refreshToken: mockEncryptedRefreshToken,
accessToken: mockAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).not.toHaveBeenCalled();
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
@@ -497,7 +501,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
it('should return existing encrypted tokens for SAML as-is without attempting a refresh', async () => {
it('should decrypt and return existing tokens for SAML without attempting a refresh', async () => {
const connectedAccount = {
id: mockConnectedAccountId,
provider: ConnectedAccountProvider.SAML,
@@ -512,12 +516,9 @@ describe('ConnectedAccountRefreshTokensService', () => {
);
expect(result).toEqual({
accessToken: mockEncryptedAccessToken,
refreshToken: mockEncryptedRefreshToken,
accessToken: mockAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
});
expect(
connectedAccountTokenEncryptionService.decrypt,
).not.toHaveBeenCalled();
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
@@ -12,6 +12,8 @@ import {
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
@@ -32,6 +34,7 @@ export class ConnectedAccountRefreshTokensService {
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService,
private readonly appOAuthRefreshAccessTokenService: AppOAuthRefreshAccessTokenService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@@ -49,7 +52,7 @@ export class ConnectedAccountRefreshTokensService {
`Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
);
return this.getExistingEncryptedTokens(connectedAccount, workspaceId);
return this.decryptExistingTokens(connectedAccount, workspaceId);
}
const encryptedRefreshToken = connectedAccount.refreshToken;
@@ -72,11 +75,16 @@ export class ConnectedAccountRefreshTokensService {
);
}
private getExistingEncryptedTokens(
private decryptExistingTokens(
connectedAccount: ConnectedAccountEntity,
workspaceId: string,
): ConnectedAccountTokens {
if (!isDefined(connectedAccount.accessToken)) {
const {
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
} = connectedAccount;
if (!isDefined(encryptedAccessToken)) {
throw new ConnectedAccountRefreshAccessTokenException(
`Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND,
@@ -84,8 +92,16 @@ export class ConnectedAccountRefreshTokensService {
}
return {
accessToken: connectedAccount.accessToken,
refreshToken: connectedAccount.refreshToken,
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
ciphertext: encryptedAccessToken,
workspaceId,
}),
refreshToken: isDefined(encryptedRefreshToken)
? this.connectedAccountTokenEncryptionService.decrypt({
ciphertext: encryptedRefreshToken,
workspaceId,
})
: null,
};
}
@@ -100,34 +116,35 @@ export class ConnectedAccountRefreshTokensService {
workspaceId,
});
const plaintextTokens = await this.refreshTokens(
const connectedAccountTokens = await this.refreshTokens(
connectedAccount,
decryptedRefreshToken,
workspaceId,
);
const {
encryptedAccessToken,
encryptedAccessToken: reEncryptedAccessToken,
encryptedRefreshToken: reEncryptedRefreshToken,
} = this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken: plaintextTokens.accessToken,
refreshToken: plaintextTokens.refreshToken,
accessToken: connectedAccountTokens.accessToken,
refreshToken: connectedAccountTokens.refreshToken,
workspaceId,
});
await this.connectedAccountRepository.update(
{ id: connectedAccount.id, workspaceId },
{
accessToken: encryptedAccessToken,
refreshToken: reEncryptedRefreshToken,
lastCredentialsRefreshedAt: new Date(),
},
);
const authContext = buildSystemAuthContext(workspaceId);
return {
accessToken: encryptedAccessToken,
refreshToken: reEncryptedRefreshToken,
};
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
await this.connectedAccountRepository.update(
{ id: connectedAccount.id, workspaceId },
{
accessToken: reEncryptedAccessToken,
refreshToken: reEncryptedRefreshToken,
lastCredentialsRefreshedAt: new Date(),
},
);
}, authContext);
return connectedAccountTokens;
}
async isAccessTokenStillValid(
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
@@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { MessageChannelSyncStatus } from 'twenty-shared/types';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { SecretEncryptionExceptionCode } from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception';
import {
type TwentyORMException,
TwentyORMExceptionCode,
@@ -27,6 +28,8 @@ export enum MessageImportSyncStep {
@Injectable()
export class MessageImportExceptionHandlerService {
private readonly logger = new Logger(MessageImportExceptionHandlerService.name);
constructor(
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
@@ -88,16 +91,37 @@ export class MessageImportExceptionHandlerService {
case MessageImportDriverExceptionCode.ACCESS_TOKEN_MISSING:
case MessageImportDriverExceptionCode.UNKNOWN:
case MessageImportDriverExceptionCode.UNKNOWN_NETWORK_ERROR:
await this.handleUnknownException(
exception,
syncStep,
messageChannel,
workspaceId,
);
break;
case SecretEncryptionExceptionCode.MALFORMED_ENVELOPE:
await this.handleMalformedEnvelopeException(
exception,
syncStep,
messageChannel,
workspaceId,
);
break;
default:
await this.handleUnknownException(
exception,
syncStep,
messageChannel,
workspaceId,
);
break;
}
} else {
await this.handleUnknownException(exception, messageChannel, workspaceId);
await this.handleUnknownException(
exception,
syncStep,
messageChannel,
workspaceId,
);
}
}
@@ -201,10 +225,15 @@ export class MessageImportExceptionHandlerService {
private async handleUnknownException(
exception: Error,
syncStep: MessageImportSyncStep,
messageChannel: Pick<MessageChannelEntity, 'id'>,
workspaceId: string,
): Promise<void> {
this.exceptionHandlerService.captureExceptions([exception], {
additionalData: {
messageChannelId: messageChannel.id,
syncStep,
},
workspace: { id: workspaceId },
});
await this.messageChannelSyncStatusService.markAsFailed(
@@ -214,6 +243,19 @@ export class MessageImportExceptionHandlerService {
);
}
private async handleMalformedEnvelopeException(
exception: Error,
syncStep: MessageImportSyncStep,
messageChannel: Pick<MessageChannelEntity, 'id'>,
workspaceId: string,
): Promise<void> {
this.logger.warn(
`Skipping Sentry capture for malformed connected account token envelope while importing messages for message channel ${messageChannel.id} in workspace ${workspaceId} at ${syncStep}: ${exception.message}`,
);
await this.handlePermanentException(messageChannel, workspaceId);
}
private async handlePermanentException(
messageChannel: Pick<MessageChannelEntity, 'id'>,
workspaceId: string,
+11 -5
View File
@@ -90,16 +90,22 @@ const nextConfig: LinariaConfig = {
];
},
async rewrites() {
const localeAlternation = DEPLOYED_LOCALE_URL_SEGMENTS.join('|');
return {
beforeFiles: [
// Root rewrites to the source locale.
{ source: '/', destination: '/en' },
// Any path that isn't already locale-prefixed (en/, fr/), an internal
// Next.js path, a static asset folder, or a file with an extension
// rewrites to the source locale prefix. Mirrors proxy.ts Rule 4.
// Multi-segment paths (e.g. /customers/9dots, /articles/my-post).
// The Worker's path-to-regexp does not allow :param(regex) to match
// across "/" boundaries, so we split multi-segment into :first/:rest+.
{
source:
'/:rest((?!en$|en/|fr$|fr/|api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|illustrations|lottie|fonts|.+\\..+).+)',
source: `/:first((?!(?:${localeAlternation}|api|_next|images|illustrations|halftone|lottie|fonts)(?=/))[^/.]+)/:rest+`,
destination: '/en/:first/:rest+',
},
// Single-segment paths (e.g. /pricing, /customers, /why-twenty).
{
source: `/:rest((?!${DEPLOYED_LOCALE_URL_SEGMENTS.map((s) => `${s}$|${s}/`).join('|')}|api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|images|illustrations|lottie|fonts|.+\\..+).+)`,
destination: '/en/:rest',
},
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

@@ -1,15 +1,14 @@
import { msg } from '@lingui/core/macro';
import { type CaseStudyData } from '@/lib/customers';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { Heading, HeadingPart } from '@/design-system/components';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { msg } from '@lingui/core/macro';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.nineDots;
const META_TITLE = msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`;
const META_DESCRIPTION = msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`;
@@ -51,7 +50,7 @@ export default async function NineDotsCaseStudyPage({
authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp',
authorRole: msg`Founder, Nine Dots Ventures`,
clientIcon: 'nine-dots',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`Real Estate`,
kpis: [
{ value: msg`150 hrs`, label: msg`Saved / month` },
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { type CaseStudyData } from '@/lib/customers';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
@@ -8,8 +8,7 @@ import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1687600154329-150952c73169?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.actEducation;
const META_TITLE = msg`Burned by vendor lock-in, AC&T built a CRM they actually own | Twenty`;
const META_DESCRIPTION = msg`How AC&T Education Migration and Flycoder replaced a shuttered vendor CRM with self-hosted Twenty, with 90%+ lower cost and full ownership.`;
@@ -51,7 +50,7 @@ export default async function ActEducationCaseStudyPage({
authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp',
authorRole: msg`CRM Engineer, AC&T Education Migration`,
clientIcon: 'act-education',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`Education`,
kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }],
},
@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { Heading, HeadingPart } from '@/design-system/components';
import type { CaseStudyData } from '@/lib/customers';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
@@ -8,8 +8,7 @@ import {
import { buildLocalizedMetadata } from '@/lib/seo';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1702047149248-a6049168a2a8?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.alternativePartners;
const META_TITLE = msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`;
const META_DESCRIPTION = msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`;
@@ -51,7 +50,7 @@ export default async function AlternativePartnersCaseStudyPage({
authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
authorRole: msg`Principal and Founder, Alternative Partners`,
clientIcon: 'alternative-partners',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`Consulting`,
kpis: [
{ value: msg`AI-assisted`, label: msg`Salesforce migration` },
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { type CaseStudyData } from '@/lib/customers';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
@@ -8,8 +8,7 @@ import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.elevateConsulting;
const META_TITLE = msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`;
const META_DESCRIPTION = msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`;
@@ -53,7 +52,7 @@ export default async function ElevateConsultingCaseStudyPage({
author: 'Justin Beadle',
authorRole: msg`Director of Digital and Information, Elevate Consulting`,
clientIcon: 'elevate-consulting',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`Management Consulting`,
kpis: [
{ value: msg`1 click`, label: msg`Proposal automation` },
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { type CaseStudyData } from '@/lib/customers';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
@@ -8,8 +8,7 @@ import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.netzero;
const META_TITLE = msg`A CRM that grows with you | NetZero & Twenty`;
const META_DESCRIPTION = msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`;
@@ -50,7 +49,7 @@ export default async function NetZeroCaseStudyPage({
authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp',
authorRole: msg`Co-founder, NetZero`,
clientIcon: 'netzero',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`Agribusiness`,
kpis: [
{ value: msg`3 product lines`, label: msg`On a single CRM` },
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { type CaseStudyData } from '@/lib/customers';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
@@ -8,8 +8,7 @@ import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.w3villa;
const META_TITLE = msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`;
const META_DESCRIPTION = msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`;
@@ -51,7 +50,7 @@ export default async function W3villaCaseStudyPage({
authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
authorRole: msg`VP of Engineering, W3villa Technologies`,
clientIcon: 'w3villa',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: HERO_IMAGE,
industry: msg`EdTech`,
kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
},
@@ -2,7 +2,16 @@ import { msg } from '@lingui/core/macro';
import type { CaseStudyCatalogEntry } from '@/lib/customers/types';
import { theme } from '@/theme';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.webp';
const CASE_STUDY_IMAGE_BASE = '/images/customers/case-studies';
export const CASE_STUDY_HERO_IMAGES = {
nineDots: `${CASE_STUDY_IMAGE_BASE}/nine-dots.webp`,
alternativePartners: `${CASE_STUDY_IMAGE_BASE}/alternative-partners.webp`,
netzero: `${CASE_STUDY_IMAGE_BASE}/netzero.webp`,
actEducation: `${CASE_STUDY_IMAGE_BASE}/act-education.webp`,
w3villa: `${CASE_STUDY_IMAGE_BASE}/w3villa.webp`,
elevateConsulting: `${CASE_STUDY_IMAGE_BASE}/elevate-consulting.webp`,
} as const;
export const CASE_STUDY_HALFTONE_PALETTE: readonly {
dashColor: string;
@@ -57,13 +66,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
author: 'Mike Babiy & Azmat Parveen',
authorAvatarSrc: '/images/partner/testimonials/mike-babiy.webp',
clientIcon: 'nine-dots',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.nineDots,
},
catalogCard: {
summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`,
date: msg`Jul 2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.nineDots,
},
},
{
@@ -80,13 +88,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
author: 'Benjamin Reynolds',
authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
clientIcon: 'alternative-partners',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.alternativePartners,
},
catalogCard: {
summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`,
date: msg`2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.alternativePartners,
},
},
{
@@ -103,13 +110,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
author: 'Olivier Reinaud',
authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.webp',
clientIcon: 'netzero',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.netzero,
},
catalogCard: {
summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`,
date: msg`2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.netzero,
},
},
{
@@ -123,13 +129,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
author: 'Joseph Chiang',
authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.webp',
clientIcon: 'act-education',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.actEducation,
},
catalogCard: {
summary: msg`AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.`,
date: msg`2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1687600154329-150952c73169?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.actEducation,
},
},
{
@@ -143,13 +148,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
author: 'Amrendra Pratap Singh',
authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
clientIcon: 'w3villa',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.w3villa,
},
catalogCard: {
summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`,
date: msg`2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.w3villa,
},
},
{
@@ -171,13 +175,12 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
title: msg`Twenty as the API backbone of a go-to-market stack`,
author: 'Justin Beadle',
clientIcon: 'elevate-consulting',
heroImageSrc: PLACEHOLDER_HERO,
heroImageSrc: CASE_STUDY_HERO_IMAGES.elevateConsulting,
},
catalogCard: {
summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`,
date: msg`Jun 2025`,
coverImageSrc:
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80',
coverImageSrc: CASE_STUDY_HERO_IMAGES.elevateConsulting,
},
},
];
@@ -11,5 +11,6 @@ export type {
export {
CASE_STUDY_CATALOG_ENTRIES,
CASE_STUDY_HALFTONE_PALETTE,
CASE_STUDY_HERO_IMAGES,
getCaseStudyPalette,
} from './case-study-catalog';