diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 40196932fc..6a4d98291e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2699,6 +2699,12 @@ type AgentTurn { createdAt: DateTime! } +type WorkspaceAiStats { + conversationsCount: Int! + skillsCount: Int! + toolsCount: Int! +} + type CalendarChannel { id: UUID! handle: String! @@ -2996,6 +3002,7 @@ type Query { myConnectedAccounts: [ConnectedAccountPublicDTO!]! myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]! minimalMetadata: MinimalMetadata! + findWorkspaceAiStats: WorkspaceAiStats! chatThreads: [AgentChatThread!]! chatThread(id: UUID!): AgentChatThread! chatMessages(threadId: UUID!): [AgentMessage!]! @@ -3269,6 +3276,7 @@ type Mutation { authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp! renewToken(appToken: String!): AuthTokens! generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken! + generatePlaygroundToken: AuthToken! emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink! updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword! createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 981f2cc258..dc602df02b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2410,6 +2410,13 @@ export interface AgentTurn { __typename: 'AgentTurn' } +export interface WorkspaceAiStats { + conversationsCount: Scalars['Int'] + skillsCount: Scalars['Int'] + toolsCount: Scalars['Int'] + __typename: 'WorkspaceAiStats' +} + export interface CalendarChannel { id: Scalars['UUID'] handle: Scalars['String'] @@ -2597,6 +2604,7 @@ export interface Query { myConnectedAccounts: ConnectedAccountPublicDTO[] myCalendarChannels: CalendarChannel[] minimalMetadata: MinimalMetadata + findWorkspaceAiStats: WorkspaceAiStats chatThreads: AgentChatThread[] chatThread: AgentChatThread chatMessages: AgentMessage[] @@ -2803,6 +2811,7 @@ export interface Mutation { authorizeApp: AuthorizeApp renewToken: AuthTokens generateApiKeyToken: ApiKeyToken + generatePlaygroundToken: AuthToken emailPasswordResetLink: EmailPasswordResetLink updatePasswordViaResetToken: InvalidatePassword createApplicationRegistration: CreateApplicationRegistration @@ -5438,6 +5447,14 @@ export interface AgentTurnGenqlSelection{ __scalar?: boolean | number } +export interface WorkspaceAiStatsGenqlSelection{ + conversationsCount?: boolean | number + skillsCount?: boolean | number + toolsCount?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface CalendarChannelGenqlSelection{ id?: boolean | number handle?: boolean | number @@ -5625,6 +5642,7 @@ export interface QueryGenqlSelection{ myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} }) minimalMetadata?: MinimalMetadataGenqlSelection + findWorkspaceAiStats?: WorkspaceAiStatsGenqlSelection chatThreads?: AgentChatThreadGenqlSelection chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} }) chatMessages?: (AgentMessageGenqlSelection & { __args: {threadId: Scalars['UUID']} }) @@ -5852,6 +5870,7 @@ export interface MutationGenqlSelection{ authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} }) renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} }) generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} }) + generatePlaygroundToken?: AuthTokenGenqlSelection emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} }) updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} }) createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} }) @@ -8152,6 +8171,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const WorkspaceAiStats_possibleTypes: string[] = ['WorkspaceAiStats'] + export const isWorkspaceAiStats = (obj?: { __typename?: any } | null): obj is WorkspaceAiStats => { + if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceAiStats"') + return WorkspaceAiStats_possibleTypes.includes(obj.__typename) + } + + + const CalendarChannel_possibleTypes: string[] = ['CalendarChannel'] export const isCalendarChannel = (obj?: { __typename?: any } | null): obj is CalendarChannel => { if (!obj?.__typename) throw new Error('__typename is missing in "isCalendarChannel"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 222cee617f..1562ee2da9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -61,25 +61,25 @@ export default { 205, 230, 293, - 302, 303, 304, 305, - 307, + 306, 308, 309, 310, 311, 312, 313, - 316, - 318, - 327, - 334, - 341, - 377, - 459, - 471 + 314, + 317, + 319, + 328, + 335, + 342, + 378, + 460, + 472 ], "types": { "BillingProductDTO": { @@ -5478,6 +5478,20 @@ export default { 1 ] }, + "WorkspaceAiStats": { + "conversationsCount": [ + 21 + ], + "skillsCount": [ + 21 + ], + "toolsCount": [ + 21 + ], + "__typename": [ + 1 + ] + }, "CalendarChannel": { "id": [ 3 @@ -5486,19 +5500,19 @@ export default { 1 ], "syncStatus": [ - 302 - ], - "syncStage": [ 303 ], - "visibility": [ + "syncStage": [ 304 ], + "visibility": [ + 305 + ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 305 + 306 ], "isSyncEnabled": [ 6 @@ -5534,22 +5548,22 @@ export default { 3 ], "visibility": [ - 307 + 308 ], "handle": [ 1 ], "type": [ - 308 + 309 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 309 + 310 ], "messageFolderImportPolicy": [ - 310 + 311 ], "excludeNonProfessionalEmails": [ 6 @@ -5558,7 +5572,7 @@ export default { 6 ], "pendingGroupEmailsAction": [ - 311 + 312 ], "isSyncEnabled": [ 6 @@ -5567,10 +5581,10 @@ export default { 4 ], "syncStatus": [ - 312 + 313 ], "syncStage": [ - 313 + 314 ], "syncStageStartedAt": [ 4 @@ -5606,7 +5620,7 @@ export default { "MessageChannelSyncStage": {}, "CreateEmailGroupChannelOutput": { "messageChannel": [ - 306 + 307 ], "forwardingAddress": [ 1 @@ -5635,7 +5649,7 @@ export default { 1 ], "pendingSyncAction": [ - 316 + 317 ], "messageChannelId": [ 3 @@ -5653,7 +5667,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 318 + 319 ], "hash": [ 1 @@ -5720,13 +5734,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 319 - ], - "views": [ 320 ], + "views": [ + 321 + ], "collectionHashes": [ - 317 + 318 ], "__typename": [ 1 @@ -5879,7 +5893,7 @@ export default { 2, { "input": [ - 323, + 324, "GetApiKeyInput!" ] } @@ -5976,7 +5990,7 @@ export default { 41, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -5988,7 +6002,7 @@ export default { 15, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -5997,7 +6011,7 @@ export default { 1, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -6080,7 +6094,7 @@ export default { 25, { "input": [ - 325, + 326, "AgentIdInput!" ] } @@ -6152,7 +6166,7 @@ export default { } ], "myMessageFolders": [ - 315, + 316, { "messageChannelId": [ 3 @@ -6160,7 +6174,7 @@ export default { } ], "myMessageChannels": [ - 306, + 307, { "connectedAccountId": [ 3 @@ -6171,7 +6185,7 @@ export default { 269 ], "myCalendarChannels": [ - 301, + 302, { "connectedAccountId": [ 3 @@ -6179,7 +6193,10 @@ export default { } ], "minimalMetadata": [ - 321 + 322 + ], + "findWorkspaceAiStats": [ + 301 ], "chatThreads": [ 292 @@ -6375,7 +6392,7 @@ export default { 289, { "input": [ - 326, + 327, "EventLogQueryInput!" ] } @@ -6384,7 +6401,7 @@ export default { 284, { "input": [ - 330, + 331, "PieChartDataInput!" ] } @@ -6393,7 +6410,7 @@ export default { 282, { "input": [ - 331, + 332, "LineChartDataInput!" ] } @@ -6402,7 +6419,7 @@ export default { 279, { "input": [ - 332, + 333, "BarChartDataInput!" ] } @@ -6452,7 +6469,7 @@ export default { 257, { "input": [ - 333 + 334 ] } ], @@ -6501,10 +6518,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 327 + 328 ], "filters": [ - 328 + 329 ], "first": [ 21 @@ -6525,7 +6542,7 @@ export default { 1 ], "dateRange": [ - 329 + 330 ], "recordId": [ 1 @@ -6592,7 +6609,7 @@ export default { 1 ], "operationTypes": [ - 334 + 335 ], "__typename": [ 1 @@ -6604,7 +6621,7 @@ export default { 6, { "input": [ - 336, + 337, "AddQuerySubscriptionInput!" ] } @@ -6613,7 +6630,7 @@ export default { 6, { "input": [ - 337, + 338, "RemoveQueryFromEventStreamInput!" ] } @@ -6622,7 +6639,7 @@ export default { 161, { "inputs": [ - 338, + 339, "[CreateNavigationMenuItemInput!]!" ] } @@ -6631,7 +6648,7 @@ export default { 161, { "input": [ - 338, + 339, "CreateNavigationMenuItemInput!" ] } @@ -6640,7 +6657,7 @@ export default { 161, { "inputs": [ - 339, + 340, "[UpdateOneNavigationMenuItemInput!]!" ] } @@ -6649,7 +6666,7 @@ export default { 161, { "input": [ - 339, + 340, "UpdateOneNavigationMenuItemInput!" ] } @@ -6676,7 +6693,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ] } @@ -6697,7 +6714,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ] } @@ -6706,7 +6723,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ] } @@ -6715,7 +6732,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ] } @@ -6724,7 +6741,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ] } @@ -6733,7 +6750,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ], "fieldMetadataId": [ @@ -6746,7 +6763,7 @@ export default { 135, { "file": [ - 341, + 342, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -6759,7 +6776,7 @@ export default { 61, { "input": [ - 342, + 343, "CreateViewFilterGroupInput!" ] } @@ -6772,7 +6789,7 @@ export default { "String!" ], "input": [ - 343, + 344, "UpdateViewFilterGroupInput!" ] } @@ -6799,7 +6816,7 @@ export default { 63, { "input": [ - 344, + 345, "CreateViewFilterInput!" ] } @@ -6808,7 +6825,7 @@ export default { 63, { "input": [ - 345, + 346, "UpdateViewFilterInput!" ] } @@ -6817,7 +6834,7 @@ export default { 63, { "input": [ - 347, + 348, "DeleteViewFilterInput!" ] } @@ -6826,7 +6843,7 @@ export default { 63, { "input": [ - 348, + 349, "DestroyViewFilterInput!" ] } @@ -6835,7 +6852,7 @@ export default { 69, { "input": [ - 349, + 350, "CreateViewInput!" ] } @@ -6848,7 +6865,7 @@ export default { "String!" ], "input": [ - 350, + 351, "UpdateViewInput!" ] } @@ -6875,7 +6892,7 @@ export default { 69, { "input": [ - 351, + 352, "UpsertViewWidgetInput!" ] } @@ -6884,7 +6901,7 @@ export default { 66, { "input": [ - 356, + 357, "CreateViewSortInput!" ] } @@ -6893,7 +6910,7 @@ export default { 66, { "input": [ - 357, + 358, "UpdateViewSortInput!" ] } @@ -6902,7 +6919,7 @@ export default { 6, { "input": [ - 359, + 360, "DeleteViewSortInput!" ] } @@ -6911,7 +6928,7 @@ export default { 6, { "input": [ - 360, + 361, "DestroyViewSortInput!" ] } @@ -6920,7 +6937,7 @@ export default { 59, { "input": [ - 361, + 362, "UpdateViewFieldInput!" ] } @@ -6929,7 +6946,7 @@ export default { 59, { "input": [ - 363, + 364, "CreateViewFieldInput!" ] } @@ -6938,7 +6955,7 @@ export default { 59, { "inputs": [ - 363, + 364, "[CreateViewFieldInput!]!" ] } @@ -6947,7 +6964,7 @@ export default { 59, { "input": [ - 364, + 365, "DeleteViewFieldInput!" ] } @@ -6956,7 +6973,7 @@ export default { 59, { "input": [ - 365, + 366, "DestroyViewFieldInput!" ] } @@ -6965,7 +6982,7 @@ export default { 68, { "input": [ - 366, + 367, "UpdateViewFieldGroupInput!" ] } @@ -6974,7 +6991,7 @@ export default { 68, { "input": [ - 368, + 369, "CreateViewFieldGroupInput!" ] } @@ -6983,7 +7000,7 @@ export default { 68, { "inputs": [ - 368, + 369, "[CreateViewFieldGroupInput!]!" ] } @@ -6992,7 +7009,7 @@ export default { 68, { "input": [ - 369, + 370, "DeleteViewFieldGroupInput!" ] } @@ -7001,7 +7018,7 @@ export default { 68, { "input": [ - 370, + 371, "DestroyViewFieldGroupInput!" ] } @@ -7010,7 +7027,7 @@ export default { 69, { "input": [ - 371, + 372, "UpsertFieldsWidgetInput!" ] } @@ -7019,7 +7036,7 @@ export default { 2, { "input": [ - 374, + 375, "CreateApiKeyInput!" ] } @@ -7028,7 +7045,7 @@ export default { 2, { "input": [ - 375, + 376, "UpdateApiKeyInput!" ] } @@ -7037,7 +7054,7 @@ export default { 2, { "input": [ - 376, + 377, "RevokeApiKeyInput!" ] } @@ -7079,7 +7096,7 @@ export default { 128, { "type": [ - 377, + 378, "AnalyticsType!" ], "name": [ @@ -7180,7 +7197,7 @@ export default { 134, { "input": [ - 378, + 379, "CreateApprovedAccessDomainInput!" ] } @@ -7189,7 +7206,7 @@ export default { 6, { "input": [ - 379, + 380, "DeleteApprovedAccessDomainInput!" ] } @@ -7198,7 +7215,7 @@ export default { 134, { "input": [ - 380, + 381, "ValidateApprovedAccessDomainInput!" ] } @@ -7207,7 +7224,7 @@ export default { 121, { "input": [ - 381, + 382, "CreatePageLayoutTabInput!" ] } @@ -7220,7 +7237,7 @@ export default { "String!" ], "input": [ - 382, + 383, "UpdatePageLayoutTabInput!" ] } @@ -7238,7 +7255,7 @@ export default { 122, { "input": [ - 383, + 384, "CreatePageLayoutInput!" ] } @@ -7251,7 +7268,7 @@ export default { "String!" ], "input": [ - 384, + 385, "UpdatePageLayoutInput!" ] } @@ -7273,7 +7290,7 @@ export default { "String!" ], "input": [ - 385, + 386, "UpdatePageLayoutWithTabsInput!" ] } @@ -7340,7 +7357,7 @@ export default { 133, { "input": [ - 389, + 390, "SendEmailViaDomainInput!" ] } @@ -7366,7 +7383,7 @@ export default { 84, { "input": [ - 390, + 391, "CreatePageLayoutWidgetInput!" ] } @@ -7379,7 +7396,7 @@ export default { "String!" ], "input": [ - 391, + 392, "UpdatePageLayoutWidgetInput!" ] } @@ -7397,7 +7414,7 @@ export default { 41, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -7406,7 +7423,7 @@ export default { 41, { "input": [ - 392, + 393, "CreateLogicFunctionFromSourceInput!" ] } @@ -7415,7 +7432,7 @@ export default { 170, { "input": [ - 393, + 394, "ExecuteOneLogicFunctionInput!" ] } @@ -7424,7 +7441,7 @@ export default { 6, { "input": [ - 394, + 395, "UpdateLogicFunctionFromSourceInput!" ] } @@ -7433,7 +7450,7 @@ export default { 35, { "input": [ - 396, + 397, "CreateCommandMenuItemInput!" ] } @@ -7442,7 +7459,7 @@ export default { 35, { "input": [ - 397, + 398, "UpdateCommandMenuItemInput!" ] } @@ -7460,7 +7477,7 @@ export default { 34, { "input": [ - 398, + 399, "CreateFrontComponentInput!" ] } @@ -7469,7 +7486,7 @@ export default { 34, { "input": [ - 399, + 400, "UpdateFrontComponentInput!" ] } @@ -7487,7 +7504,7 @@ export default { 55, { "input": [ - 401, + 402, "CreateOneObjectInput!" ] } @@ -7496,7 +7513,7 @@ export default { 55, { "input": [ - 403, + 404, "DeleteOneObjectInput!" ] } @@ -7505,7 +7522,7 @@ export default { 55, { "input": [ - 404, + 405, "UpdateOneObjectInput!" ] } @@ -7514,7 +7531,7 @@ export default { 46, { "input": [ - 406, + 407, "CreateOneIndexInput!" ] } @@ -7523,7 +7540,7 @@ export default { 46, { "input": [ - 409, + 410, "DeleteOneIndexInput!" ] } @@ -7532,7 +7549,7 @@ export default { 25, { "input": [ - 410, + 411, "CreateAgentInput!" ] } @@ -7541,7 +7558,7 @@ export default { 25, { "input": [ - 411, + 412, "UpdateAgentInput!" ] } @@ -7550,7 +7567,7 @@ export default { 25, { "input": [ - 325, + 326, "AgentIdInput!" ] } @@ -7572,7 +7589,7 @@ export default { 29, { "createRoleInput": [ - 412, + 413, "CreateRoleInput!" ] } @@ -7581,7 +7598,7 @@ export default { 29, { "updateRoleInput": [ - 413, + 414, "UpdateRoleInput!" ] } @@ -7599,7 +7616,7 @@ export default { 16, { "upsertObjectPermissionsInput": [ - 415, + 416, "UpsertObjectPermissionsInput!" ] } @@ -7608,7 +7625,7 @@ export default { 27, { "upsertPermissionFlagsInput": [ - 417, + 418, "UpsertPermissionFlagsInput!" ] } @@ -7617,7 +7634,7 @@ export default { 26, { "upsertFieldPermissionsInput": [ - 418, + 419, "UpsertFieldPermissionsInput!" ] } @@ -7626,7 +7643,7 @@ export default { 233, { "input": [ - 420, + 421, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -7657,7 +7674,7 @@ export default { 274, { "input": [ - 423, + 424, "CreateWebhookInput!" ] } @@ -7666,7 +7683,7 @@ export default { 274, { "input": [ - 424, + 425, "UpdateWebhookInput!" ] } @@ -7684,7 +7701,7 @@ export default { 43, { "input": [ - 426, + 427, "CreateOneFieldMetadataInput!" ] } @@ -7693,7 +7710,7 @@ export default { 43, { "input": [ - 428, + 429, "UpdateOneFieldMetadataInput!" ] } @@ -7702,7 +7719,7 @@ export default { 43, { "input": [ - 430, + 431, "DeleteOneFieldInput!" ] } @@ -7711,7 +7728,7 @@ export default { 65, { "input": [ - 431, + 432, "CreateViewGroupInput!" ] } @@ -7720,7 +7737,7 @@ export default { 65, { "inputs": [ - 431, + 432, "[CreateViewGroupInput!]!" ] } @@ -7729,7 +7746,7 @@ export default { 65, { "input": [ - 432, + 433, "UpdateViewGroupInput!" ] } @@ -7738,7 +7755,7 @@ export default { 65, { "inputs": [ - 432, + 433, "[UpdateViewGroupInput!]!" ] } @@ -7747,7 +7764,7 @@ export default { 65, { "input": [ - 434, + 435, "DeleteViewGroupInput!" ] } @@ -7756,49 +7773,49 @@ export default { 65, { "input": [ - 435, + 436, "DestroyViewGroupInput!" ] } ], "updateMessageFolder": [ - 315, + 316, { "input": [ - 436, + 437, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 315, + 316, { "input": [ - 438, + 439, "UpdateMessageFoldersInput!" ] } ], "updateMessageChannel": [ - 306, + 307, { "input": [ - 439, + 440, "UpdateMessageChannelInput!" ] } ], "createEmailGroupChannel": [ - 314, + 315, { "input": [ - 441, + 442, "CreateEmailGroupChannelInput!" ] } ], "deleteEmailGroupChannel": [ - 306, + 307, { "id": [ 3, @@ -7816,10 +7833,10 @@ export default { } ], "updateCalendarChannel": [ - 301, + 302, { "input": [ - 442, + 443, "UpdateCalendarChannelInput!" ] } @@ -7849,7 +7866,7 @@ export default { 1 ], "fileAttachments": [ - 444, + 445, "[FileAttachmentInput!]" ] } @@ -7916,7 +7933,7 @@ export default { 290, { "input": [ - 445, + 446, "CreateSkillInput!" ] } @@ -7925,7 +7942,7 @@ export default { 290, { "input": [ - 446, + 447, "UpdateSkillInput!" ] } @@ -7983,7 +8000,7 @@ export default { 242, { "input": [ - 447, + 448, "GetAuthorizationUrlForSSOInput!" ] } @@ -8208,6 +8225,9 @@ export default { ] } ], + "generatePlaygroundToken": [ + 32 + ], "emailPasswordResetLink": [ 241, { @@ -8237,7 +8257,7 @@ export default { 201, { "input": [ - 448, + 449, "CreateApplicationRegistrationInput!" ] } @@ -8246,7 +8266,7 @@ export default { 7, { "input": [ - 449, + 450, "UpdateApplicationRegistrationInput!" ] } @@ -8273,7 +8293,7 @@ export default { 5, { "input": [ - 451, + 452, "CreateApplicationRegistrationVariableInput!" ] } @@ -8282,7 +8302,7 @@ export default { 5, { "input": [ - 452, + 453, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8300,7 +8320,7 @@ export default { 7, { "file": [ - 341, + 342, "Upload!" ], "universalIdentifier": [ @@ -8371,7 +8391,7 @@ export default { 6, { "input": [ - 454, + 455, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -8405,7 +8425,7 @@ export default { 75, { "data": [ - 455, + 456, "ActivateWorkspaceInput!" ] } @@ -8414,7 +8434,7 @@ export default { 75, { "data": [ - 456, + 457, "UpdateWorkspaceInput!" ] } @@ -8429,7 +8449,7 @@ export default { 6, { "workspaceMigration": [ - 457, + 458, "WorkspaceMigrationInput!" ] } @@ -8447,7 +8467,7 @@ export default { 224, { "input": [ - 460, + 461, "SetupOIDCSsoInput!" ] } @@ -8456,7 +8476,7 @@ export default { 224, { "input": [ - 461, + 462, "SetupSAMLSsoInput!" ] } @@ -8465,7 +8485,7 @@ export default { 220, { "input": [ - 462, + 463, "DeleteSsoInput!" ] } @@ -8474,7 +8494,7 @@ export default { 221, { "input": [ - 463, + 464, "EditSsoInput!" ] } @@ -8505,7 +8525,7 @@ export default { 286, { "input": [ - 464, + 465, "SendEmailInput!" ] } @@ -8527,7 +8547,7 @@ export default { "String!" ], "connectionParameters": [ - 466, + 467, "EmailAccountConnectionParameters!" ], "id": [ @@ -8539,7 +8559,7 @@ export default { 172, { "input": [ - 468, + 469, "UpdateLabPublicFeatureFlagInput!" ] } @@ -8590,7 +8610,7 @@ export default { 77, { "input": [ - 469, + 470, "CreateOneAppTokenInput!" ] } @@ -8657,7 +8677,7 @@ export default { 260, { "file": [ - 341, + 342, "Upload!" ], "applicationUniversalIdentifier": [ @@ -8665,7 +8685,7 @@ export default { "String!" ], "fileFolder": [ - 471, + 472, "FileFolder!" ], "filePath": [ @@ -8774,7 +8794,7 @@ export default { 3 ], "update": [ - 340 + 341 ], "__typename": [ 1 @@ -8884,7 +8904,7 @@ export default { 3 ], "update": [ - 346 + 347 ], "__typename": [ 1 @@ -9043,17 +9063,17 @@ export default { 3 ], "viewFields": [ - 352 - ], - "viewFilters": [ 353 ], - "viewFilterGroups": [ + "viewFilters": [ 354 ], - "viewSorts": [ + "viewFilterGroups": [ 355 ], + "viewSorts": [ + 356 + ], "__typename": [ 1 ] @@ -9163,7 +9183,7 @@ export default { 3 ], "update": [ - 358 + 359 ], "__typename": [ 1 @@ -9201,7 +9221,7 @@ export default { 3 ], "update": [ - 362 + 363 ], "__typename": [ 1 @@ -9277,7 +9297,7 @@ export default { 3 ], "update": [ - 367 + 368 ], "__typename": [ 1 @@ -9341,10 +9361,10 @@ export default { 3 ], "groups": [ - 372 + 373 ], "fields": [ - 373 + 374 ], "__typename": [ 1 @@ -9364,7 +9384,7 @@ export default { 6 ], "fields": [ - 373 + 374 ], "__typename": [ 1 @@ -9533,7 +9553,7 @@ export default { 3 ], "tabs": [ - 386 + 387 ], "__typename": [ 1 @@ -9556,7 +9576,7 @@ export default { 88 ], "widgets": [ - 387 + 388 ], "__typename": [ 1 @@ -9579,7 +9599,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9660,7 +9680,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9686,7 +9706,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9758,7 +9778,7 @@ export default { 3 ], "update": [ - 395 + 396 ], "__typename": [ 1 @@ -9918,7 +9938,7 @@ export default { 3 ], "update": [ - 400 + 401 ], "__typename": [ 1 @@ -9937,7 +9957,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 402 + 403 ], "__typename": [ 1 @@ -9997,7 +10017,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 405 + 406 ], "id": [ 3 @@ -10052,7 +10072,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 407 + 408 ], "__typename": [ 1 @@ -10063,7 +10083,7 @@ export default { 3 ], "fields": [ - 408 + 409 ], "indexType": [ 47 @@ -10210,7 +10230,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 414 + 415 ], "id": [ 3 @@ -10265,7 +10285,7 @@ export default { 3 ], "objectPermissions": [ - 416 + 417 ], "__typename": [ 1 @@ -10307,7 +10327,7 @@ export default { 3 ], "fieldPermissions": [ - 419 + 420 ], "__typename": [ 1 @@ -10338,10 +10358,10 @@ export default { 3 ], "predicates": [ - 421 + 422 ], "predicateGroups": [ - 422 + 423 ], "__typename": [ 1 @@ -10424,7 +10444,7 @@ export default { 3 ], "update": [ - 425 + 426 ], "__typename": [ 1 @@ -10449,7 +10469,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 427 + 428 ], "__typename": [ 1 @@ -10522,7 +10542,7 @@ export default { 3 ], "update": [ - 429 + 430 ], "__typename": [ 1 @@ -10614,7 +10634,7 @@ export default { 3 ], "update": [ - 433 + 434 ], "__typename": [ 1 @@ -10658,7 +10678,7 @@ export default { 3 ], "update": [ - 437 + 438 ], "__typename": [ 1 @@ -10677,7 +10697,7 @@ export default { 3 ], "update": [ - 437 + 438 ], "__typename": [ 1 @@ -10688,7 +10708,7 @@ export default { 3 ], "update": [ - 440 + 441 ], "__typename": [ 1 @@ -10696,16 +10716,16 @@ export default { }, "UpdateMessageChannelInputUpdates": { "visibility": [ - 307 + 308 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 309 + 310 ], "messageFolderImportPolicy": [ - 310 + 311 ], "isSyncEnabled": [ 6 @@ -10733,7 +10753,7 @@ export default { 3 ], "update": [ - 443 + 444 ], "__typename": [ 1 @@ -10741,13 +10761,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 304 + 305 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 305 + 306 ], "isSyncEnabled": [ 6 @@ -10849,7 +10869,7 @@ export default { 1 ], "update": [ - 450 + 451 ], "__typename": [ 1 @@ -10897,7 +10917,7 @@ export default { 1 ], "update": [ - 453 + 454 ], "__typename": [ 1 @@ -11015,7 +11035,7 @@ export default { }, "WorkspaceMigrationInput": { "actions": [ - 458 + 459 ], "__typename": [ 1 @@ -11023,10 +11043,10 @@ export default { }, "WorkspaceMigrationDeleteActionInput": { "type": [ - 459 + 460 ], "metadataName": [ - 318 + 319 ], "universalIdentifier": [ 1 @@ -11118,7 +11138,7 @@ export default { 1 ], "files": [ - 465 + 466 ], "__typename": [ 1 @@ -11137,13 +11157,13 @@ export default { }, "EmailAccountConnectionParameters": { "IMAP": [ - 467 + 468 ], "SMTP": [ - 467 + 468 ], "CALDAV": [ - 467 + 468 ], "__typename": [ 1 @@ -11182,7 +11202,7 @@ export default { }, "CreateOneAppTokenInput": { "appToken": [ - 470 + 471 ], "__typename": [ 1 @@ -11211,7 +11231,7 @@ export default { 234, { "input": [ - 473, + 474, "LogicFunctionLogsInput!" ] } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 35afc1b0d8..fb584c997b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2461,6 +2461,7 @@ export type Mutation = { executeOneLogicFunction: LogicFunctionExecutionResult; generateApiKeyToken: ApiKeyToken; generateApplicationToken: ApplicationTokenPair; + generatePlaygroundToken: AuthToken; generateTransientToken: TransientToken; getAuthTokensFromLoginToken: AuthTokens; getAuthTokensFromOTP: AuthTokens; @@ -4139,6 +4140,7 @@ export type Query = { findOneApplication: Application; findOneApplicationRegistration: ApplicationRegistration; findOneLogicFunction: LogicFunction; + findWorkspaceAiStats: WorkspaceAiStats; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; frontComponent?: Maybe; @@ -5953,6 +5955,13 @@ export enum WorkspaceActivationStatus { SUSPENDED = 'SUSPENDED' } +export type WorkspaceAiStats = { + __typename?: 'WorkspaceAiStats'; + conversationsCount: Scalars['Int']; + skillsCount: Scalars['Int']; + toolsCount: Scalars['Int']; +}; + export type WorkspaceInvitation = { __typename?: 'WorkspaceInvitation'; email: Scalars['String']; @@ -6235,6 +6244,11 @@ export type FindOneSkillQueryVariables = Exact<{ export type FindOneSkillQuery = { __typename?: 'Query', skill?: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } | null }; +export type FindWorkspaceAiStatsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FindWorkspaceAiStatsQuery = { __typename?: 'Query', findWorkspaceAiStats: { __typename?: 'WorkspaceAiStats', conversationsCount: number, skillsCount: number, toolsCount: number } }; + export type GetAgentTurnsQueryVariables = Exact<{ agentId: Scalars['UUID']; }>; @@ -6347,6 +6361,11 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{ export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } }; +export type GeneratePlaygroundTokenMutationVariables = Exact<{ [key: string]: never; }>; + + +export type GeneratePlaygroundTokenMutation = { __typename?: 'Mutation', generatePlaygroundToken: { __typename?: 'AuthToken', token: string, expiresAt: string } }; + export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>; @@ -8017,6 +8036,7 @@ export const FindManyAgentsDocument = {"kind":"Document","definitions":[{"kind": export const FindManySkillsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManySkills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const FindOneAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Agent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"responseFormat"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"modelConfiguration"}},{"kind":"Field","name":{"kind":"Name","value":"evaluationInputs"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const FindOneSkillDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneSkill"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skill"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const FindWorkspaceAiStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"conversationsCount"}},{"kind":"Field","name":{"kind":"Name","value":"skillsCount"}},{"kind":"Field","name":{"kind":"Name","value":"toolsCount"}}]}}]}}]} as unknown as DocumentNode; export const GetAgentTurnsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAgentTurns"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentTurns"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}}]}}]}}]} as unknown as DocumentNode; export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; @@ -8031,6 +8051,7 @@ export const FindOneApplicationSummaryDocument = {"kind":"Document","definitions export const AuthorizeAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authorizeApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"state"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorizeApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"clientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}}},{"kind":"Argument","name":{"kind":"Name","value":"codeChallenge"},"value":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}}},{"kind":"Argument","name":{"kind":"Name","value":"redirectUrl"},"value":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"Variable","name":{"kind":"Name","value":"state"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"redirectUrl"}}]}}]}}]} as unknown as DocumentNode; export const EmailPasswordResetLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EmailPasswordResetLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emailPasswordResetLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const GenerateApiKeyTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GenerateApiKeyToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateApiKeyToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"apiKeyId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiresAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]} as unknown as DocumentNode; +export const GeneratePlaygroundTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GeneratePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generatePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; export const GenerateTransientTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAuthTokensFromLoginTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; export const GetAuthTokensFromOtpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"otp"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"otp"},"value":{"kind":"Variable","name":{"kind":"Name","value":"otp"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/findWorkspaceAiStats.ts b/packages/twenty-front/src/modules/ai/graphql/queries/findWorkspaceAiStats.ts new file mode 100644 index 0000000000..4501ef2e67 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/graphql/queries/findWorkspaceAiStats.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const FIND_WORKSPACE_AI_STATS = gql` + query FindWorkspaceAiStats { + findWorkspaceAiStats { + conversationsCount + skillsCount + toolsCount + } + } +`; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index e4c1944f6a..5e250fbd18 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -220,6 +220,12 @@ const SettingsApplicationCommandMenuItemDetail = lazy(() => ), ); +const SettingsLayout = lazy(() => + import('~/pages/settings/layout/SettingsLayout').then((module) => ({ + default: module.SettingsLayout, + })), +); + const SettingsLayoutViewDetail = lazy(() => import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({ default: module.SettingsLayoutViewDetail, @@ -666,6 +672,40 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( path={SettingsPath.ApiWebhooks} element={} /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + } + > } /> } /> ( path={SettingsPath.LogicFunctionDetail} element={} /> - } /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> + + + } + > + } /> ( + { + key: 'frontComponentsSelector', + get: ({ get }) => { + const storeItem = get(metadataStoreState, 'frontComponents'); + + return storeItem.current as FlatFrontComponent[]; + }, + }, +); diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts index cbaaae0ce7..6e0c2892e3 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts @@ -24,13 +24,16 @@ export const useEnterLayoutCustomizationMode = () => { const { navigateSidePanel } = useNavigateSidePanel(); const { enqueueWarningSnackBar } = useSnackBar(); - const enterLayoutCustomizationMode = useCallback(() => { + // Returns whether customization mode is active afterward, so callers that + // navigate on entry can skip navigation when entry was blocked (e.g. a + // dashboard is mid-edit). + const enterLayoutCustomizationMode = useCallback((): boolean => { const isLayoutCustomizationModeAlreadyEnabled = store.get( isLayoutCustomizationModeEnabledState.atom, ); if (isLayoutCustomizationModeAlreadyEnabled) { - return; + return true; } const dashboardPageLayoutIdInEditMode = store.get( @@ -49,7 +52,7 @@ export const useEnterLayoutCustomizationMode = () => { message: t`Save or cancel dashboard changes before editing the layout.`, }); - return; + return false; } } @@ -82,6 +85,8 @@ export const useEnterLayoutCustomizationMode = () => { resetNavigationStack: true, }); } + + return true; }, [enqueueWarningSnackBar, navigateSidePanel, store]); return { enterLayoutCustomizationMode }; diff --git a/packages/twenty-front/src/modules/navigation/components/SettingsNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/SettingsNavigationDrawer.tsx index 36a149dc80..f176a32098 100644 --- a/packages/twenty-front/src/modules/navigation/components/SettingsNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/SettingsNavigationDrawer.tsx @@ -1,11 +1,28 @@ +import { NavigationDrawerAiChatContent } from '@/ai/components/NavigationDrawerAiChatContent'; +import { MainNavigationDrawerTabsRow } from '@/navigation/components/MainNavigationDrawerTabsRow'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { SettingsNavigationDrawerItems } from '@/settings/components/SettingsNavigationDrawerItems'; import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer'; import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent'; import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent'; import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; +import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState'; +import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; +import { useIsMobile } from 'twenty-ui/utilities'; import { AdvancedSettingsToggle } from 'twenty-ui/navigation'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; + +const StyledAdvancedToggleWrapper = styled.div<{ isMobile: boolean }>` + padding-left: ${({ isMobile }) => + isMobile ? '0' : themeCssVariables.spacing[5]}; + padding-right: ${({ isMobile }) => + isMobile ? '0' : themeCssVariables.spacing[8]}; +`; export const SettingsNavigationDrawer = ({ className, @@ -13,23 +30,46 @@ export const SettingsNavigationDrawer = ({ className?: string; }) => { const { t } = useLingui(); + const isMobile = useIsMobile(); const [isAdvancedModeEnabled, setIsAdvancedModeEnabled] = useAtomState( isAdvancedModeEnabledState, ); + const navigationDrawerActiveTab = useAtomStateValue( + navigationDrawerActiveTabState, + ); + const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI); + + const showAiChatContent = + hasAiPermission && + navigationDrawerActiveTab === NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY; return ( + {hasAiPermission && ( + + + + )} + - + {showAiChatContent ? ( + + ) : ( + + )} - - - + {!showAiChatContent && ( + + + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsSettingsSection.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsSettingsSection.tsx index 770dca1401..53355f075b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsSettingsSection.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsSettingsSection.tsx @@ -19,11 +19,16 @@ const StyledCardsContainer = styled.div` gap: ${themeCssVariables.spacing[4]}; margin-top: ${themeCssVariables.spacing[6]}; - @media (max-width: ${MOBILE_VIEWPORT}pxF) { + @media (max-width: ${MOBILE_VIEWPORT}px) { flex-direction: column; } `; +const StyledCardLinkSlot = styled.div` + flex: 1 1 0; + min-width: 0; +`; + export const SettingsAccountsSettingsSection = () => { const { theme } = useContext(ThemeContext); const { t } = useLingui(); @@ -34,30 +39,34 @@ export const SettingsAccountsSettingsSection = () => { description={t`Configure your emails and calendar settings.`} /> - - - } - title={t`Emails`} - description={t`Set email visibility, manage your blocklist and more.`} - /> - - - - } - title={t`Calendar`} - description={t`Configure and customize your calendar preferences.`} - /> - + + + + } + title={t`Emails`} + description={t`Set email visibility, manage your blocklist and more.`} + /> + + + + + + } + title={t`Calendar`} + description={t`Configure and customize your calendar preferences.`} + /> + + ); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsCustomizeVideoModal.tsx b/packages/twenty-front/src/modules/settings/components/SettingsCustomizeVideoModal.tsx new file mode 100644 index 0000000000..bd22c3a615 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsCustomizeVideoModal.tsx @@ -0,0 +1,103 @@ +import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { styled } from '@linaria/react'; +import { useState } from 'react'; +import { type IconComponent, IconX } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +export type SettingsCustomizeVideoModalTab = { + id: string; + title: string; + Icon: IconComponent; + vimeoId: string; +}; + +type SettingsCustomizeVideoModalProps = { + modalInstanceId: string; + tabsInstanceId: string; + tabs: SettingsCustomizeVideoModalTab[]; +}; + +const StyledHeader = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + height: 48px; + justify-content: space-between; + padding-right: ${themeCssVariables.spacing[3]}; +`; + +const StyledTabsContainer = styled.div` + flex: 1 1 auto; + min-width: 0; + padding-left: ${themeCssVariables.spacing[3]}; +`; + +const StyledVideoContainer = styled.div` + display: flex; + justify-content: center; + padding: ${themeCssVariables.spacing[6]}; +`; + +const StyledVideoIframe = styled.iframe` + aspect-ratio: 1440 / 900; + border: 0; + border-radius: ${themeCssVariables.border.radius.md}; + box-shadow: ${themeCssVariables.boxShadow.strong}; + display: block; + height: auto; + max-width: 100%; + width: 960px; +`; + +export const SettingsCustomizeVideoModal = ({ + modalInstanceId, + tabsInstanceId, + tabs, +}: SettingsCustomizeVideoModalProps) => { + const { closeModal } = useModal(); + const [activeTabId, setActiveTabId] = useState(tabs[0]?.id ?? ''); + + if (tabs.length === 0) { + return null; + } + + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; + + const handleClose = () => { + closeModal(modalInstanceId); + }; + + return ( + + + + setActiveTabId(tabId)} + /> + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx new file mode 100644 index 0000000000..c6958ad81e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsDiscoveryHeroCard.tsx @@ -0,0 +1,85 @@ +import { + SettingsCustomizeVideoModal, + type SettingsCustomizeVideoModalTab, +} from '@/settings/components/SettingsCustomizeVideoModal'; +import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { useContext } from 'react'; +import { Card } from 'twenty-ui/layout'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const COVER_HEIGHT = 150; + +const StyledCoverContainer = styled.div` + background: ${themeCssVariables.background.secondary}; + box-sizing: border-box; + height: ${COVER_HEIGHT}px; + overflow: hidden; + position: relative; +`; + +const StyledImage = styled.img` + display: block; + height: 100%; + inset: 0; + object-fit: cover; + object-position: center top; + position: absolute; + width: 100%; +`; + +const StyledOverlay = styled.div` + align-items: center; + display: flex; + inset: 0; + justify-content: center; + position: absolute; +`; + +type SettingsDiscoveryHeroCardProps = { + lightSrc: string; + darkSrc: string; + instanceIdPrefix: string; + tabs: SettingsCustomizeVideoModalTab[]; + playButtonAriaLabel?: string; +}; + +export const SettingsDiscoveryHeroCard = ({ + lightSrc, + darkSrc, + instanceIdPrefix, + tabs, + playButtonAriaLabel, +}: SettingsDiscoveryHeroCardProps) => { + const { t } = useLingui(); + const { colorScheme } = useContext(ThemeContext); + const { openModal } = useModal(); + + const modalInstanceId = `${instanceIdPrefix}-modal`; + const tabsInstanceId = `${instanceIdPrefix}-tabs`; + + const src = colorScheme === 'light' ? lightSrc : darkSrc; + + return ( + <> + + + + + openModal(modalInstanceId)} + ariaLabel={playButtonAriaLabel ?? t`Watch demo`} + /> + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx index 9df0d0dbb1..cff5bdf0b5 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx @@ -39,6 +39,7 @@ export const SettingsNavigationDrawerItem = ({ label={item.label} to={href} Icon={item.Icon} + withIconBackground active={isActive} modifier={item.modifier} onClick={item.onClick} @@ -54,6 +55,7 @@ export const SettingsNavigationDrawerItem = ({ label={item.label} to={href || undefined} Icon={item.Icon} + withIconBackground active={isActive} modifier={item.modifier} onClick={item.onClick} diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 5f36df3223..727a5c5028 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -5,13 +5,81 @@ import { type SettingsNavigationSection, useSettingsNavigationItems, } from '@/settings/hooks/useSettingsNavigationItems'; +import { CollapsibleNavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection'; import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup'; -import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; -import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; +import { styled } from '@linaria/react'; import { matchPath, resolvePath, useLocation } from 'react-router-dom'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { getSettingsPath } from 'twenty-shared/utils'; +const StyledSectionsContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[3]}; +`; + +const renderSectionItem = ( + item: SettingsNavigationItem, + index: number, + section: SettingsNavigationSection, + getSelectedIndexForSubItems: (subItems: SettingsNavigationItem[]) => number, +) => { + const subItems = item.subItems; + if (Array.isArray(subItems) && subItems.length > 0) { + const selectedSubItemIndex = getSelectedIndexForSubItems(subItems); + const hasActiveSubItem = selectedSubItemIndex !== -1; + + return ( + + + {subItems.map((subItem, subIndex) => ( + + ))} + + ); + } + return ( + + ); +}; + export const SettingsNavigationDrawerItems = () => { const settingsNavigationItems: SettingsNavigationSection[] = useSettingsNavigationItems(); @@ -34,7 +102,7 @@ export const SettingsNavigationDrawerItems = () => { }; return ( - <> + {settingsNavigationItems.map((section) => { const allItemsHidden = section.items.every((item) => item.isHidden); if (allItemsHidden) { @@ -42,75 +110,31 @@ export const SettingsNavigationDrawerItems = () => { } return ( - - {section.isAdvanced ? ( - - - - ) : ( - + ( + + {titleNode} + + ) + : undefined + } + > + {section.items.map((item, index) => + renderSectionItem( + item, + index, + section, + getSelectedIndexForSubItems, + ), )} - {section.items.map((item, index) => { - const subItems = item.subItems; - if (Array.isArray(subItems) && subItems.length > 0) { - const selectedSubItemIndex = - getSelectedIndexForSubItems(subItems); - const hasActiveSubItem = selectedSubItemIndex !== -1; - - return ( - - - {subItems.map((subItem, subIndex) => ( - - ))} - - ); - } - return ( - - ); - })} - + ); })} - + ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx index a210d25136..e25f724a42 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsPageContainer.tsx @@ -1,4 +1,3 @@ -import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration'; @@ -13,6 +12,7 @@ const StyledSettingsPageContainer = styled.div<{ width?: number; isMobile?: boolean; }>` + box-sizing: border-box; display: flex; flex-direction: column; gap: ${themeCssVariables.spacing[8]}; @@ -27,7 +27,7 @@ const StyledSettingsPageContainer = styled.div<{ if (isMobile) { return 'unset'; } - return OBJECT_SETTINGS_WIDTH + 'px'; + return '100%'; }}; `; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx b/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx new file mode 100644 index 0000000000..d00667107a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsStatsGrid.tsx @@ -0,0 +1,96 @@ +import { styled } from '@linaria/react'; +import { Fragment, useContext } from 'react'; +import { type IconComponent } from 'twenty-ui/display'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +export type SettingsStatRow = { + Icon: IconComponent; + label: string; + // String so callers can render a placeholder (e.g. "—") while async counts + // are still loading. Layout stats just pass `count.toString()`. + value: string; +}; + +type SettingsStatsGridProps = { + // Each inner array is one column rendered top-to-bottom; columns are + // separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout + // or [[a, b, c]] for a single column. + columns: SettingsStatRow[][]; +}; + +const StyledContainer = styled.div` + background: ${themeCssVariables.background.secondary}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.md}; + display: flex; + gap: ${themeCssVariables.spacing[3]}; + padding: ${themeCssVariables.spacing[2]}; +`; + +const StyledColumn = styled.div` + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; + min-width: 0; +`; + +const StyledDivider = styled.div` + align-self: stretch; + background: ${themeCssVariables.border.color.light}; + width: 1px; +`; + +const StyledRow = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[2]}; + height: ${themeCssVariables.spacing[6]}; +`; + +const StyledLabel = styled.div` + color: ${themeCssVariables.font.color.tertiary}; + flex: 1 1 0; + font-size: ${themeCssVariables.font.size.sm}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledValue = styled.div` + color: ${themeCssVariables.font.color.primary}; + padding: 0 ${themeCssVariables.spacing[1]}; +`; + +type StatRowProps = SettingsStatRow; + +const StatRow = ({ Icon, label, value }: StatRowProps) => { + const { theme } = useContext(ThemeContext); + return ( + + + {label} + {value} + + ); +}; + +export const SettingsStatsGrid = ({ columns }: SettingsStatsGridProps) => ( + + {columns.map((column, index) => ( + + {index > 0 && } + + {column.map((stat) => ( + + ))} + + + ))} + +); diff --git a/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png b/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png index 317aa0ba68..5da09efbdf 100644 Binary files a/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png and b/packages/twenty-front/src/modules/settings/data-model/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png b/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png index 4cabceeda2..6d8aebd11b 100644 Binary files a/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png and b/packages/twenty-front/src/modules/settings/data-model/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx index 095385c930..777fa6671b 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents.tsx @@ -3,12 +3,13 @@ import { styled } from '@linaria/react'; import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; import React from 'react'; +// Column width used by the Applications data tables (Instances column). export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px'; -const SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH = '140px'; -const SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH = '72px'; - -export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `180px ${SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`; +// Relative grid: Name takes all remaining space (with a floor); App / Fields / +// Instances get fixed minimums so short text columns don't collapse; trailing +// 36 px holds the chevron / action cell. +export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`; export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px'; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx index 9f9b0bcce6..6f6eb284c3 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx @@ -58,7 +58,10 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { return; } - enterLayoutCustomizationMode(); + // Skip navigation when entry was blocked (e.g. a dashboard is mid-edit). + if (!enterLayoutCustomizationMode()) { + return; + } navigateApp(AppPath.RecordShowPage, { objectNameSingular: objectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/components/SettingsObjectCoverImage.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/components/SettingsObjectCoverImage.tsx deleted file mode 100644 index ccdfded184..0000000000 --- a/packages/twenty-front/src/modules/settings/data-model/objects/components/SettingsObjectCoverImage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { styled } from '@linaria/react'; - -import { useLingui } from '@lingui/react/macro'; -import { useContext } from 'react'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath } from 'twenty-shared/utils'; -import { IconEye } from 'twenty-ui/display'; -import { FloatingButton } from 'twenty-ui/input'; - -import DarkCoverImage from '@/settings/data-model/assets/cover-dark.png'; -import LightCoverImage from '@/settings/data-model/assets/cover-light.png'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; - -const StyledCoverImageContainer = styled.div` - border: 1px solid ${themeCssVariables.border.color.medium}; - border-radius: ${themeCssVariables.border.radius.md}; - box-sizing: border-box; - margin-bottom: ${themeCssVariables.spacing[8]}; - min-height: 153px; - overflow: hidden; - position: relative; -`; - -const StyledCoverImage = styled.img` - display: block; - height: 100%; - inset: 0; - object-fit: cover; - object-position: center; - position: absolute; - width: 100%; -`; - -const StyledButtonOverlay = styled.div` - align-items: center; - display: flex; - inset: 0; - justify-content: center; - pointer-events: none; - position: absolute; - - & > * { - pointer-events: auto; - } -`; -export const SettingsObjectCoverImage = () => { - const { colorScheme } = useContext(ThemeContext); - - const { t } = useLingui(); - return ( - - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx index ad422f5320..fae608161a 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx @@ -11,7 +11,7 @@ import { useContext } from 'react'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { type Webhook } from '~/generated-metadata/graphql'; -const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px'; +export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px'; const StyledIconChevronRightContainer = styled.span` align-items: center; diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx index cb866464bb..f2e4ab8640 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsWebhooksTable.tsx @@ -1,6 +1,9 @@ import { styled } from '@linaria/react'; -import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow'; +import { + SettingsDevelopersWebhookTableRow, + WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS, +} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow'; import { Table } from '@/ui/layout/table/components/Table'; import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; @@ -24,7 +27,7 @@ export const SettingsWebhooksTable = () => { return ( - + URL diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index 6e8c9f4968..05b4bb7de1 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -27,6 +27,7 @@ import { IconHelpCircle, IconHierarchy2, IconKey, + IconLayout, IconMail, IconMessage, IconPlug, @@ -124,20 +125,18 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { Icon: IconSettings, isHidden: !permissionMap[PermissionFlagType.WORKSPACE], }, - { - label: t`Email`, - path: SettingsPath.WorkspaceEmail, - Icon: IconMail, - isHidden: - !isEmailGroupFeatureEnabled || - !permissionMap[PermissionFlagType.WORKSPACE], - }, { label: t`Data model`, path: SettingsPath.Objects, Icon: IconHierarchy2, isHidden: !permissionMap[PermissionFlagType.DATA_MODEL], }, + { + label: t`Layout`, + path: SettingsPath.Layout, + Icon: IconLayout, + isHidden: !permissionMap[PermissionFlagType.LAYOUTS], + }, { label: t`Members`, path: SettingsPath.WorkspaceMembersPage, @@ -175,9 +174,17 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { label: t`AI`, path: SettingsPath.AI, Icon: IconSparkles, - isHidden: !permissionMap[PermissionFlagType.WORKSPACE], + isHidden: !permissionMap[PermissionFlagType.AI], modifier: 'new', }, + { + label: t`Email`, + path: SettingsPath.WorkspaceEmail, + Icon: IconMail, + isHidden: + !isEmailGroupFeatureEnabled || + !permissionMap[PermissionFlagType.WORKSPACE], + }, { label: t`Security`, path: SettingsPath.Security, diff --git a/packages/twenty-front/src/modules/settings/layout/components/SettingsLayoutItemsStats.tsx b/packages/twenty-front/src/modules/settings/layout/components/SettingsLayoutItemsStats.tsx new file mode 100644 index 0000000000..95f7c2d5e6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/layout/components/SettingsLayoutItemsStats.tsx @@ -0,0 +1,65 @@ +import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector'; +import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector'; +import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector'; +import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector'; +import { SettingsStatsGrid } from '@/settings/components/SettingsStatsGrid'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { viewsSelector } from '@/views/states/selectors/viewsSelector'; +import { useLingui } from '@lingui/react/macro'; +import { + IconAppWindow, + IconCommand, + IconLayoutSidebarLeftExpand, + IconPuzzle, + IconTable, +} from 'twenty-ui/display'; + +export const SettingsLayoutItemsStats = () => { + const { t } = useLingui(); + + const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); + const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector); + const views = useAtomStateValue(viewsSelector); + const pageLayoutsWithRelations = useAtomStateValue( + pageLayoutsWithRelationsSelector, + ); + const frontComponents = useAtomStateValue(frontComponentsSelector); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png index 069ca9d300..3e58c9f5fc 100644 Binary files a/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png and b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png index dadd89963c..b03a8d23d0 100644 Binary files a/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png and b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx b/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx index dc6cae5b2c..d26fab64bb 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx @@ -1,4 +1,7 @@ -import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState'; +import { + isPlaygroundApiKeyFresh, + playgroundApiKeyState, +} from '@/settings/playground/states/playgroundApiKeyState'; import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; @@ -40,7 +43,7 @@ export const GraphQLPlayground = ({ const { colorScheme } = useContext(ThemeContext); - if (!playgroundApiKey) { + if (!isPlaygroundApiKeyFresh(playgroundApiKey)) { onError(); return null; } @@ -60,7 +63,7 @@ export const GraphQLPlayground = ({ plugins={[explorer]} fetcher={fetcher} defaultHeaders={JSON.stringify({ - Authorization: `Bearer ${playgroundApiKey}`, + Authorization: `Bearer ${playgroundApiKey.token}`, })} /> diff --git a/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx index 5dfb2bfcf2..57d55ef38a 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx @@ -1,139 +1,61 @@ +import { useOpenPlayground } from '@/settings/playground/hooks/useOpenPlayground'; import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playground/constants/SettingsPlaygroundFormSchemaSelectOptions'; -import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState'; import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes'; import { Select } from '@/ui/input/components/Select'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { styled } from '@linaria/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useLingui } from '@lingui/react/macro'; import { Controller, useForm } from 'react-hook-form'; -import { SettingsPath } from 'twenty-shared/types'; -import { CustomError } from 'twenty-shared/utils'; import { IconApi, IconBrandGraphql } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { z } from 'zod'; -import { REACT_APP_SERVER_BASE_URL } from '~/config'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; const playgroundSetupFormSchema = z.object({ - apiKeyForPlayground: z.string(), schema: z.enum(PlaygroundSchemas), playgroundType: z.enum(PlaygroundTypes), }); type PlaygroundSetupFormValues = z.infer; +// Last column shrinks to the Launch button's content width so its right +// edge sits at the form's right edge. The two select columns share the +// remaining space equally. const StyledForm = styled.form` align-items: end; display: grid; gap: ${themeCssVariables.spacing[2]}; - grid-template-columns: 1.5fr 1fr 1fr 0.5fr; - margin-bottom: ${themeCssVariables.spacing[2]}; + grid-template-columns: 1fr 1fr auto; width: 100%; `; export const PlaygroundSetupForm = () => { const { t } = useLingui(); - const navigateSettings = useNavigateSettings(); - const [playgroundApiKey, setPlaygroundApiKey] = useAtomState( - playgroundApiKeyState, - ); + const openPlayground = useOpenPlayground(); const { control, handleSubmit, formState: { isSubmitting }, - setError, } = useForm({ mode: 'onTouched', resolver: zodResolver(playgroundSetupFormSchema), defaultValues: { schema: PlaygroundSchemas.CORE, playgroundType: PlaygroundTypes.REST, - apiKeyForPlayground: playgroundApiKey || '', }, }); - const validateApiKey = async (values: PlaygroundSetupFormValues) => { - try { - const response = await fetch( - `${REACT_APP_SERVER_BASE_URL}/rest/open-api/${values.schema}`, - { - headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` }, - }, - ); - - if (!response.ok) { - throw new CustomError( - `HTTP error! status: ${response.status}`, - 'HTTP_ERROR', - ); - } - - const openAPIReference = await response.json(); - - if (!openAPIReference.tags) { - throw new Error('Invalid API Key'); - } - - return true; - } catch { - throw new Error(t`Invalid API key`); - } - }; - const onSubmit = async (values: PlaygroundSetupFormValues) => { - try { - await validateApiKey(values); - - setPlaygroundApiKey(values.apiKeyForPlayground); - - const path = - values.playgroundType === PlaygroundTypes.GRAPHQL - ? SettingsPath.GraphQLPlayground - : SettingsPath.RestPlayground; - - navigateSettings(path, { - schema: values.schema.toLowerCase(), - }); - } catch (error) { - setError('apiKeyForPlayground', { - type: 'manual', - message: - error instanceof Error - ? error.message - : t`An unexpected error occurred`, - }); - } + await openPlayground(values.playgroundType, values.schema); }; return ( - ( - { - onChange(newValue); - setPlaygroundApiKey(newValue); - }} - error={error?.message} - required - /> - )} - /> ( { const playgroundApiKey = useAtomStateValue(playgroundApiKeyState); const [specContent, setSpecContent] = useState(null); - if (!playgroundApiKey) { + if (!isPlaygroundApiKeyFresh(playgroundApiKey)) { onError(); return null; } @@ -74,7 +77,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => { @@ -89,7 +92,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => { }, authentication: { http: { - bearer: { token: playgroundApiKey }, + bearer: { token: playgroundApiKey.token }, }, }, baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema, diff --git a/packages/twenty-front/src/modules/settings/playground/components/SettingsMcpSetup.tsx b/packages/twenty-front/src/modules/settings/playground/components/SettingsMcpSetup.tsx new file mode 100644 index 0000000000..b564aed69d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/playground/components/SettingsMcpSetup.tsx @@ -0,0 +1,56 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { H2Title, IconCopy } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Card, CardContent, Section } from 'twenty-ui/layout'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +const StyledPre = styled.pre` + font-family: monospace; + margin: 0; + white-space: pre; +`; + +const buildMcpConfig = (serverUrl: string) => + `{ + "mcpServers": { + "twenty": { + "url": "${serverUrl}/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +}`; + +export const SettingsMcpSetup = () => { + const { t } = useLingui(); + const { copyToClipboard } = useCopyToClipboard(); + const mcpConfig = buildMcpConfig(REACT_APP_SERVER_BASE_URL); + + return ( +
+ + + + {mcpConfig} + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/playground/components/SettingsPlaygroundCoverImage.tsx b/packages/twenty-front/src/modules/settings/playground/components/SettingsPlaygroundCoverImage.tsx deleted file mode 100644 index 02cf751420..0000000000 --- a/packages/twenty-front/src/modules/settings/playground/components/SettingsPlaygroundCoverImage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { styled } from '@linaria/react'; -import { type ReactNode, useContext } from 'react'; - -import DarkCoverImage from '@/settings/playground/assets/cover-dark.png'; -import LightCoverImage from '@/settings/playground/assets/cover-light.png'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; - -const StyledCoverContainer = styled.div` - align-items: center; - background-size: cover; - border-radius: ${themeCssVariables.border.radius.md}; - box-sizing: border-box; - display: flex; - height: 153px; - justify-content: center; - margin-bottom: ${themeCssVariables.spacing[4]}; - margin-top: ${themeCssVariables.spacing[4]}; - position: relative; -`; - -type StyledSettingsApiPlaygroundCoverImageProps = { - children?: ReactNode; - className?: string; -}; - -export const StyledSettingsApiPlaygroundCoverImage = ({ - children, - className, -}: StyledSettingsApiPlaygroundCoverImageProps) => { - const { colorScheme } = useContext(ThemeContext); - - const coverImage = - colorScheme === 'light' - ? LightCoverImage.toString() - : DarkCoverImage.toString(); - return ( - - {children} - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/playground/components/__stories__/GraphQLPlayground.stories.tsx b/packages/twenty-front/src/modules/settings/playground/components/__stories__/GraphQLPlayground.stories.tsx index cd8df95319..47e997d5f5 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/__stories__/GraphQLPlayground.stories.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/__stories__/GraphQLPlayground.stories.tsx @@ -14,7 +14,10 @@ const PlaygroundApiKeySetterEffect = () => { const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState); useEffect(() => { - setPlaygroundApiKey('test-api-key-123'); + setPlaygroundApiKey({ + token: 'test-api-key-123', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); }, [setPlaygroundApiKey]); return null; diff --git a/packages/twenty-front/src/modules/settings/playground/components/__stories__/RestPlayground.stories.tsx b/packages/twenty-front/src/modules/settings/playground/components/__stories__/RestPlayground.stories.tsx index 8c66b9211c..3ede3a59dd 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/__stories__/RestPlayground.stories.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/__stories__/RestPlayground.stories.tsx @@ -12,7 +12,10 @@ const PlaygroundApiKeySetterEffect = () => { const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState); useEffect(() => { - setPlaygroundApiKey('test-api-key-123'); + setPlaygroundApiKey({ + token: 'test-api-key-123', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); }, [setPlaygroundApiKey]); return null; diff --git a/packages/twenty-front/src/modules/settings/playground/hooks/useOpenPlayground.ts b/packages/twenty-front/src/modules/settings/playground/hooks/useOpenPlayground.ts new file mode 100644 index 0000000000..fdfa87243c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/playground/hooks/useOpenPlayground.ts @@ -0,0 +1,62 @@ +import { useMutation } from '@apollo/client/react'; +import { useCallback } from 'react'; +import { t } from '@lingui/core/macro'; +import { SettingsPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { + isPlaygroundApiKeyFresh, + playgroundApiKeyState, +} from '@/settings/playground/states/playgroundApiKeyState'; +import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; +import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { GeneratePlaygroundTokenDocument } from '~/generated-metadata/graphql'; + +// Re-mint when less than this remains so the user never lands on a token about to expire. +const TOKEN_FRESHNESS_BUFFER_MS = 5 * 60 * 1000; + +export const useOpenPlayground = () => { + const navigateSettings = useNavigateSettings(); + const [playgroundApiKey, setPlaygroundApiKey] = useAtomState( + playgroundApiKeyState, + ); + const { enqueueErrorSnackBar } = useSnackBar(); + const [generatePlaygroundToken] = useMutation( + GeneratePlaygroundTokenDocument, + { + onError: () => { + enqueueErrorSnackBar({ + message: t`Could not open the API playground`, + }); + }, + }, + ); + + return useCallback( + async (type: PlaygroundTypes, schema: PlaygroundSchemas) => { + if ( + !isPlaygroundApiKeyFresh(playgroundApiKey, TOKEN_FRESHNESS_BUFFER_MS) + ) { + const { data } = await generatePlaygroundToken(); + const mintedToken = data?.generatePlaygroundToken; + if (!isDefined(mintedToken)) return; + setPlaygroundApiKey(mintedToken); + } + + const path = + type === PlaygroundTypes.GRAPHQL + ? SettingsPath.GraphQLPlayground + : SettingsPath.RestPlayground; + navigateSettings(path, { schema }); + }, + [ + playgroundApiKey, + generatePlaygroundToken, + navigateSettings, + setPlaygroundApiKey, + ], + ); +}; diff --git a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts index 3ad6d31240..75e9478a37 100644 --- a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts +++ b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts @@ -1,7 +1,21 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { isDefined } from 'twenty-shared/utils'; +import { type AuthToken } from '~/generated-metadata/graphql'; -export const playgroundApiKeyState = createAtomState({ +// In-memory only: a short-lived, full-permission bearer token. Keeping it out of +// localStorage bounds the exfiltration window to the current tab and leaves no +// usable credential at rest after the tab closes. +export const playgroundApiKeyState = createAtomState({ key: 'playgroundApiKeyState', defaultValue: null, - useLocalStorage: true, }); + +// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass +// no buffer (reject the moment it expires); the launcher passes a buffer so it +// re-mints before a near-expired token can fail mid-session. +export const isPlaygroundApiKeyFresh = ( + token: AuthToken | null, + bufferMs = 0, +): token is AuthToken => + isDefined(token) && + new Date(token.expiresAt).getTime() - Date.now() > bufferMs; diff --git a/packages/twenty-front/src/modules/ui/layout/hero/components/HeroPlayButton.tsx b/packages/twenty-front/src/modules/ui/layout/hero/components/HeroPlayButton.tsx new file mode 100644 index 0000000000..3820c56ffe --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/hero/components/HeroPlayButton.tsx @@ -0,0 +1,61 @@ +import { styled } from '@linaria/react'; +import { useContext } from 'react'; +import { IconPlayerPlay } from 'twenty-ui/display'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +type HeroPlayButtonProps = { + onClick?: () => void; + ariaLabel?: string; + className?: string; +}; + +const StyledButton = styled.button` + align-items: center; + background: ${themeCssVariables.background.primary}; + border: none; + border-radius: 50%; + box-shadow: ${themeCssVariables.boxShadow.strong}; + color: ${themeCssVariables.font.color.tertiary}; + cursor: pointer; + display: inline-flex; + height: 44px; + justify-content: center; + padding: 0; + transition: + transform 120ms ease-out, + background-color 120ms ease-out; + width: 44px; + + &:hover { + background: ${themeCssVariables.background.secondary}; + transform: scale(1.04); + } + + &:active { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid ${themeCssVariables.border.color.blue}; + outline-offset: 2px; + } +`; + +export const HeroPlayButton = ({ + onClick, + ariaLabel = 'Play video', + className, +}: HeroPlayButtonProps) => { + const { theme } = useContext(ThemeContext); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 95cb427332..f27e452fdd 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -9,10 +9,8 @@ import { LayoutCustomizationBar } from '@/layout-customization/components/Layout import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer'; import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar'; import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider'; -import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; -import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings'; import { BackgroundMockNavigationDrawer } from '@/sign-in-background-mock/components/BackgroundMockNavigationDrawer'; -import { Suspense, lazy, useContext } from 'react'; +import { Suspense, lazy } from 'react'; const BackgroundMockPage = lazy(() => import('@/sign-in-background-mock/components/BackgroundMockPage').then( @@ -21,13 +19,11 @@ const BackgroundMockPage = lazy(() => ); import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; -import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { styled } from '@linaria/react'; -import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; +import { AnimatePresence, LayoutGroup } from 'framer-motion'; import { Outlet } from 'react-router-dom'; -import { useScreenSize } from 'twenty-ui/utilities'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledLayout = styled.div` background: ${themeCssVariables.background.noisy}; display: flex; @@ -43,14 +39,13 @@ const StyledLayout = styled.div` } `; -const StyledPageContainerBase = styled.div` +const StyledPageContainer = styled.div` display: flex; flex: 1 1 auto; flex-direction: row; min-height: 0; min-width: 0; `; -const StyledPageContainer = motion.create(StyledPageContainerBase); const StyledNavigationDrawerWrapper = styled.div` flex-shrink: 0; @@ -65,11 +60,8 @@ const StyledMainContainer = styled.div` export const DefaultLayout = () => { const isMobile = useIsMobile(); - const isSettingsPage = useIsSettingsPage(); - const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); const useShowFullScreen = useShowFullscreen(); - const { theme } = useContext(ThemeContext); return ( <> @@ -78,21 +70,7 @@ export const DefaultLayout = () => { - + {!showAuthModal && } {showAuthModal ? ( diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx index 878f94c6dd..ca4e8bc530 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx @@ -1,4 +1,5 @@ import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper'; +import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel'; import { Breadcrumb, type BreadcrumbProps, @@ -6,7 +7,6 @@ import { import { isDefined } from 'twenty-shared/utils'; import { styled } from '@linaria/react'; import { type JSX, type ReactNode } from 'react'; -import { PageBody } from './PageBody'; import { PageHeader } from './PageHeader'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -20,12 +20,31 @@ type SubMenuTopBarContainerProps = { tag?: JSX.Element; }; +// Cards, forms, and tables inside the white panel are centered in a fixed +// max-width column so they don't sprawl on large displays. The white panel +// itself spans edge-to-edge; only the content is constrained. +const SETTINGS_CONTENT_MAX_WIDTH = 760; + const StyledContainer = styled.div` display: flex; flex-direction: column; width: 100%; `; +// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's +// own overflow handling sits one level up and depends on its children +// participating in the flex height calculation rather than collapsing to +// content height. +const StyledBodyContentWrapper = styled.div` + display: flex; + flex: 1; + flex-direction: column; + margin: 0 auto; + max-width: ${SETTINGS_CONTENT_MAX_WIDTH}px; + min-height: 0; + width: 100%; +`; + const StyledTitle = styled.span<{ reserveTitleSpace?: boolean }>` color: ${themeCssVariables.font.color.primary}; display: flex; @@ -53,16 +72,24 @@ export const SubMenuTopBarContainer = ({ }> {actionButton} - - - {(isDefined(title) || reserveTitleSpace === true) && ( - - {title} - {tag} - - )} - {children} - + {/* + MainContainerLayoutWithSidePanel is the same wrapper the App's record + pages use: it renders the page body on the left and SidePanelForDesktop + on the right. Hosting it here lets the AI chat side panel (and any + other side-panel page) open in settings exactly as it does in the App. + */} + + + + {(isDefined(title) || reserveTitleSpace === true) && ( + + {title} + {tag} + + )} + {children} + +
); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx new file mode 100644 index 0000000000..3d89770794 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection.tsx @@ -0,0 +1,57 @@ +import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; +import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; +import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; +import { type ReactNode } from 'react'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; + +type CollapsibleNavigationDrawerSectionProps = { + // Unique id used to persist the open/closed state in localStorage. Pass + // a namespaced value (e.g. 'settings/User') so unrelated sections in + // different drawers don't share state. + sectionId: string; + label: string; + children: ReactNode; + // Optional wrapper around the section title (e.g. AdvancedSettingsWrapper + // for advanced-mode-only sections). Receives the title node and returns + // the wrapped node. + wrapTitle?: (titleNode: ReactNode) => ReactNode; +}; + +// One-stop section component for any drawer that wants the main-app's +// collapsible section behavior: click the title to collapse / expand, +// animated height transition, persisted open state, chevron-on-hover. +// Use this instead of stitching together NavigationDrawerSection + +// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at +// every call site. +export const CollapsibleNavigationDrawerSection = ({ + sectionId, + label, + children, + wrapTitle, +}: CollapsibleNavigationDrawerSectionProps) => { + const { toggleNavigationSection, isNavigationSectionOpen } = + useNavigationSection(sectionId); + + const titleNode = ( + + ); + + return ( + + {wrapTitle ? wrapTitle(titleNode) : titleNode} + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index d7f275417f..e0ee354bf5 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -52,7 +52,6 @@ const StyledAnimatedContainer = styled.div<{ `; const StyledContainer = styled.div<{ - isSettings?: boolean; isExpanded?: boolean; }>` box-sizing: border-box; @@ -60,10 +59,8 @@ const StyledContainer = styled.div<{ flex-direction: column; gap: ${themeCssVariables.spacing[3]}; height: 100%; - padding: ${({ isSettings }) => - isSettings - ? `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} 0` - : `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]}`}; + padding: ${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} + ${themeCssVariables.spacing[2]}; width: ${({ isExpanded }) => isExpanded ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` : '100%'}; @media (max-width: ${MOBILE_VIEWPORT}px) { @@ -123,7 +120,7 @@ export const NavigationDrawer = ({ isExpanded={isExpanded} isResizing={isResizing} > - + {!isMobile && isSettingsDrawer && title ? ( ) : ( diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx index 4fb9e3db6c..5d72ca68d3 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx @@ -42,7 +42,6 @@ const StyledContainer = styled.div` flex-direction: row; height: ${themeCssVariables.spacing[8]}; justify-content: space-between; - padding-left: ${themeCssVariables.spacing[5]}; `; export const NavigationDrawerBackButton = ({ diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx index 47aca16a5e..4496174a02 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent.tsx @@ -1,34 +1,28 @@ import { type ReactNode } from 'react'; -import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { styled } from '@linaria/react'; import { useIsMobile } from 'twenty-ui/utilities'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -const StyledFixedContainer = styled.div<{ - isSettings?: boolean; - isMobile?: boolean; -}>` - padding-left: ${({ isSettings, isMobile }) => - isSettings || isMobile ? themeCssVariables.spacing[5] : '0'}; - padding-right: ${({ isSettings, isMobile }) => - isMobile - ? themeCssVariables.spacing[5] - : isSettings - ? themeCssVariables.spacing[8] - : '0'}; +// Mobile keeps the touch-friendly horizontal padding; on desktop the container +// is edge-to-edge and the child supplies its own padding. +const StyledFixedContainer = styled.div<{ isMobile?: boolean }>` + padding-left: ${({ isMobile }) => + isMobile ? themeCssVariables.spacing[5] : '0'}; + padding-right: ${({ isMobile }) => + isMobile ? themeCssVariables.spacing[5] : '0'}; `; + export const NavigationDrawerFixedContent = ({ children, }: { children: ReactNode; }) => { - const isSettingsDrawer = useIsSettingsDrawer(); const isMobile = useIsMobile(); return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx index 80a3ae293d..ef6cae878d 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx @@ -52,6 +52,10 @@ export type NavigationDrawerItemProps = { onClick?: () => void; Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element); iconColor?: string | null; + // Wrap the plain icon in a soft grey tile (no border) — used by the + // settings drawer so its icons read as a uniform group without picking + // up TintedIconTile's bordered colored treatment. + withIconBackground?: boolean; active?: boolean; modifier?: NavigationDrawerItemModifier; rightOptions?: ReactNode; @@ -202,6 +206,21 @@ const StyledIcon = styled.div` margin-right: ${themeCssVariables.spacing[2]}; `; +// Soft grey background-only tile (no border) used by the settings drawer. +// Sized one step larger than the icon so the icon sits with a couple of +// pixels of breathing room on every side. radius.md matches the rest of +// the App's small-card / tile language; radius.sm read as sharp squares. +const StyledIconBackgroundTile = styled.div` + align-items: center; + background-color: ${themeCssVariables.background.tertiary}; + border-radius: ${themeCssVariables.border.radius.md}; + display: flex; + flex-shrink: 0; + height: ${themeCssVariables.spacing[6]}; + justify-content: center; + width: ${themeCssVariables.spacing[6]}; +`; + const StyledRightOptionsContainer = styled.div` align-items: center; border-radius: ${themeCssVariables.border.radius.sm}; @@ -243,6 +262,7 @@ export const NavigationDrawerItem = ({ indentationLevel = DEFAULT_INDENTATION_LEVEL, Icon, iconColor, + withIconBackground = false, to, onClick, active, @@ -347,6 +367,20 @@ export const NavigationDrawerItem = ({ + ) : withIconBackground ? ( + + + + + ) : ( ` +const StyledScrollableMobileInnerContainer = styled.div` height: 100%; padding-left: ${themeCssVariables.spacing[5]}; - padding-right: ${({ isMobile }) => - isMobile ? themeCssVariables.spacing[5] : themeCssVariables.spacing[8]}; + padding-right: ${themeCssVariables.spacing[5]}; `; export const NavigationDrawerScrollableContent = ({ @@ -34,10 +33,10 @@ export const NavigationDrawerScrollableContent = ({ defaultEnableXScroll={false} > - {isSettingsDrawer || isMobile ? ( - + {isMobile ? ( + {children} - + ) : ( <>{children} )} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx index 1b66714ad6..448a127b58 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx @@ -5,22 +5,19 @@ import { styled } from '@linaria/react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useIsMobile } from 'twenty-ui/utilities'; -const StyledSection = styled.div<{ isSettingsDrawer?: boolean }>` - margin-bottom: ${({ isSettingsDrawer }) => - isSettingsDrawer ? themeCssVariables.spacing[3] : '0'}; +const StyledSection = styled.div` width: 100%; `; const StyledSectionInnerContainerMinusScrollPadding = styled.div<{ isMobile: boolean; - isSettingsDrawer: boolean; isMainNavCollapsed: boolean; }>` display: flex; flex-direction: column; gap: ${themeCssVariables.betweenSiblingsGap}; - width: ${({ isMobile, isSettingsDrawer, isMainNavCollapsed }) => - isMobile || isSettingsDrawer || isMainNavCollapsed + width: ${({ isMobile, isMainNavCollapsed }) => + isMobile || isMainNavCollapsed ? '100%' : `calc(100% - ${themeCssVariables.spacing[2]})`}; `; @@ -41,10 +38,9 @@ export const NavigationDrawerSection = ({ !isSettingsDrawer && !isMobile && !isNavigationDrawerExpanded; return ( - + {children} diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx index d33cbb8924..395f2e8129 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction'; +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; @@ -15,19 +16,25 @@ import { t } from '@lingui/core/macro'; import { IconChartBar, IconCpu, + IconLayoutDashboard, IconPlus, - IconSettingsBolt, IconSparkles, IconTool, } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; -import { SettingsAiMoreTab } from '~/pages/settings/ai/components/SettingsAiMoreTab'; +import { SettingsAgentSkillsTab } from '~/pages/settings/ai/components/SettingsAgentSkillsTab'; import { SettingsAgentToolsTab } from '~/pages/settings/ai/components/SettingsAgentToolsTab'; -import { SettingsAiModelsTab } from './components/SettingsAiModelsTab'; -import { SettingsAiUsageTab } from './components/SettingsAiUsageTab'; -import { SettingsAgentSkills } from './components/SettingsAgentSkills'; -import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs'; +import { SettingsAiModelsTab } from '~/pages/settings/ai/components/SettingsAiModelsTab'; +import { SettingsAiOverviewTab } from '~/pages/settings/ai/components/SettingsAiOverviewTab'; +import { SettingsAiUsageTab } from '~/pages/settings/ai/components/SettingsAiUsageTab'; +import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs'; + +const AI_HERO_LIGHT = '/images/ai/ai-tools-cover-light.png'; +const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png'; + +const SETTINGS_AI_HERO_INSTANCE_ID_PREFIX = 'settings-ai-hero'; export const SettingsAI = () => { const navigate = useNavigate(); @@ -56,8 +63,7 @@ export const SettingsAI = () => { const newLogicFunction = result.response.data.createOneLogicFunction; enqueueSuccessSnackBar({ message: t`Tool created` }); - const applicationId = (newLogicFunction as { applicationId?: string }) - .applicationId; + const applicationId = newLogicFunction.applicationId; if (isDefined(applicationId)) { navigate( getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, { @@ -81,6 +87,11 @@ export const SettingsAI = () => { }; const tabs = [ + { + id: SETTINGS_AI_TABS.TABS_IDS.OVERVIEW, + title: t`Overview`, + Icon: IconLayoutDashboard, + }, { id: SETTINGS_AI_TABS.TABS_IDS.MODELS, title: t`Models`, @@ -101,18 +112,14 @@ export const SettingsAI = () => { title: t`Usage`, Icon: IconChartBar, }, - { - id: SETTINGS_AI_TABS.TABS_IDS.MORE, - title: t`More`, - Icon: IconSettingsBolt, - }, ]; - const isModelsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MODELS; - const isSkillsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS; - const isToolsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS; - const isUsageTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.USAGE; - const isMoreTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MORE; + const resolvedTabId = activeTabId ?? SETTINGS_AI_TABS.TABS_IDS.OVERVIEW; + const isOverviewTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.OVERVIEW; + const isModelsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.MODELS; + const isSkillsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS; + const isToolsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS; + const isUsageTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.USAGE; return ( { ]} > +
+ +
+ {isOverviewTab && } {isModelsTab && } - {isSkillsTab && } + {isSkillsTab && } {isToolsTab && } {isUsageTab && } - {isMoreTab && }
); diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkills.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx similarity index 59% rename from packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkills.tsx rename to packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx index 7e7af8cde6..df8ecbc7b0 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkills.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentSkillsTab.tsx @@ -5,11 +5,11 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { useContext, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { H2Title, IconArchive } from 'twenty-ui/display'; import { SearchInput } from 'twenty-ui/input'; import { MenuItemToggle } from 'twenty-ui/navigation'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useMutation, useQuery } from '@apollo/client/react'; import { Section } from 'twenty-ui/layout'; @@ -22,20 +22,12 @@ import { SETTINGS_SKILL_TABLE_METADATA } from '~/pages/settings/ai/constants/Set import { normalizeSearchText } from '~/utils/normalizeSearchText'; import { SettingsAgentSkillsTable } from './SettingsAgentSkillsTable'; -const StyledCoverImage = styled.div` - background-position: center; - background-size: cover; - height: 160px; - overflow: hidden; -`; - const StyledSearchInput = styled(SearchInput)` margin-bottom: ${themeCssVariables.spacing[4]}; `; -export const SettingsAgentSkills = () => { +export const SettingsAgentSkillsTab = () => { const { t } = useLingui(); - const { colorScheme } = useContext(ThemeContext); const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const { data, loading, refetch } = useQuery(FindManySkillsDocument); @@ -90,55 +82,45 @@ export const SettingsAgentSkills = () => { } }; - const coverImage = - colorScheme === 'light' - ? '/images/ai/ai-skills-cover-light.png' - : '/images/ai/ai-skills-cover-dark.png'; - return ( - <> - -
- +
+ - ( - - - - setShowDeactivated(!showDeactivated) - } - toggled={showDeactivated} - text={t`Deactivated`} - toggleSize="small" - /> - - - } - /> - )} - /> - -
- + ( + + + setShowDeactivated(!showDeactivated)} + toggled={showDeactivated} + text={t`Deactivated`} + toggleSize="small" + /> + + + } + /> + )} + /> + +
); }; diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx index 2d27c11152..01e7e7e086 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx @@ -1,28 +1,219 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; import { styled } from '@linaria/react'; -import { useContext } from 'react'; -import { Section } from 'twenty-ui/layout'; -import { ThemeContext } from 'twenty-ui/theme-constants'; -import { SettingsToolsTable } from '~/pages/settings/ai/components/SettingsToolsTable'; +import { useLingui } from '@lingui/react/macro'; +import { type ReactNode, useMemo, useState } from 'react'; -const StyledCoverImage = styled.div` - background-position: center; - background-size: cover; - height: 160px; - overflow: hidden; +import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { ToolCategory } from 'twenty-shared/ai'; +import { isDefined } from 'twenty-shared/utils'; +import { H2Title, IconLock, IconPuzzle, IconTool } from 'twenty-ui/display'; +import { SearchInput } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { MenuItemToggle } from 'twenty-ui/navigation'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { + type SettingsAgentToolItem, + SettingsAgentToolsTable, +} from '~/pages/settings/ai/components/SettingsAgentToolsTable'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; + +const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql` + query FindManyApplicationsForToolTable { + findManyApplications { + id + name + universalIdentifier + logo + } + } +`; + +const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql` + query FindManyMarketplaceAppsForToolTable { + findManyMarketplaceApps { + id + universalIdentifier + icon + logo + } + } +`; + +const StyledSearchContainer = styled.div` + padding-bottom: ${themeCssVariables.spacing[2]}; `; export const SettingsAgentToolsTab = () => { - const { colorScheme } = useContext(ThemeContext); - const coverImage = - colorScheme === 'light' - ? '/images/ai/ai-tools-cover-light.png' - : '/images/ai/ai-tools-cover-dark.png'; + const logicFunctions = useAtomStateValue(logicFunctionsSelector); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const { + toolIndex, + loading: toolIndexLoading, + error: toolIndexError, + } = useGetToolIndex(); + const { data: applicationsData } = useQuery<{ + findManyApplications: Array<{ + id: string; + name: string; + universalIdentifier: string; + logo?: string | null; + }>; + }>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE); + const { data: marketplaceAppsData } = useQuery<{ + findManyMarketplaceApps: Array<{ + id: string; + universalIdentifier: string; + icon: string; + logo?: string | null; + }>; + }>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE); + const { t } = useLingui(); + const [searchTerm, setSearchTerm] = useState(''); + const [showCustomTools, setShowCustomTools] = useState(true); + const [showManagedTools, setShowManagedTools] = useState(true); + const [showStandardTools, setShowStandardTools] = useState(true); + + const workspaceCustomApplicationId = + currentWorkspace?.workspaceCustomApplication?.id; + + const isManaged = (applicationId?: string | null) => + isDefined(applicationId) && applicationId !== workspaceCustomApplicationId; + + const isCustom = (tool: SettingsAgentToolItem) => + isDefined(tool.applicationId); + + const allTools: SettingsAgentToolItem[] = useMemo( + () => [ + ...logicFunctions + .filter((fn) => isDefined(fn.toolTriggerSettings)) + .map((fn) => ({ + identifier: fn.id, + name: fn.name, + description: fn.description, + applicationId: fn.applicationId, + })), + ...toolIndex + .filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION) + .map((tool) => ({ + identifier: tool.name, + name: tool.name, + description: tool.description, + category: tool.category, + objectName: tool.objectName, + icon: tool.icon, + })), + ], + [logicFunctions, toolIndex], + ); + + const applicationById = new Map( + (applicationsData?.findManyApplications ?? []).map((application) => [ + application.id, + application, + ]), + ); + const marketplaceAppByUniversalIdentifier = new Map( + (marketplaceAppsData?.findManyMarketplaceApps ?? []).map( + (marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp], + ), + ); + + const filteredTools = allTools + .filter((tool) => { + const searchNormalized = normalizeSearchText(searchTerm); + + const matchesSearch = + normalizeSearchText(tool.name).includes(searchNormalized) || + normalizeSearchText(tool.description ?? '').includes(searchNormalized); + + if (!matchesSearch) { + return false; + } + + if (!isCustom(tool)) { + return showStandardTools; + } + + if (isManaged(tool.applicationId)) { + return showManagedTools; + } + + return showCustomTools; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const isLoading = toolIndexLoading && !toolIndexError; + return ( - <> -
- -
- - +
+ + + ( + + + + setShowCustomTools(!showCustomTools) + } + toggled={showCustomTools} + text={t`Custom`} + toggleSize="small" + /> + + setShowManagedTools(!showManagedTools) + } + toggled={showManagedTools} + text={t`Managed`} + toggleSize="small" + /> + + setShowStandardTools(!showStandardTools) + } + toggled={showStandardTools} + text={t`Standard`} + toggleSize="small" + /> + + + } + /> + )} + /> + + +
); }; diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx new file mode 100644 index 0000000000..6d1f7b7007 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx @@ -0,0 +1,146 @@ +import { useContext } from 'react'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; + +import { Table } from '@/ui/layout/table/components/Table'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { IconChevronRight } from 'twenty-ui/display'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; +import { SettingsToolIcon } from '~/pages/settings/ai/components/SettingsToolIcon'; +import { + SettingsToolTableRow, + TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS, +} from '~/pages/settings/ai/components/SettingsToolTableRow'; + +export type SettingsAgentToolItem = { + identifier: string; + name: string; + description?: string | null; + category?: string; + objectName?: string | null; + icon?: string | null; + applicationId?: string | null; +}; + +type Application = { + id: string; + name: string; + universalIdentifier: string; + logo?: string | null; +}; + +type MarketplaceApp = { + id: string; + universalIdentifier: string; + icon: string; + logo?: string | null; +}; + +type SettingsAgentToolsTableProps = { + tools: SettingsAgentToolItem[]; + isLoading: boolean; + applicationById: Map; + marketplaceAppByUniversalIdentifier: Map; + currentWorkspace: CurrentWorkspace | null; +}; + +const StyledTableHeaderRowContainer = styled.div` + margin-bottom: ${themeCssVariables.spacing[2]}; +`; + +const getToolApplicationId = ( + tool: SettingsAgentToolItem, + currentWorkspace: CurrentWorkspace | null, +): string => { + if (isDefined(tool.applicationId)) { + return tool.applicationId; + } + + return ( + currentWorkspace?.installedApplications?.find( + (app) => + app.universalIdentifier === + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, + )?.id ?? '' + ); +}; + +const getToolLink = (tool: SettingsAgentToolItem): string => + getSettingsPath(SettingsPath.AiToolDetail, { + toolIdentifier: tool.identifier, + }); + +export const SettingsAgentToolsTable = ({ + tools, + isLoading, + applicationById, + marketplaceAppByUniversalIdentifier, + currentWorkspace, +}: SettingsAgentToolsTableProps) => { + const { theme } = useContext(ThemeContext); + const { t } = useLingui(); + + return ( +
+ + + {t`Name`} + {t`App`} + + + + {isLoading + ? Array.from({ length: 3 }).map((_, index) => ( + + + + )) + : tools.map((tool) => { + const application = isDefined(tool.applicationId) + ? applicationById.get(tool.applicationId) + : undefined; + const marketplaceApp = isDefined(application) + ? marketplaceAppByUniversalIdentifier.get( + application.universalIdentifier, + ) + : undefined; + + return ( + + } + name={tool.name} + applicationId={getToolApplicationId(tool, currentWorkspace)} + action={ + + } + link={getToolLink(tool)} + /> + ); + })} +
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx index 5aee504e2f..a0c2f4a937 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx @@ -1,10 +1,5 @@ import { styled } from '@linaria/react'; -import { useState } from 'react'; - -import { - AUTO_SELECT_FAST_MODEL_ID, - AUTO_SELECT_SMART_MODEL_ID, -} from 'twenty-shared/constants'; +import { useContext, useState } from 'react'; import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -12,6 +7,7 @@ import { aiModelsState } from '@/client-config/states/aiModelsState'; import { SettingsAiModelsTable } from '@/settings/ai/components/SettingsAiModelsTable'; import { getDataResidencyDisplay } from '@/settings/ai/utils/getDataResidencyDisplay'; import { getModelIcon } from '@/settings/ai/utils/getModelIcon'; +import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -19,13 +15,30 @@ import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useMutation } from '@apollo/client/react'; +import { useMutation, useQuery } from '@apollo/client/react'; import { t } from '@lingui/core/macro'; -import { H2Title, IconBolt, IconBrain, IconStar } from 'twenty-ui/display'; +import { + AUTO_SELECT_FAST_MODEL_ID, + AUTO_SELECT_SMART_MODEL_ID, +} from 'twenty-shared/constants'; +import { + H2Title, + IconBolt, + IconBrain, + IconPrompt, + IconStar, +} from 'twenty-ui/display'; import { SearchInput } from 'twenty-ui/input'; import { Card, Section } from 'twenty-ui/layout'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql'; +import { UndecoratedLink } from 'twenty-ui/navigation'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { + GetAiSystemPromptPreviewDocument, + UpdateWorkspaceDocument, +} from '~/generated-metadata/graphql'; +import { formatNumber } from '~/utils/format/formatNumber'; const StyledCustomModelsContainer = styled.div` display: flex; @@ -35,18 +48,27 @@ const StyledCustomModelsContainer = styled.div` `; export const SettingsAiModelsTab = () => { + const { theme } = useContext(ThemeContext); const { enqueueErrorSnackBar } = useSnackBar(); const [currentWorkspace, setCurrentWorkspace] = useAtomState( currentWorkspaceState, ); const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); const [searchQuery, setSearchQuery] = useState(''); + const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument); const aiModels = useAtomStateValue(aiModelsState); - const { enabledModels, useRecommendedModels, realModels } = - useWorkspaceAiModelAvailability(); + const systemPromptTokenCount = + previewData?.getAiSystemPromptPreview.estimatedTokenCount; + const systemPromptDescription = isDefined(systemPromptTokenCount) + ? t`Read the system prompts to understand how the AI works (~${formatNumber( + systemPromptTokenCount, + { abbreviate: true, decimals: 1 }, + )} tokens)` + : t`Read the system prompts to understand how the AI works`; - const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []); + const { useRecommendedModels, realModels, enabledModels } = + useWorkspaceAiModelAvailability(); const currentSmartModel = currentWorkspace?.smartModel; const currentFastModel = currentWorkspace?.fastModel; @@ -55,11 +77,7 @@ export const SettingsAiModelsTab = () => { const autoSelectEntry = aiModels.find( (model) => model.modelId === autoSelectModelId, ); - - if (!autoSelectEntry) { - return undefined; - } - + if (!autoSelectEntry) return undefined; return { value: autoSelectModelId, label: autoSelectEntry.label, @@ -74,57 +92,34 @@ export const SettingsAiModelsTab = () => { const smartPinnedOption = buildPinnedOption(AUTO_SELECT_SMART_MODEL_ID); const fastPinnedOption = buildPinnedOption(AUTO_SELECT_FAST_MODEL_ID); - const buildModelOptions = () => - enabledModels.map((model) => { - const residencyFlag = model.dataResidency - ? ` ${getDataResidencyDisplay(model.dataResidency)}` - : ''; - - return { - value: model.modelId, - label: `${model.label}${residencyFlag}`, - Icon: getModelIcon(model.modelFamily, model.providerName), - }; - }); - - const smartModelOptions = buildModelOptions(); - const fastModelOptions = buildModelOptions(); + const modelOptions = enabledModels.map((model) => { + const residencyFlag = model.dataResidency + ? ` ${getDataResidencyDisplay(model.dataResidency)}` + : ''; + return { + value: model.modelId, + label: `${model.label}${residencyFlag}`, + Icon: getModelIcon(model.modelFamily, model.providerName), + }; + }); const handleModelFieldChange = async ( field: 'smartModel' | 'fastModel', value: string, ) => { - if (!currentWorkspace?.id) { - return; - } - + if (!currentWorkspace?.id) return; const previousValue = currentWorkspace[field]; - try { - setCurrentWorkspace({ - ...currentWorkspace, - [field]: value, - }); - - await updateWorkspace({ - variables: { - input: { - [field]: value, - }, - }, - }); + setCurrentWorkspace({ ...currentWorkspace, [field]: value }); + await updateWorkspace({ variables: { input: { [field]: value } } }); } catch { - setCurrentWorkspace({ - ...currentWorkspace, - [field]: previousValue, - }); - - enqueueErrorSnackBar({ - message: t`Failed to update model`, - }); + setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue }); + enqueueErrorSnackBar({ message: t`Failed to update model` }); } }; + const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []); + const handleUseRecommendedToggle = async (checked: boolean) => { if (!currentWorkspace?.id) { return; @@ -224,10 +219,9 @@ export const SettingsAiModelsTab = () => { <>
- { description={t`Used for chats, agents, and complex reasoning`} > handleModelFieldChange('fastModel', value)} - options={fastModelOptions} + options={modelOptions} pinnedOption={fastPinnedOption} selectSizeVariant="small" dropdownWidth={GenericDropdownContentWidth.ExtraLarge} @@ -264,7 +258,7 @@ export const SettingsAiModelsTab = () => {
@@ -323,6 +317,19 @@ export const SettingsAiModelsTab = () => { )}
+ +
+ + + } + title={t`Read system prompts`} + /> + +
); }; diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiMoreTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiOverviewTab.tsx similarity index 59% rename from packages/twenty-front/src/pages/settings/ai/components/SettingsAiMoreTab.tsx rename to packages/twenty-front/src/pages/settings/ai/components/SettingsAiOverviewTab.tsx index 7313ed83a0..e213aaeea8 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiMoreTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiOverviewTab.tsx @@ -1,41 +1,50 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput'; import { SettingsCard } from '@/settings/components/SettingsCard'; +import { SettingsStatsGrid } from '@/settings/components/SettingsStatsGrid'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { CombinedGraphQLErrors } from '@apollo/client'; import { useMutation, useQuery } from '@apollo/client/react'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; -import { useContext, useState } from 'react'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { H2Title, IconPrompt } from 'twenty-ui/display'; +import { Fragment, useContext, useState } from 'react'; +import { + H2Title, + IconMessage, + IconRobot, + IconSparkles, + IconTool, +} from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { useDebouncedCallback } from 'use-debounce'; import { - GetAiSystemPromptPreviewDocument, + FindWorkspaceAiStatsDocument, UpdateWorkspaceDocument, } from '~/generated-metadata/graphql'; -import { SettingsAiMCP } from '~/pages/settings/ai/components/SettingsAiMCP'; -import { formatNumber } from '~/utils/format/formatNumber'; -const StyledFormContainer = styled.div` +const StyledInstructionsContainer = styled.div` display: flex; flex-direction: column; gap: ${themeCssVariables.spacing[4]}; `; -export const SettingsAiMoreTab = () => { +const MCP_DEEP_LINK = `${getSettingsPath(SettingsPath.ApiWebhooks)}#mcp`; + +export const SettingsAiOverviewTab = () => { const { theme } = useContext(ThemeContext); const { enqueueErrorSnackBar } = useSnackBar(); const [currentWorkspace, setCurrentWorkspace] = useAtomState( currentWorkspaceState, ); const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); - const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument); + + const { data: aiStatsData } = useQuery(FindWorkspaceAiStatsDocument); + const stats = aiStatsData?.findWorkspaceAiStats; const initialInstructions = currentWorkspace?.aiAdditionalInstructions ?? ''; const [workspaceInstructions, setWorkspaceInstructions] = @@ -44,35 +53,25 @@ export const SettingsAiMoreTab = () => { useState(initialInstructions); const autoSave = useDebouncedCallback(async (newValue: string) => { - if (!currentWorkspace?.id || newValue === originalInstructions) { - return; - } - + if (!currentWorkspace?.id || newValue === originalInstructions) return; try { setCurrentWorkspace({ ...currentWorkspace, aiAdditionalInstructions: newValue || null, }); - await updateWorkspace({ variables: { - input: { - aiAdditionalInstructions: newValue || null, - }, + input: { aiAdditionalInstructions: newValue || null }, }, }); - setOriginalInstructions(newValue); } catch (error) { setCurrentWorkspace({ ...currentWorkspace, aiAdditionalInstructions: originalInstructions || null, }); - if (CombinedGraphQLErrors.is(error)) { - enqueueErrorSnackBar({ - apolloError: error, - }); + enqueueErrorSnackBar({ apolloError: error }); } else { enqueueErrorSnackBar({ message: t`Failed to save workspace instructions`, @@ -81,68 +80,75 @@ export const SettingsAiMoreTab = () => { } }, 1000); - const handleWorkspaceInstructionsChange = (value: string) => { - setWorkspaceInstructions(value); - autoSave(value); - }; - - const systemPromptTokenCount = - previewData?.getAiSystemPromptPreview.estimatedTokenCount; - const systemPromptDescription = isDefined(systemPromptTokenCount) - ? t`Read the system prompts to understand how the AI works (~${formatNumber( - systemPromptTokenCount, - { - abbreviate: true, - decimals: 1, - }, - )} tokens)` - : t`Read the system prompts to understand how the AI works`; - return ( - <> + +
+ + +
+ +
+ + + } + title={t`Set up MCP`} + /> + +
+
- + { + setWorkspaceInstructions(value); + autoSave(value); + }} enableFullScreen={true} fullScreenBreadcrumbs={[ - { - children: t`System Prompt`, - href: '#', - }, - { - children: t`Workspace Instructions`, - }, + { children: t`System Prompt`, href: '#' }, + { children: t`Workspace Instructions` }, ]} placeholder={t`E.g., "We are a B2B SaaS company. Always use formal language..."`} minHeight={150} maxWidth={700} /> - +
- - -
- - - - } - title={t`Read system prompts`} - /> - -
- +
); }; diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx deleted file mode 100644 index dd678e5450..0000000000 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { styled } from '@linaria/react'; -import { gql } from '@apollo/client'; -import { useQuery } from '@apollo/client/react'; -import { useLingui } from '@lingui/react/macro'; -import { type ReactNode, useContext, useMemo, useState } from 'react'; -import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; - -import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector'; -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { Table } from '@/ui/layout/table/components/Table'; -import { TableHeader } from '@/ui/layout/table/components/TableHeader'; -import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { - H2Title, - IconChevronRight, - IconLock, - IconPuzzle, - IconTool, -} from 'twenty-ui/display'; -import { SearchInput } from 'twenty-ui/input'; -import { Section } from 'twenty-ui/layout'; -import { MenuItemToggle } from 'twenty-ui/navigation'; -import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; - -import { ToolCategory } from 'twenty-shared/ai'; -import { normalizeSearchText } from '~/utils/normalizeSearchText'; -import { SettingsToolIcon } from './SettingsToolIcon'; -import { - SettingsToolTableRow, - TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS, -} from './SettingsToolTableRow'; -import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; - -type ToolItem = { - identifier: string; - name: string; - description?: string | null; - category?: string; - objectName?: string | null; - icon?: string | null; - applicationId?: string | null; -}; - -const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql` - query FindManyApplicationsForToolTable { - findManyApplications { - id - name - universalIdentifier - logo - } - } -`; - -const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql` - query FindManyMarketplaceAppsForToolTable { - findManyMarketplaceApps { - id - universalIdentifier - icon - logo - } - } -`; - -const StyledSearchContainer = styled.div` - padding-bottom: ${themeCssVariables.spacing[2]}; -`; - -const StyledTableHeaderRowContainer = styled.div` - margin-bottom: ${themeCssVariables.spacing[2]}; -`; - -export const SettingsToolsTable = () => { - const { theme } = useContext(ThemeContext); - const logicFunctions = useAtomStateValue(logicFunctionsSelector); - const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const { - toolIndex, - loading: toolIndexLoading, - error: toolIndexError, - } = useGetToolIndex(); - const { data: applicationsData } = useQuery<{ - findManyApplications: Array<{ - id: string; - name: string; - universalIdentifier: string; - logo?: string | null; - }>; - }>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE); - const { data: marketplaceAppsData } = useQuery<{ - findManyMarketplaceApps: Array<{ - id: string; - universalIdentifier: string; - icon: string; - logo?: string | null; - }>; - }>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE); - const { t } = useLingui(); - const [searchTerm, setSearchTerm] = useState(''); - const [showCustomTools, setShowCustomTools] = useState(true); - const [showManagedTools, setShowManagedTools] = useState(true); - const [showStandardTools, setShowStandardTools] = useState(true); - - const workspaceCustomApplicationId = - currentWorkspace?.workspaceCustomApplication?.id; - - const isManaged = (applicationId?: string | null) => - isDefined(applicationId) && applicationId !== workspaceCustomApplicationId; - - const isCustom = (item: ToolItem) => isDefined(item.applicationId); - - const getToolLink = (item: ToolItem) => - getSettingsPath(SettingsPath.AiToolDetail, { - toolIdentifier: item.identifier, - }); - - const getToolApplicationId = (item: ToolItem) => { - if (isDefined(item.applicationId)) { - return item.applicationId; - } - - return ( - currentWorkspace?.installedApplications?.find( - (app) => - app.universalIdentifier === - TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, - )?.id ?? '' - ); - }; - - const allTools: ToolItem[] = useMemo( - () => [ - ...logicFunctions - .filter((fn) => isDefined(fn.toolTriggerSettings)) - .map((fn) => ({ - identifier: fn.id, - name: fn.name, - description: fn.description, - applicationId: fn.applicationId, - })), - ...toolIndex - .filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION) - .map((tool) => ({ - identifier: tool.name, - name: tool.name, - description: tool.description, - category: tool.category, - objectName: tool.objectName, - icon: tool.icon, - })), - ], - [logicFunctions, toolIndex], - ); - - const applicationById = new Map( - (applicationsData?.findManyApplications ?? []).map((application) => [ - application.id, - application, - ]), - ); - const marketplaceAppByUniversalIdentifier = new Map( - (marketplaceAppsData?.findManyMarketplaceApps ?? []).map( - (marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp], - ), - ); - - const filteredTools = allTools - .filter((item) => { - const searchNormalized = normalizeSearchText(searchTerm); - - const matchesSearch = - normalizeSearchText(item.name).includes(searchNormalized) || - normalizeSearchText(item.description ?? '').includes(searchNormalized); - - if (!matchesSearch) { - return false; - } - - if (!isCustom(item)) { - return showStandardTools; - } - - if (isManaged(item.applicationId)) { - return showManagedTools; - } - - return showCustomTools; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - - const showSkeleton = toolIndexLoading && !toolIndexError; - - return ( -
- - - ( - - - - setShowCustomTools(!showCustomTools) - } - toggled={showCustomTools} - text={t`Custom`} - toggleSize="small" - /> - - setShowManagedTools(!showManagedTools) - } - toggled={showManagedTools} - text={t`Managed`} - toggleSize="small" - /> - - setShowStandardTools(!showStandardTools) - } - toggled={showStandardTools} - text={t`Standard`} - toggleSize="small" - /> - - - } - /> - )} - /> - - - - - {t`Name`} - {t`App`} - - - - {showSkeleton - ? Array.from({ length: 3 }).map((_, index) => ( - - - - )) - : filteredTools.map((item) => { - const application = isDefined(item.applicationId) - ? applicationById.get(item.applicationId) - : undefined; - const marketplaceApp = isDefined(application) - ? marketplaceAppByUniversalIdentifier.get( - application.universalIdentifier, - ) - : undefined; - - return ( - - } - name={item.name} - applicationId={getToolApplicationId(item)} - action={ - - } - link={getToolLink(item)} - /> - ); - })} -
-
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/ai/constants/SettingsAiTabs.ts b/packages/twenty-front/src/pages/settings/ai/constants/SettingsAiTabs.ts index 285b7a1ff2..5161ae631f 100644 --- a/packages/twenty-front/src/pages/settings/ai/constants/SettingsAiTabs.ts +++ b/packages/twenty-front/src/pages/settings/ai/constants/SettingsAiTabs.ts @@ -1,10 +1,10 @@ export const SETTINGS_AI_TABS = { COMPONENT_INSTANCE_ID: 'settings-ai-tab-list', TABS_IDS: { + OVERVIEW: 'overview', MODELS: 'models', SKILLS: 'skills', TOOLS: 'tools', USAGE: 'usage', - MORE: 'more', }, } as const; diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx index 1240329ae4..4f71a894d4 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplications.tsx @@ -1,3 +1,4 @@ +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; @@ -8,7 +9,11 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; -import { IconApps, IconCode, IconDownload } from 'twenty-ui/display'; +import { IconApps, IconCode, IconDownload, IconPlug } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; +// TODO: replace with apps-specific illustrations + recordings when designed. +import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png'; +import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png'; import { FeatureFlagKey, PermissionFlagType, @@ -18,6 +23,7 @@ import { SettingsApplicationsDeveloperTab } from '~/pages/settings/applications/ import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab'; const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list'; +const APPLICATIONS_HERO_INSTANCE_ID_PREFIX = 'settings-applications-hero'; export const SettingsApplications = () => { const { t } = useLingui(); @@ -74,6 +80,34 @@ export const SettingsApplications = () => { ]} > +
+ +
{renderActiveTabContent()}
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx index 0ecc520ba8..18818f952f 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjects.tsx @@ -1,25 +1,60 @@ import { isDDLLockedState } from '@/client-config/states/isDDLLockedState'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; - -import { SettingsObjectCoverImage } from '@/settings/data-model/objects/components/SettingsObjectCoverImage'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { Trans, useLingui } from '@lingui/react/macro'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useLingui } from '@lingui/react/macro'; +import { useMemo } from 'react'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; -import { H2Title, IconPlus } from 'twenty-ui/display'; +import { + H2Title, + IconEye, + IconHierarchy2, + IconLink, + IconList, + IconPlus, +} from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { UndecoratedLink } from 'twenty-ui/navigation'; +import DarkCoverImage from '@/settings/data-model/assets/cover-dark.png'; +import LightCoverImage from '@/settings/data-model/assets/cover-light.png'; import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable'; +const SETTINGS_DATA_MODEL_HERO_INSTANCE_ID_PREFIX = 'settings-data-model-hero'; + export const SettingsObjects = () => { const { t } = useLingui(); const { objectMetadataItems } = useFilteredObjectMetadataItems(); const isDDLLocked = useAtomStateValue(isDDLLockedState); + const heroTabs = useMemo( + () => [ + { + id: 'objects', + title: t`Objects`, + Icon: IconHierarchy2, + vimeoId: '926288174', + }, + { + id: 'fields', + title: t`Fields`, + Icon: IconList, + vimeoId: '927628219', + }, + { + id: 'relations', + title: t`Relations`, + Icon: IconLink, + vimeoId: '1185511827', + }, + ], + [t], + ); + return ( { } links={[ { - children: Workspace, + children: t`Workspace`, href: getSettingsPath(SettingsPath.Workspace), }, - { children: Objects }, + { children: t`Objects` }, ]} > - <> - -
- - - -
- +
+ +
+
+ + +
+
+ + +
); diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopers.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopers.stories.tsx deleted file mode 100644 index 7eec24cddc..0000000000 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopers.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { type Meta, type StoryObj } from '@storybook/react-vite'; -import { within } from 'storybook/test'; - -import { SettingsApiKeys } from '~/pages/settings/developers/api-keys/SettingsApiKeys'; -import { - PageDecorator, - type PageDecoratorArgs, -} from '~/testing/decorators/PageDecorator'; -import { graphqlMocks } from '~/testing/graphqlMocks'; - -const meta: Meta = { - title: 'Pages/Settings/ApiKeys', - component: SettingsApiKeys, - decorators: [PageDecorator], - args: { routePath: '/settings/apis' }, - parameters: { - msw: graphqlMocks, - }, -}; - -export default meta; - -export type Story = StoryObj; - -export const Default: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('API keys', undefined, { - timeout: 3000, - }); - }, -}; diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/playground/GraphQLPlayground.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsGraphQLPlayground.stories.tsx similarity index 80% rename from packages/twenty-front/src/pages/settings/developers/__stories__/playground/GraphQLPlayground.stories.tsx rename to packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsGraphQLPlayground.stories.tsx index 97e02d3920..b447976a5d 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/playground/GraphQLPlayground.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsGraphQLPlayground.stories.tsx @@ -9,12 +9,15 @@ import { import { SettingsGraphQLPlayground } from '~/pages/settings/developers/playground/SettingsGraphQLPlayground'; import { graphqlMocks } from '~/testing/graphqlMocks'; -const meta: Meta = { +const meta: Meta = { title: 'Pages/Settings/Playground/GraphQLPlayground', component: SettingsGraphQLPlayground, decorators: [ (Story) => { - jotaiStore.set(playgroundApiKeyState.atom, 'test-api-key'); + jotaiStore.set(playgroundApiKeyState.atom, { + token: 'test-api-key', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); return ; }, ComponentDecorator, @@ -32,7 +35,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsPlayground.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsRestPlayground.stories.tsx similarity index 88% rename from packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsPlayground.stories.tsx rename to packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsRestPlayground.stories.tsx index 510555e6d4..7b5e518df8 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsPlayground.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/playground/SettingsRestPlayground.stories.tsx @@ -14,7 +14,10 @@ const meta: Meta = { component: SettingsRestPlayground, decorators: [ (Story) => { - jotaiStore.set(playgroundApiKeyState.atom, 'test-api-key'); + jotaiStore.set(playgroundApiKeyState.atom, { + token: 'test-api-key', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); return ; }, ComponentDecorator, diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsApiKeys.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsApiKeys.tsx deleted file mode 100644 index be03453ff1..0000000000 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsApiKeys.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable'; -import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm'; -import { StyledSettingsApiPlaygroundCoverImage } from '@/settings/playground/components/SettingsPlaygroundCoverImage'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { styled } from '@linaria/react'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath } from 'twenty-shared/utils'; -import { H2Title, IconPlus } from 'twenty-ui/display'; -import { Button } from 'twenty-ui/input'; -import { Section } from 'twenty-ui/layout'; -import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; - -const StyledButtonContainer = styled.div` - display: flex; - justify-content: flex-end; - padding-top: ${themeCssVariables.spacing[2]}; - @media (max-width: ${MOBILE_VIEWPORT}px) { - padding-top: ${themeCssVariables.spacing[5]}; - } -`; - -const StyledContainer = styled.div` - display: flex; - flex-direction: column; - gap: ${themeCssVariables.spacing[2]}; - overflow: visible; -`; - -export const SettingsApiKeys = () => { - const { t } = useLingui(); - - return ( - Workspace, - href: getSettingsPath(SettingsPath.Workspace), - }, - { children: APIs }, - ]} - > - - -
- - - -
-
- -
- - - -
-
-
-
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/layout/SettingsLayout.tsx b/packages/twenty-front/src/pages/settings/layout/SettingsLayout.tsx new file mode 100644 index 0000000000..e69556bef5 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/layout/SettingsLayout.tsx @@ -0,0 +1,120 @@ +import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode'; +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useLingui } from '@lingui/react/macro'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { AppPath, SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; +import { + H2Title, + IconAppWindow, + IconCommand, + IconLayoutDashboard, + IconLayoutSidebarLeftExpand, + IconPencil, + IconTable, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import customizeIllustrationDark from '~/pages/settings/layout/assets/customize-illustration-dark.png'; +import customizeIllustrationLight from '~/pages/settings/layout/assets/customize-illustration-light.png'; + +const SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX = 'settings-layout-hero'; + +export const SettingsLayout = () => { + const { t } = useLingui(); + const navigate = useNavigate(); + const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode(); + + // Match the in-app customization entry points: arm customization mode before + // landing in the app, otherwise the user just drops into the app normally. + // Skip navigation when entry was blocked (e.g. a dashboard is mid-edit) so we + // don't yank the user away after the warning. + const handleCustomize = () => { + if (enterLayoutCustomizationMode()) { + navigate(AppPath.Index); + } + }; + + // TODO: replace placeholder demo videos per tab + const heroTabs = useMemo( + () => [ + { + id: 'sidebar', + title: t`Sidebar`, + Icon: IconLayoutSidebarLeftExpand, + vimeoId: '1185511790', + }, + { + id: 'record-page', + title: t`Record page`, + Icon: IconAppWindow, + vimeoId: '1185511790', + }, + { + id: 'command-menu', + title: t`Command menu`, + Icon: IconCommand, + vimeoId: '1185416775', + }, + { + id: 'views', + title: t`Views`, + Icon: IconTable, + vimeoId: '1145648745', + }, + { + id: 'dashboards', + title: t`Dashboards`, + Icon: IconLayoutDashboard, + vimeoId: '1185511768', + }, + ], + [t], + ); + + return ( + + } + links={[ + { + children: t`Workspace`, + href: getSettingsPath(SettingsPath.Workspace), + }, + { children: t`Layout` }, + ]} + > + +
+ +
+
+ + +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-dark.png b/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-dark.png new file mode 100644 index 0000000000..3bb25adb99 Binary files /dev/null and b/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-dark.png differ diff --git a/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-light.png b/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-light.png new file mode 100644 index 0000000000..f9c7f6320a Binary files /dev/null and b/packages/twenty-front/src/pages/settings/layout/assets/customize-illustration-light.png differ diff --git a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx index e72a38c158..bed5816d8a 100644 --- a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx @@ -3,16 +3,21 @@ import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { IconLock, IconUserPlus, IconUsers } from 'twenty-ui/display'; +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { Section } from 'twenty-ui/layout'; import { PermissionFlagType } from '~/generated-metadata/graphql'; import { SettingsWorkspaceMembersInviteTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersInviteTab'; import { SettingsWorkspaceMembersRolesTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab'; import { SettingsWorkspaceMembersTeamTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab'; +// TODO: replace with members-specific illustrations + recordings when designed. +import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png'; +import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png'; const MEMBERS_TAB_LIST_ID = 'members-tab-list'; @@ -20,6 +25,8 @@ const MEMBERS_TAB_TEAM_ID = 'team'; const MEMBERS_TAB_INVITE_ID = 'invite'; const MEMBERS_TAB_ROLES_ID = 'roles'; +const SETTINGS_MEMBERS_HERO_INSTANCE_ID_PREFIX = 'settings-members-hero'; + export const SettingsWorkspaceMembers = () => { const { t } = useLingui(); @@ -65,6 +72,38 @@ export const SettingsWorkspaceMembers = () => { ]} > +
+ +
{renderActiveTabContent()}
diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsApiWebhooks.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsApiWebhooks.tsx index 0fe34b94da..1fd587ff80 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsApiWebhooks.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsApiWebhooks.tsx @@ -1,18 +1,37 @@ +import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable'; import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable'; import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm'; -import { StyledSettingsApiPlaygroundCoverImage } from '@/settings/playground/components/SettingsPlaygroundCoverImage'; +import { SettingsMcpSetup } from '@/settings/playground/components/SettingsMcpSetup'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import PlaygroundCoverDark from '@/settings/playground/assets/cover-dark.png'; +import PlaygroundCoverLight from '@/settings/playground/assets/cover-light.png'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; -import { Trans, useLingui } from '@lingui/react/macro'; +import { useLingui } from '@lingui/react/macro'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; -import { H2Title, IconPlus } from 'twenty-ui/display'; +import { + H2Title, + IconBrandGraphql, + IconCode, + IconPlus, + IconRobot, + IconWebhook, +} from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; +import { SETTINGS_API_WEBHOOKS_TABS } from '~/pages/settings/workspace/constants/SettingsApiWebhooksTabs'; + +type TabKey = + (typeof SETTINGS_API_WEBHOOKS_TABS.TABS_IDS)[keyof typeof SETTINGS_API_WEBHOOKS_TABS.TABS_IDS]; + +const SETTINGS_API_HERO_INSTANCE_ID_PREFIX = 'settings-api-hero'; const StyledButtonContainer = styled.div` display: flex; @@ -23,18 +42,14 @@ const StyledButtonContainer = styled.div` } `; -const StyledMainContent = styled.div` +const StyledTabContent = styled.div` display: flex; flex-direction: column; gap: ${themeCssVariables.spacing[10]}; - min-height: 200px; + padding-top: ${themeCssVariables.spacing[6]}; `; -const StyledSectionContainer = styled.div` - flex-shrink: 0; -`; - -const StyledContainer = styled.div<{ isMobile?: boolean }>` +const StyledTableContainer = styled.div<{ isMobile?: boolean }>` display: flex; flex-direction: column; gap: ${themeCssVariables.spacing[2]}; @@ -45,39 +60,87 @@ export const SettingsApiWebhooks = () => { const isMobile = useIsMobile(); const { t } = useLingui(); + const activeTabId = useAtomComponentStateValue( + activeTabIdComponentState, + SETTINGS_API_WEBHOOKS_TABS.COMPONENT_INSTANCE_ID, + ); + const activeTab: TabKey = + (activeTabId as TabKey) ?? SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API; + + const tabs = [ + { + id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API, + title: t`API`, + Icon: IconCode, + }, + { + id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.MCP, + title: t`MCP`, + Icon: IconRobot, + }, + { + id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.WEBHOOKS, + title: t`Webhooks`, + Icon: IconWebhook, + }, + ]; + return ( Workspace, + children: t`Workspace`, href: getSettingsPath(SettingsPath.Workspace), }, - { children: APIs & Webhooks }, + { children: t`APIs & Webhooks` }, ]} > - - +
+ +
+ + + + {activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API && ( +
- - - - +
-
-
- +
-
+ + )} - + {activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.MCP && ( + + + + )} + + {activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.WEBHOOKS && ( +
- +
-
-
+ + )}
); diff --git a/packages/twenty-front/src/pages/settings/workspace/constants/SettingsApiWebhooksTabs.ts b/packages/twenty-front/src/pages/settings/workspace/constants/SettingsApiWebhooksTabs.ts new file mode 100644 index 0000000000..d62ea660d7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/constants/SettingsApiWebhooksTabs.ts @@ -0,0 +1,8 @@ +export const SETTINGS_API_WEBHOOKS_TABS = { + COMPONENT_INSTANCE_ID: 'settings-api-webhooks-tabs', + TABS_IDS: { + API: 'api', + MCP: 'mcp', + WEBHOOKS: 'webhooks', + }, +} as const; diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 445ca401bb..6067dc21cc 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -4,6 +4,7 @@ import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api- import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type'; +import { type JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @@ -24,5 +25,6 @@ declare module 'express-serve-static-core' { userWorkspaceId?: string; authProvider?: AuthProviderEnum | null; impersonationContext?: RawAuthContext['impersonationContext']; + tokenType?: JwtTokenTypeEnum; } } diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts index afeb68ac33..2edc9c105d 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts @@ -14,6 +14,7 @@ import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-a import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @@ -58,6 +59,10 @@ export class ApiKeyResolver { } } + // A long-lived API key is durable credential management: only a first-person + // session (ACCESS) may mint or alter one, so a short-lived derived token + // (PLAYGROUND) or an API key itself cannot escalate into another. + @UseGuards(RequireAccessTokenGuard) @Mutation(() => ApiKeyEntity) async createApiKey( @AuthWorkspace() workspace: WorkspaceEntity, @@ -72,6 +77,7 @@ export class ApiKeyResolver { }); } + @UseGuards(RequireAccessTokenGuard) @Mutation(() => ApiKeyEntity, { nullable: true }) async updateApiKey( @AuthWorkspace() workspace: WorkspaceEntity, @@ -89,6 +95,7 @@ export class ApiKeyResolver { return this.apiKeyService.update(input.id, workspace.id, updateData); } + @UseGuards(RequireAccessTokenGuard) @Mutation(() => ApiKeyEntity, { nullable: true }) async revokeApiKey( @AuthWorkspace() workspace: WorkspaceEntity, @@ -97,6 +104,7 @@ export class ApiKeyResolver { return this.apiKeyService.revoke(input.id, workspace.id); } + @UseGuards(RequireAccessTokenGuard) @Mutation(() => Boolean) async assignRoleToApiKey( @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts index 676a276ab8..0473835450 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts @@ -21,6 +21,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key. import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter'; @@ -54,6 +55,9 @@ export class ApiKeyController { return this.apiKeyService.findById(id, workspace.id); } + // Minting/altering a long-lived API key requires a first-person session + // (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here. + @UseGuards(RequireAccessTokenGuard) @Post() async create( @Body() createApiKeyDto: CreateApiKeyInput, @@ -70,6 +74,7 @@ export class ApiKeyController { }); } + @UseGuards(RequireAccessTokenGuard) @Patch(':id') async update( @Param('id') id: string, @@ -91,6 +96,7 @@ export class ApiKeyController { return this.apiKeyService.update(id, workspace.id, updateData); } + @UseGuards(RequireAccessTokenGuard) @Delete(':id') async remove( @Param('id') id: string, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts index 5329dce6bb..bbc40e9406 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts @@ -10,7 +10,7 @@ import { } from 'src/engine/core-modules/api-key/exceptions/api-key.exception'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts index 2269cfd06e..2d978b6730 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts @@ -10,7 +10,7 @@ import { ApiKeyExceptionCode, } from 'src/engine/core-modules/api-key/exceptions/api-key.exception'; import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token.dto'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts index d64bafa1e3..4057edad3d 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts @@ -18,7 +18,7 @@ import { isDefined } from 'twenty-shared/utils'; import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service'; import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index bb2c2dc092..abf176efbf 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -19,10 +19,8 @@ import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/co import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util'; import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util'; import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util'; -import { - type AppOAuthStateJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 202f715230..c197452acb 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -16,10 +16,8 @@ import { ApprovedAccessDomainExceptionCode, } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate'; -import { - type ApprovedAccessDomainJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts index c94f585413..ebb79bc87b 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts @@ -10,7 +10,7 @@ import { ApprovedAccessDomainException, ApprovedAccessDomainExceptionCode, } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index a6b5a961f1..89e5d485c1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -6,6 +6,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key. import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; @@ -86,6 +87,10 @@ describe('AuthResolver', () => { provide: ApiKeyService, useValue: {}, }, + { + provide: AccessTokenService, + useValue: {}, + }, { provide: ResetPasswordService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index cf4d212d94..731cfc2686 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -38,17 +38,16 @@ import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; -import { - AuthContextUser, - JwtTokenTypeEnum, - LoginTokenJwtPayload, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; @@ -73,6 +72,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -80,6 +80,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { ApiKeyToken } from './dto/api-key-token.dto'; +import { AuthToken } from './dto/auth-token.dto'; import { AuthTokens } from './dto/auth-tokens.dto'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { LoginTokenDTO } from './dto/login-token.dto'; @@ -112,6 +113,7 @@ export class AuthResolver { private renewTokenService: RenewTokenService, private userService: UserService, private apiKeyService: ApiKeyService, + private accessTokenService: AccessTokenService, private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, @@ -805,6 +807,7 @@ export class AuthResolver { @UseGuards( WorkspaceAuthGuard, + RequireAccessTokenGuard, SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS), ) @Mutation(() => ApiKeyToken) @@ -819,6 +822,24 @@ export class AuthResolver { ); } + @UseGuards( + WorkspaceAuthGuard, + RequireAccessTokenGuard, + SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS), + ) + @Mutation(() => AuthToken) + async generatePlaygroundToken( + @AuthUser() user: UserEntity, + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthProvider() authProvider: AuthProviderEnum, + ): Promise { + return await this.accessTokenService.generatePlaygroundToken({ + userId: user.id, + workspaceId: workspace.id, + authProvider, + }); + } + @Mutation(() => EmailPasswordResetLinkDTO) @UseGuards(PublicEndpointGuard, NoPermissionGuard) async emailPasswordResetLink( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 220a1cd8e6..430e96df35 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -45,10 +45,8 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; -import { - AuthContextUser, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type AuthProviderWithPasswordType, type ExistingUserOrNewUser, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 3ff20cb7a4..c39d57a6ba 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -6,10 +6,8 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { - type JwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { JwtAuthStrategy } from './jwt.auth.strategy'; @@ -944,4 +942,58 @@ describe('JwtAuthStrategy', () => { ); }); }); + + describe('PLAYGROUND token validation', () => { + // A PLAYGROUND token is access-shaped but must never impersonate. This is the + // same payload that, as an ACCESS token with isImpersonating set but without + // impersonation ids, throws 'Invalid or missing user workspace ID in + // impersonation token' above; as a PLAYGROUND token it must skip the + // impersonation path entirely and resolve to the first-person context. + it('ignores isImpersonating and resolves first-person', async () => { + const validUserId = 'valid-user-id'; + const validUserWorkspaceId = randomUUID(); + const validWorkspaceId = randomUUID(); + + const payload = { + sub: validUserId, + type: JwtTokenTypeEnum.PLAYGROUND, + userWorkspaceId: validUserWorkspaceId, + workspaceId: validWorkspaceId, + isImpersonating: true, + }; + + workspaceStore[validWorkspaceId] = new WorkspaceEntity(); + userStore[validUserId] = { id: validUserId, lastName: 'lastNameDefault' }; + + coreEntityCacheService.get.mockImplementation( + async (keyName: string, entityId: string) => { + if (keyName === 'workspaceEntity') { + return workspaceStore[entityId] ?? null; + } + + if (keyName === 'user') { + return userStore[entityId] ?? null; + } + + if (keyName === 'userWorkspaceEntity') { + return { + id: validUserWorkspaceId, + user: { id: validUserId, lastName: 'lastNameDefault' }, + workspace: { id: validWorkspaceId }, + }; + } + + return null; + }, + ); + + strategy = createStrategy(); + + const result = await strategy.validate(payload as JwtPayload); + + expect(result.impersonationContext).toBeUndefined(); + expect(result.tokenType).toBe(JwtTokenTypeEnum.PLAYGROUND); + expect(result.userWorkspaceId).toBe(validUserWorkspaceId); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index dd058a7a8d..5e200acb1a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -13,15 +13,16 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { - type AccessTokenJwtPayload, - type ApiKeyTokenJwtPayload, - ApplicationAccessTokenJwtPayload, type AuthContext, type AuthContextUser, - type JwtPayload, - JwtTokenTypeEnum, - type WorkspaceAgnosticTokenJwtPayload, } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type'; +import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; +import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; +import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant'; @@ -100,7 +101,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { } private async validateAccessToken( - payload: AccessTokenJwtPayload, + payload: AccessTokenJwtPayload | PlaygroundTokenJwtPayload, ): Promise { let user: AuthContextUser | null = null; let context: AuthContext = {}; @@ -117,7 +118,11 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - if (payload.isImpersonating === true) { + // Only ACCESS tokens can carry impersonation; PLAYGROUND is always first-person. + if ( + payload.type === JwtTokenTypeEnum.ACCESS && + payload.isImpersonating === true + ) { context.impersonationContext = await this.validateImpersonation(payload); } @@ -425,6 +430,17 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { } async validate(payload: JwtPayload): Promise { + const context = await this.dispatch(payload); + + return { + ...context, + tokenType: this.isLegacyApiKeyPayload(payload) + ? JwtTokenTypeEnum.API_KEY + : payload.type, + }; + } + + private async dispatch(payload: JwtPayload): Promise { // Support legacy api keys if ( payload.type === JwtTokenTypeEnum.API_KEY || @@ -437,7 +453,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { return await this.validateWorkspaceAgnosticToken(payload); } - if (payload.type === JwtTokenTypeEnum.ACCESS) { + if ( + payload.type === JwtTokenTypeEnum.ACCESS || + payload.type === JwtTokenTypeEnum.PLAYGROUND + ) { return await this.validateAccessToken(payload); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index f590010269..382d7f543c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -224,6 +224,12 @@ describe('AccessTokenService', () => { it('should throw an error if user is not found', async () => { jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({} as WorkspaceEntity); + jest + .spyOn(userWorkspaceRepository, 'findOne') + .mockResolvedValue({} as UserWorkspaceEntity); await expect( service.generateAccessToken({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index ecd84aeccf..857e46d6da 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -15,11 +15,10 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { - type AccessTokenJwtPayload, - type AuthContext, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -47,42 +46,36 @@ export class AccessTokenService { private readonly userWorkspaceRepository: Repository, ) {} - async generateAccessToken({ - userId, - workspaceId, - authProvider, - isImpersonating, - impersonatorUserWorkspaceId, - impersonatedUserWorkspaceId, - }: Omit< - AccessTokenJwtPayload, - 'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub' - >): Promise { - const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'); - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const user = await this.userRepository.findOne({ - where: { id: userId }, - }); + private async resolveTokenSubject( + userId: string, + workspaceId: string, + ): Promise<{ + user: UserEntity; + workspace: WorkspaceEntity; + userWorkspace: UserWorkspaceEntity; + workspaceMemberId: string | undefined; + }> { + const [user, workspace, userWorkspace] = await Promise.all([ + this.userRepository.findOne({ where: { id: userId } }), + this.workspaceRepository.findOne({ where: { id: workspaceId } }), + this.userWorkspaceRepository.findOne({ + where: { userId, workspaceId }, + }), + ]); userValidator.assertIsDefinedOrThrow( user, new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT), ); - - let tokenWorkspaceMemberId: string | undefined; - - const workspace = await this.workspaceRepository.findOne({ - where: { id: workspaceId }, - }); - assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); + assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError); + + let workspaceMemberId: string | undefined; if (isWorkspaceActiveOrSuspended(workspace)) { const authContext = buildSystemAuthContext(workspaceId); - tokenWorkspaceMemberId = + workspaceMemberId = await this.globalWorkspaceOrmManager.executeInWorkspaceContext( async () => { const workspaceMemberRepository = @@ -93,9 +86,7 @@ export class AccessTokenService { ); const workspaceMember = await workspaceMemberRepository.findOne({ - where: { - userId: user.id, - }, + where: { userId: user.id }, }); assertIsDefinedOrThrow( @@ -114,31 +105,73 @@ export class AccessTokenService { authContext, ); } - const userWorkspace = await this.userWorkspaceRepository.findOne({ - where: { - userId: user.id, - workspaceId, - }, - }); - assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError); + return { user, workspace, userWorkspace, workspaceMemberId }; + } - const payloadImpersonatorUserWorkspaceId = - isImpersonating === true ? impersonatorUserWorkspaceId : undefined; - const payloadOriginalUserWorkspaceId = - isImpersonating === true ? impersonatedUserWorkspaceId : undefined; + async generateAccessToken({ + userId, + workspaceId, + authProvider, + isImpersonating, + impersonatorUserWorkspaceId, + impersonatedUserWorkspaceId, + }: Omit< + AccessTokenJwtPayload, + 'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub' + >): Promise { + const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'); + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const { user, userWorkspace, workspaceMemberId } = + await this.resolveTokenSubject(userId, workspaceId); const jwtPayload: AccessTokenJwtPayload = { sub: user.id, userId: user.id, workspaceId, - workspaceMemberId: tokenWorkspaceMemberId, + workspaceMemberId, userWorkspaceId: userWorkspace.id, type: JwtTokenTypeEnum.ACCESS, authProvider, isImpersonating: isImpersonating === true, - impersonatorUserWorkspaceId: payloadImpersonatorUserWorkspaceId, - impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId, + impersonatorUserWorkspaceId: + isImpersonating === true ? impersonatorUserWorkspaceId : undefined, + impersonatedUserWorkspaceId: + isImpersonating === true ? impersonatedUserWorkspaceId : undefined, + }; + + const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, { + expiresIn, + }); + + return { token, expiresAt }; + } + + async generatePlaygroundToken({ + userId, + workspaceId, + authProvider, + }: Pick< + PlaygroundTokenJwtPayload, + 'userId' | 'workspaceId' | 'authProvider' + >): Promise { + const expiresIn = this.twentyConfigService.get( + 'PLAYGROUND_TOKEN_EXPIRES_IN', + ); + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const { user, userWorkspace, workspaceMemberId } = + await this.resolveTokenSubject(userId, workspaceId); + + const jwtPayload: PlaygroundTokenJwtPayload = { + sub: user.id, + userId: user.id, + workspaceId, + workspaceMemberId, + userWorkspaceId: userWorkspace.id, + type: JwtTokenTypeEnum.PLAYGROUND, + authProvider, }; const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts index 96a02b2fbb..e64c72608e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts @@ -10,7 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts index ae0818b90c..8892a16654 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts @@ -8,11 +8,9 @@ import ms from 'ms'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { - type ApplicationAccessTokenJwtPayload, - type ApplicationRefreshTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; +import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index 499ec45bc6..3c8f3e0522 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { LoginTokenService } from './login-token.service'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index c016e5c2c5..a842d3635c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -8,10 +8,8 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { - type LoginTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 5d74c1044d..f43c27dd84 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -8,7 +8,7 @@ import { AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index c592a6ff52..c9a341dfb9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -14,10 +14,8 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; -import { - type RefreshTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts index 2df3b76fca..2ff1f2ad3d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -8,7 +8,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts index d297b4ea88..4e0143f28b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -13,7 +13,7 @@ import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto' import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts index cd10898fdc..a66aa6ef20 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { TransientTokenService } from './transient-token.service'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts index 6cf9175908..7d6e2c6055 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -10,10 +10,8 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { - type TransientTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type TransientTokenJwtPayload } from 'src/engine/core-modules/auth/types/transient-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; @Injectable() export class TransientTokenService { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts index 2064948594..e364e99ca0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts @@ -9,7 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; describe('WorkspaceAgnosticToken', () => { let service: WorkspaceAgnosticTokenService; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts index 49e8535a8d..39797d3f0b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts @@ -11,11 +11,9 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; -import { - type AuthContext, - JwtTokenTypeEnum, - type WorkspaceAgnosticTokenJwtPayload, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/access-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/access-token-jwt-payload.type.ts new file mode 100644 index 0000000000..f8f821344a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/access-token-jwt-payload.type.ts @@ -0,0 +1,15 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.ACCESS; + workspaceId: string; + userId: string; + workspaceMemberId?: string; + userWorkspaceId: string; + authProvider: AuthProviderEnum; + isImpersonating?: boolean; + impersonatorUserWorkspaceId?: string; + impersonatedUserWorkspaceId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/api-key-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/api-key-token-jwt-payload.type.ts new file mode 100644 index 0000000000..1cb030b787 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/api-key-token-jwt-payload.type.ts @@ -0,0 +1,9 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.API_KEY; + workspaceId: string; + workspaceMemberId?: string; + jti?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts new file mode 100644 index 0000000000..ca03331ea2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type.ts @@ -0,0 +1,19 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APP_OAUTH_STATE; + workspaceId: string; + connectionProviderId: string; + userId: string; + userWorkspaceId: string; + // 'user' = the resulting credential is private to userWorkspaceId. + // 'workspace' = visible to anyone in the workspace. + // Named `visibility` to disambiguate from OAuth `scopes` on the row. + visibility: 'user' | 'workspace'; + // If set, the callback updates this existing connectedAccount row instead + // of creating a new one (used by the UI's "Reconnect" action). + reconnectingConnectedAccountId: string | null; + redirectLocation: string | null; + codeVerifier: string | null; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/application-access-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/application-access-token-jwt-payload.type.ts new file mode 100644 index 0000000000..da03a1dc68 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/application-access-token-jwt-payload.type.ts @@ -0,0 +1,10 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPLICATION_ACCESS; + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type.ts new file mode 100644 index 0000000000..f709f2fd27 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type.ts @@ -0,0 +1,10 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPLICATION_REFRESH; + workspaceId: string; + applicationId: string; + userWorkspaceId?: string; + userId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type.ts new file mode 100644 index 0000000000..bf0eb3d1c5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type.ts @@ -0,0 +1,9 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type ApprovedAccessDomainJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPROVED_ACCESS_DOMAIN; + workspaceId: string; + approvedAccessDomainId: string; + domain: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index c9b012fbd9..02e956d78d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -1,177 +1,7 @@ -import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; -import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type'; -import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type'; -import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; -import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; -import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; export { AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants'; export { type FlatAuthContextUser as AuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type'; -export type RawAuthContext = { - user?: FlatAuthContextUser | null | undefined; - apiKey?: FlatApiKey | null | undefined; - workspaceMemberId?: string; - workspaceMember?: WorkspaceMemberWorkspaceEntity; - workspace?: FlatWorkspace; - application?: FlatApplication | null | undefined; - userWorkspaceId?: string; - userWorkspace?: FlatUserWorkspace; - authProvider?: AuthProviderEnum; - impersonationContext?: { - impersonatorUserWorkspaceId?: string; - impersonatedUserWorkspaceId?: string; - }; -}; - // @deprecated Use WorkspaceAuthContext instead export type AuthContext = RawAuthContext; - -export type SerializableAuthContext = { - userId?: string; - userWorkspaceId?: string; - workspaceMemberId?: string; - apiKeyId?: string; - applicationId?: string; -}; - -export enum JwtTokenTypeEnum { - ACCESS = 'ACCESS', - REFRESH = 'REFRESH', - WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC', - LOGIN = 'LOGIN', - FILE = 'FILE', - API_KEY = 'API_KEY', - REMOTE_SERVER = 'REMOTE_SERVER', - KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', - APPLICATION_ACCESS = 'APPLICATION_ACCESS', - APPLICATION_REFRESH = 'APPLICATION_REFRESH', - APP_OAUTH_STATE = 'APP_OAUTH_STATE', - APPROVED_ACCESS_DOMAIN = 'APPROVED_ACCESS_DOMAIN', -} - -type CommonPropertiesJwtPayload = { - sub: string; -}; - -export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.FILE; - workspaceId: string; - filename: string; - workspaceMemberId?: string; - noteBlockId?: string; - attachmentId?: string; - personId?: string; -}; - -export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.FILE; - workspaceId: string; - fileId: string; -}; - -export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.LOGIN; - workspaceId: string; - authProvider: AuthProviderEnum; - impersonatorUserWorkspaceId?: string; -}; - -export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.LOGIN; - workspaceId: string; - userId: string; - workspaceMemberId: string; -}; - -export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.REFRESH; - workspaceId?: string | null; - userId: string; - jti?: string; - authProvider?: AuthProviderEnum; - targetedTokenType: JwtTokenTypeEnum; - isImpersonating?: boolean; - impersonatorUserWorkspaceId?: string; - impersonatedUserWorkspaceId?: string; -}; - -export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC; - userId: string; - authProvider: AuthProviderEnum; -}; - -export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.API_KEY; - workspaceId: string; - workspaceMemberId?: string; - jti?: string; -}; - -export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.APPLICATION_ACCESS; - workspaceId: string; - applicationId: string; - userWorkspaceId?: string; - userId?: string; -}; - -export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.APPLICATION_REFRESH; - workspaceId: string; - applicationId: string; - userWorkspaceId?: string; - userId?: string; -}; - -export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.ACCESS; - workspaceId: string; - userId: string; - workspaceMemberId?: string; - userWorkspaceId: string; - authProvider: AuthProviderEnum; - isImpersonating?: boolean; - impersonatorUserWorkspaceId?: string; - impersonatedUserWorkspaceId?: string; -}; - -export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.APP_OAUTH_STATE; - workspaceId: string; - connectionProviderId: string; - userId: string; - userWorkspaceId: string; - // 'user' = the resulting credential is private to userWorkspaceId. - // 'workspace' = visible to anyone in the workspace. - // Named `visibility` to disambiguate from OAuth `scopes` on the row. - visibility: 'user' | 'workspace'; - // If set, the callback updates this existing connectedAccount row instead - // of creating a new one (used by the UI's "Reconnect" action). - reconnectingConnectedAccountId: string | null; - redirectLocation: string | null; - codeVerifier: string | null; -}; - -export type ApprovedAccessDomainJwtPayload = CommonPropertiesJwtPayload & { - type: JwtTokenTypeEnum.APPROVED_ACCESS_DOMAIN; - workspaceId: string; - approvedAccessDomainId: string; - domain: string; -}; - -export type JwtPayload = - | AccessTokenJwtPayload - | ApiKeyTokenJwtPayload - | ApplicationAccessTokenJwtPayload - | ApplicationRefreshTokenJwtPayload - | WorkspaceAgnosticTokenJwtPayload - | LoginTokenJwtPayload - | TransientTokenJwtPayload - | RefreshTokenJwtPayload - | FileTokenJwtPayload - | FileTokenJwtPayloadLegacy - | AppOAuthStateJwtPayload - | ApprovedAccessDomainJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/common-properties-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/common-properties-jwt-payload.type.ts new file mode 100644 index 0000000000..31492e13d9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/common-properties-jwt-payload.type.ts @@ -0,0 +1,3 @@ +export type CommonPropertiesJwtPayload = { + sub: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type.ts new file mode 100644 index 0000000000..0d6984d963 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type.ts @@ -0,0 +1,12 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.FILE; + workspaceId: string; + filename: string; + workspaceMemberId?: string; + noteBlockId?: string; + attachmentId?: string; + personId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload.type.ts new file mode 100644 index 0000000000..d3298fb5e9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/file-token-jwt-payload.type.ts @@ -0,0 +1,8 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.FILE; + workspaceId: string; + fileId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts new file mode 100644 index 0000000000..789f772d97 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts @@ -0,0 +1,28 @@ +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type'; +import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; +import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type'; +import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type'; +import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type'; +import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type'; +import { type FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type'; +import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type'; +import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; +import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type'; +import { type TransientTokenJwtPayload } from 'src/engine/core-modules/auth/types/transient-token-jwt-payload.type'; +import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; + +export type JwtPayload = + | AccessTokenJwtPayload + | ApiKeyTokenJwtPayload + | ApplicationAccessTokenJwtPayload + | ApplicationRefreshTokenJwtPayload + | WorkspaceAgnosticTokenJwtPayload + | LoginTokenJwtPayload + | TransientTokenJwtPayload + | RefreshTokenJwtPayload + | FileTokenJwtPayload + | FileTokenJwtPayloadLegacy + | AppOAuthStateJwtPayload + | ApprovedAccessDomainJwtPayload + | PlaygroundTokenJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts new file mode 100644 index 0000000000..0550ad5294 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts @@ -0,0 +1,15 @@ +export enum JwtTokenTypeEnum { + ACCESS = 'ACCESS', + REFRESH = 'REFRESH', + WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC', + LOGIN = 'LOGIN', + FILE = 'FILE', + API_KEY = 'API_KEY', + REMOTE_SERVER = 'REMOTE_SERVER', + KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', + APPLICATION_ACCESS = 'APPLICATION_ACCESS', + APPLICATION_REFRESH = 'APPLICATION_REFRESH', + APP_OAUTH_STATE = 'APP_OAUTH_STATE', + APPROVED_ACCESS_DOMAIN = 'APPROVED_ACCESS_DOMAIN', + PLAYGROUND = 'PLAYGROUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/login-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/login-token-jwt-payload.type.ts new file mode 100644 index 0000000000..3cc15f6580 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/login-token-jwt-payload.type.ts @@ -0,0 +1,10 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.LOGIN; + workspaceId: string; + authProvider: AuthProviderEnum; + impersonatorUserWorkspaceId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/playground-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/playground-token-jwt-payload.type.ts new file mode 100644 index 0000000000..7081c8bd79 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/playground-token-jwt-payload.type.ts @@ -0,0 +1,12 @@ +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type PlaygroundTokenJwtPayload = Omit< + AccessTokenJwtPayload, + | 'type' + | 'isImpersonating' + | 'impersonatorUserWorkspaceId' + | 'impersonatedUserWorkspaceId' +> & { + type: JwtTokenTypeEnum.PLAYGROUND; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts new file mode 100644 index 0000000000..268c4474d6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/raw-auth-context.type.ts @@ -0,0 +1,25 @@ +import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type'; +import { type JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; +import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +export type RawAuthContext = { + user?: FlatAuthContextUser | null | undefined; + apiKey?: FlatApiKey | null | undefined; + workspaceMemberId?: string; + workspaceMember?: WorkspaceMemberWorkspaceEntity; + workspace?: FlatWorkspace; + application?: FlatApplication | null | undefined; + userWorkspaceId?: string; + userWorkspace?: FlatUserWorkspace; + authProvider?: AuthProviderEnum; + impersonationContext?: { + impersonatorUserWorkspaceId?: string; + impersonatedUserWorkspaceId?: string; + }; + tokenType?: JwtTokenTypeEnum; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/refresh-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/refresh-token-jwt-payload.type.ts new file mode 100644 index 0000000000..8ece4ab281 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/refresh-token-jwt-payload.type.ts @@ -0,0 +1,15 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.REFRESH; + workspaceId?: string | null; + userId: string; + jti?: string; + authProvider?: AuthProviderEnum; + targetedTokenType: JwtTokenTypeEnum; + isImpersonating?: boolean; + impersonatorUserWorkspaceId?: string; + impersonatedUserWorkspaceId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/serializable-auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/serializable-auth-context.type.ts new file mode 100644 index 0000000000..356aa4e799 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/serializable-auth-context.type.ts @@ -0,0 +1,7 @@ +export type SerializableAuthContext = { + userId?: string; + userWorkspaceId?: string; + workspaceMemberId?: string; + apiKeyId?: string; + applicationId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/transient-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/transient-token-jwt-payload.type.ts new file mode 100644 index 0000000000..0e623012c5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/transient-token-jwt-payload.type.ts @@ -0,0 +1,9 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.LOGIN; + workspaceId: string; + userId: string; + workspaceMemberId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type.ts new file mode 100644 index 0000000000..86dc57f1d1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type.ts @@ -0,0 +1,9 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC; + userId: string; + authProvider: AuthProviderEnum; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/workspace-auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/workspace-auth-context.type.ts index 472411317f..0461f84664 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/workspace-auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/workspace-auth-context.type.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; export type WorkspaceAuthContextType = | 'system' diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/build-api-key-auth-context.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/build-api-key-auth-context.util.ts index 959ea8939b..0eb06238bb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/build-api-key-auth-context.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/build-api-key-auth-context.util.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { type ApiKeyWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; type ApiKeyAuthContextInput = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/build-application-auth-context.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/build-application-auth-context.util.ts index 4ec360d14a..b71ce31622 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/build-application-auth-context.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/build-application-auth-context.util.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { type ApplicationWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; type ApplicationAuthContextInput = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/build-pending-activation-user-auth-context.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/build-pending-activation-user-auth-context.util.ts index 4d4b86f796..1f2a8add98 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/build-pending-activation-user-auth-context.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/build-pending-activation-user-auth-context.util.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { type PendingActivationUserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; type PendingActivationUserAuthContextInput = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/build-system-auth-context.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/build-system-auth-context.util.ts index fd3c5628d1..b6be58fe90 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/build-system-auth-context.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/build-system-auth-context.util.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; type SystemAuthContextInput = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/build-user-auth-context.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/build-user-auth-context.util.ts index 809057e30b..12cee0a64e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/build-user-auth-context.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/build-user-auth-context.util.ts @@ -1,4 +1,4 @@ -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; type UserAuthContextInput = { diff --git a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts index e0fcfbe687..de7dee6455 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts @@ -3,10 +3,8 @@ import { Injectable } from '@nestjs/common'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { - FileTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts index 3657a25295..e4ae618c6e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts @@ -4,7 +4,7 @@ import { FileFolder } from 'twenty-shared/types'; import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; -import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; export const SUPPORTED_FILE_FOLDERS = [ diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index c893e6b6fc..8d62913cdd 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -4,7 +4,7 @@ import { Injectable, } from '@nestjs/common'; -import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type'; import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index c3218efc08..d44b9ad427 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -12,10 +12,8 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { - type JwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JWT_ASYMMETRIC_ALGORITHM, JWT_LEGACY_ALGORITHM, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index 1ab91d5b84..de8370090b 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -16,10 +16,8 @@ import { } from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { - type AccessTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 45d0ec10fe..cc1eb98480 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -365,6 +365,16 @@ export class ConfigVariables { @IsOptional() APPLICATION_REFRESH_TOKEN_EXPIRES_IN = '60d'; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.TOKENS_DURATION, + description: + 'Duration for which a playground token (in-app REST/GraphQL playground bearer) is valid', + type: ConfigVariableType.STRING, + }) + @IsDuration() + @IsOptional() + PLAYGROUND_TOKEN_EXPIRES_IN = '2h'; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.EMAIL_SETTINGS, description: 'Email address used as the sender for outgoing emails', diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts index 383084fd13..bc80a76f21 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { createCipheriv, createHash, randomBytes } from 'crypto'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SimpleSecretEncryptionUtil } from './simple-secret-encryption.util'; diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts index 8c9a8236ef..cc2f541988 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { createDecipheriv, createHash } from 'crypto'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; diff --git a/packages/twenty-server/src/engine/guards/require-access-token.guard.spec.ts b/packages/twenty-server/src/engine/guards/require-access-token.guard.spec.ts new file mode 100644 index 0000000000..245077d87c --- /dev/null +++ b/packages/twenty-server/src/engine/guards/require-access-token.guard.spec.ts @@ -0,0 +1,53 @@ +import { type ExecutionContext } from '@nestjs/common'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard'; + +const buildExecutionContext = ( + request: { tokenType?: JwtTokenTypeEnum } | undefined, +): ExecutionContext => + ({ + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => request }), + }) as unknown as ExecutionContext; + +describe('RequireAccessTokenGuard', () => { + const guard = new RequireAccessTokenGuard(); + + it('allows a session ACCESS token', () => { + const context = buildExecutionContext({ + tokenType: JwtTokenTypeEnum.ACCESS, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('rejects a PLAYGROUND token so it cannot mint further tokens', () => { + const context = buildExecutionContext({ + tokenType: JwtTokenTypeEnum.PLAYGROUND, + }); + + expect(() => guard.canActivate(context)).toThrow(AuthException); + }); + + it('rejects an API_KEY token', () => { + const context = buildExecutionContext({ + tokenType: JwtTokenTypeEnum.API_KEY, + }); + + expect(() => guard.canActivate(context)).toThrow(AuthException); + }); + + it('rejects a request without a resolved token type', () => { + const context = buildExecutionContext({ tokenType: undefined }); + + expect(() => guard.canActivate(context)).toThrow(AuthException); + }); + + it('denies when there is no request on the context', () => { + const context = buildExecutionContext(undefined); + + expect(guard.canActivate(context)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/guards/require-access-token.guard.ts b/packages/twenty-server/src/engine/guards/require-access-token.guard.ts new file mode 100644 index 0000000000..a9d3d546e6 --- /dev/null +++ b/packages/twenty-server/src/engine/guards/require-access-token.guard.ts @@ -0,0 +1,30 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, +} from '@nestjs/common'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { getRequest } from 'src/utils/extract-request'; + +@Injectable() +export class RequireAccessTokenGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = getRequest(context); + + if (!request) return false; + + if (request.tokenType !== JwtTokenTypeEnum.ACCESS) { + throw new AuthException( + 'This endpoint requires a session access token', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return true; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module.ts new file mode 100644 index 0000000000..f70542a21f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; +import { AiWorkspaceStatsResolver } from 'src/engine/metadata-modules/ai/ai-workspace-stats/resolvers/ai-workspace-stats.resolver'; +import { AiWorkspaceStatsService } from 'src/engine/metadata-modules/ai/ai-workspace-stats/services/ai-workspace-stats.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AgentChatThreadEntity]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + PermissionsModule, + ToolProviderModule, + UserRoleModule, + ], + providers: [ + AiWorkspaceStatsResolver, + AiWorkspaceStatsService, + provideWorkspaceScopedRepository(AgentChatThreadEntity), + ], + exports: [AiWorkspaceStatsService], +}) +export class AiWorkspaceStatsModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/dtos/workspace-ai-stats.dto.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/dtos/workspace-ai-stats.dto.ts new file mode 100644 index 0000000000..f958a09a24 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/dtos/workspace-ai-stats.dto.ts @@ -0,0 +1,13 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType('WorkspaceAiStats') +export class WorkspaceAiStatsDTO { + @Field(() => Int) + conversationsCount: number; + + @Field(() => Int) + skillsCount: number; + + @Field(() => Int) + toolsCount: number; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/resolvers/ai-workspace-stats.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/resolvers/ai-workspace-stats.resolver.ts new file mode 100644 index 0000000000..3bc49936d7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/resolvers/ai-workspace-stats.resolver.ts @@ -0,0 +1,38 @@ +import { UseGuards } from '@nestjs/common'; +import { Query } from '@nestjs/graphql'; + +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { WorkspaceAiStatsDTO } from 'src/engine/metadata-modules/ai/ai-workspace-stats/dtos/workspace-ai-stats.dto'; +import { AiWorkspaceStatsService } from 'src/engine/metadata-modules/ai/ai-workspace-stats/services/ai-workspace-stats.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; + +@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) +@MetadataResolver() +export class AiWorkspaceStatsResolver { + constructor( + private readonly aiWorkspaceStatsService: AiWorkspaceStatsService, + private readonly userRoleService: UserRoleService, + ) {} + + @Query(() => WorkspaceAiStatsDTO) + async findWorkspaceAiStats( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, + ): Promise { + // The tool catalog is role-scoped, so resolve the caller's role to count + // the tools they can actually see. + const roleId = await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId, + workspaceId, + }); + + return this.aiWorkspaceStatsService.computeStats(workspaceId, roleId); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/services/ai-workspace-stats.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/services/ai-workspace-stats.service.ts new file mode 100644 index 0000000000..ec1df03aec --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-workspace-stats/services/ai-workspace-stats.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; +import { WorkspaceAiStatsDTO } from 'src/engine/metadata-modules/ai/ai-workspace-stats/dtos/workspace-ai-stats.dto'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; + +@Injectable() +export class AiWorkspaceStatsService { + constructor( + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly toolRegistryService: ToolRegistryService, + ) {} + + async computeStats( + workspaceId: string, + roleId: string, + ): Promise { + const [conversationsCount, flatMaps, toolIndex] = await Promise.all([ + this.threadRepository.count(workspaceId), + this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({ + workspaceId, + flatMapsKeys: ['flatSkillMaps'], + }), + // Count the full tool catalog the user can see (built-in tools + custom + // logic-function tools), matching the Tools tab rather than only the + // custom subset. The catalog is role-scoped, like the tab. + this.toolRegistryService.buildToolIndex(workspaceId, roleId), + ]); + + const skillsCount = Object.values( + flatMaps.flatSkillMaps.byUniversalIdentifier, + ).filter(isDefined).length; + + return { + conversationsCount, + skillsCount, + toolsCount: toolIndex.length, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index 838071bcdb..83762b3793 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -5,6 +5,7 @@ import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-mo import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module'; import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module'; +import { AiWorkspaceStatsModule } from 'src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module'; import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module'; import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module'; import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module'; @@ -43,6 +44,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work AiAgentMonitorModule, AiChatModule, AiGenerateTextModule, + AiWorkspaceStatsModule, MinimalMetadataModule, ViewModule, WorkspaceMetadataVersionModule, diff --git a/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts b/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts index 6c8e15f00f..f3292c6481 100644 --- a/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts +++ b/packages/twenty-server/src/engine/subscriptions/event-stream.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/serializable-auth-context.type'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; import { WithLock } from 'src/engine/core-modules/cache-lock/with-lock.decorator'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; diff --git a/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts index ee949d4c5a..12b8ba28b1 100644 --- a/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/object-record-event/object-record-event-publisher.ts @@ -21,7 +21,7 @@ import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-neste import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/serializable-auth-context.type'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; diff --git a/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts b/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts index cf26780f27..e6bef381fa 100644 --- a/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts +++ b/packages/twenty-server/src/engine/subscriptions/types/event-stream-data.type.ts @@ -3,7 +3,7 @@ import { type RecordGqlOperationSignature, } from 'twenty-shared/types'; -import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/serializable-auth-context.type'; export type RecordOrMetadataGqlOperationSignature = | RecordGqlOperationSignature diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts index 60c8d446b5..93d57fa711 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts @@ -17,7 +17,7 @@ import { import type { ObjectLiteral } from 'typeorm'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; diff --git a/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts index b75733f17b..5c5150fc98 100644 --- a/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts +++ b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts @@ -1,7 +1,7 @@ import { type Request } from 'express'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; -import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type'; export const bindDataToRequestObject = ( data: RawAuthContext, @@ -20,6 +20,7 @@ export const bindDataToRequestObject = ( request.userWorkspaceId = data.userWorkspaceId; request.authProvider = data.authProvider; request.impersonationContext = data.impersonationContext; + request.tokenType = data.tokenType; request.locale = data.userWorkspace?.locale ?? diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts index 4ef2fdffc0..7a05c3c414 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/admin-panel-signing-keys.integration-spec.ts @@ -10,10 +10,8 @@ import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util'; -import { - type AccessTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { PREVIOUS_PRIVATE_KEY_PEM, diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts index f76d20be44..67f1a2a405 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts @@ -15,11 +15,9 @@ import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util'; -import { - type AccessTokenJwtPayload, - type ApplicationAccessTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts index f02c434335..80a46d8fb3 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts @@ -13,14 +13,12 @@ import { renewToken } from 'test/integration/graphql/utils/renew-token.util'; import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; -import { - type AccessTokenJwtPayload, - type ApplicationRefreshTokenJwtPayload, - JwtTokenTypeEnum, - type LoginTokenJwtPayload, - type RefreshTokenJwtPayload, - type WorkspaceAgnosticTokenJwtPayload, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; +import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type'; +import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type'; +import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts index 84db0d1a53..81055c6f6a 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts @@ -32,5 +32,56 @@ describe('api key and webhooks permissions', () => { expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); }); }); + + // A non-session token (here API_KEY; PLAYGROUND shares the same access path) + // must not mint a durable API key, otherwise a short-lived token could + // escalate into a long-lived one. RequireAccessTokenGuard enforces this. + it('should reject a non-ACCESS token even with API key permission', async () => { + const queryData = { + query: ` + mutation generateApiKeyToken { + generateApiKeyToken(apiKeyId: "test-api-key-id", expiresAt: "2025-01-01T00:00:00Z") { + token + } + } + `, + }; + + await client + .post('/metadata') + .set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); + }); + + describe('createApiKey', () => { + it('should reject a non-ACCESS token even with API key permission', async () => { + const queryData = { + query: ` + mutation createApiKey { + createApiKey(input: { name: "escalation", expiresAt: "2025-01-01T00:00:00Z" }) { + id + } + } + `, + }; + + await client + .post('/metadata') + .set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts index 57e3cf491b..530a43c22d 100644 --- a/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto'; import * as jwt from 'jsonwebtoken'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; // Mirrors JwtWrapperService.generateAppSecret + extractAppSecretBody so tests // can hand-craft tokens that match what a pre-2.5 server would have signed. diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts index 20bf6d838d..e603357f43 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts @@ -6,7 +6,7 @@ import { DataSource } from 'typeorm'; import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util'; -import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type'; import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant'; diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index 9e7a2651eb..fb53776806 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -21,6 +21,7 @@ export enum SettingsPath { ObjectNewIndex = 'objects/:objectNamePlural/new-index', ObjectFieldEdit = 'objects/:objectNamePlural/:fieldName', NewObject = 'objects/new', + Layout = 'layout', WorkspaceMembersPage = 'members', WorkspaceMemberPage = 'members/:workspaceMemberId', Workspace = 'general',