Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776f5afe3c | ||
|
|
8826d12a18 | ||
|
|
abf5902ab5 | ||
|
|
a78c4c9fe8 | ||
|
|
a9ff1a9d3c |
@@ -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>;
|
||||
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
@@ -66,6 +66,7 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
|
||||
return (
|
||||
<SettingsApplicationRegistrationConfigTab
|
||||
registration={registration}
|
||||
fromAdmin
|
||||
/>
|
||||
);
|
||||
case 'oauth':
|
||||
|
||||
+19
-3
@@ -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> {
|
||||
|
||||
+8
@@ -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' },
|
||||
|
||||
-2
@@ -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],
|
||||
|
||||
+2
-7
@@ -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,
|
||||
};
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_CURRENT_VERSION = '2.7.0' as const;
|
||||
export const TWENTY_CURRENT_VERSION = '2.8.0' as const;
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@
|
||||
*/
|
||||
|
||||
export const TWENTY_NEXT_VERSIONS = [
|
||||
'2.8.0',
|
||||
'2.9.0',
|
||||
] as const;
|
||||
|
||||
+1
@@ -18,4 +18,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
|
||||
'2.4.0',
|
||||
'2.5.0',
|
||||
'2.6.0',
|
||||
'2.7.0',
|
||||
] as const;
|
||||
|
||||
+55
-54
@@ -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();
|
||||
|
||||
+38
-21
@@ -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(
|
||||
|
||||
+44
-2
@@ -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,
|
||||
|
||||
@@ -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 |
BIN
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';
|
||||
|
||||
Reference in New Issue
Block a user