diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index e47ba5e28c0..833cbbd7f7c 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -46,8 +46,14 @@ import { ParseEventRequestMulticastCommand } from '../events/usecases/parse-even import { ParseEventRequest } from '../events/usecases/parse-event-request/parse-event-request.usecase'; import { GenerateChatOauthUrlRequestDto } from '../integrations/dtos/generate-chat-oauth-url.dto'; import { GenerateChatOAuthUrlResponseDto } from '../integrations/dtos/generate-chat-oauth-url-response.dto'; +import { GenerateConnectOauthUrlRequestDto } from '../integrations/dtos/generate-connect-oauth-url-request.dto'; +import { GenerateLinkUserOauthUrlRequestDto } from '../integrations/dtos/generate-link-user-oauth-url-request.dto'; import { GenerateChatOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command'; import { GenerateChatOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase'; +import { GenerateConnectOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.command'; +import { GenerateConnectOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase'; +import { GenerateLinkUserOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.command'; +import { GenerateLinkUserOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase'; import { ExcludeFromIdempotency } from '../shared/framework/exclude-from-idempotency'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; import { KeylessAccessible } from '../shared/framework/swagger/keyless.security'; @@ -138,6 +144,8 @@ export class InboxController { private getChannelEndpointUsecase: GetChannelEndpoint, private deleteChannelEndpointUsecase: DeleteChannelEndpoint, private generateChatOauthUrlUsecase: GenerateChatOauthUrl, + private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl, + private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl, private featureFlagsService: FeatureFlagsService ) {} @@ -816,15 +824,15 @@ export class InboxController { } @UseGuards(AuthGuard('subscriberJwt')) - @Post('/chat/oauth') - async generateChatOAuthUrl( + @Post('/channel-connections/oauth') + async generateConnectOAuthUrl( @SubscriberSession() subscriberSession: SubscriberSession, - @Body() body: GenerateChatOauthUrlRequestDto + @Body() body: GenerateConnectOauthUrlRequestDto ): Promise { await this.checkChannelFeatureEnabled(subscriberSession._organizationId); - const url = await this.generateChatOauthUrlUsecase.execute( - GenerateChatOauthUrlCommand.create({ + const url = await this.generateConnectOauthUrlUsecase.execute( + GenerateConnectOauthUrlCommand.create({ environmentId: subscriberSession._environmentId, organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, @@ -832,9 +840,31 @@ export class InboxController { connectionIdentifier: body.connectionIdentifier, context: body.context, scope: body.scope, - userScope: body.userScope, - mode: body.mode, connectionMode: body.connectionMode, + autoLinkUser: body.autoLinkUser, + }) + ); + + return { url }; + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/channel-endpoints/oauth') + async generateLinkUserOAuthUrl( + @SubscriberSession() subscriberSession: SubscriberSession, + @Body() body: GenerateLinkUserOauthUrlRequestDto + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const url = await this.generateLinkUserOauthUrlUsecase.execute( + GenerateLinkUserOauthUrlCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + integrationIdentifier: body.integrationIdentifier, + connectionIdentifier: body.connectionIdentifier, + context: body.context, + userScope: body.userScope, }) ); diff --git a/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts b/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts index 415c1740fa0..190c40bfea7 100644 --- a/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts +++ b/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts @@ -1,13 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; import { ConnectionMode, ContextPayload } from '@novu/shared'; -import { IsArray, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { OAuthMode, SLACK_DEFAULT_OAUTH_SCOPES, SLACK_LINK_USER_OAUTH_SCOPES, } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; +/** + * @deprecated Use GenerateConnectOauthUrlRequestDto (POST /channel-connections/oauth) or + * GenerateLinkUserOauthUrlRequestDto (POST /channel-endpoints/oauth) instead. + */ export class GenerateChatOauthUrlRequestDto { @ApiProperty({ type: String, @@ -113,4 +117,20 @@ export class GenerateChatOauthUrlRequestDto { @IsString() @IsIn(['subscriber', 'shared']) connectionMode?: ConnectionMode; + + @ApiPropertyOptional({ + type: Boolean, + description: + 'When true, after the workspace/tenant connection is created the OAuth flow also links the subscriber ' + + 'who clicked "Connect" as a personal endpoint. ' + + 'For Slack, this uses the authed_user.id already returned by oauth.v2.access — no extra redirect. ' + + 'For MS Teams, this triggers a second OAuth redirect for delegated user-identity consent. ' + + 'Defaults to false when omitted; the SlackConnectButton and MsTeamsConnectButton SDK components ' + + 'default this to true.', + example: true, + required: false, + }) + @IsOptional() + @IsBoolean() + autoLinkUser?: boolean; } diff --git a/apps/api/src/app/integrations/dtos/generate-connect-oauth-url-request.dto.ts b/apps/api/src/app/integrations/dtos/generate-connect-oauth-url-request.dto.ts new file mode 100644 index 00000000000..f22bf84f93e --- /dev/null +++ b/apps/api/src/app/integrations/dtos/generate-connect-oauth-url-request.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; +import { IsArray, IsBoolean, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { SLACK_DEFAULT_OAUTH_SCOPES } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; + +export class GenerateConnectOauthUrlRequestDto { + @ApiPropertyOptional({ + type: String, + description: + 'The subscriber ID to associate with the channel connection. ' + + 'For Slack: optional for workspace connections (required only for incoming-webhook scope). ' + + 'For MS Teams: optional. Admin consent is tenant-wide.', + example: 'subscriber-123', + }) + @IsOptional() + @IsString() + subscriberId?: string; + + @ApiProperty({ + type: String, + description: 'Integration identifier', + }) + @IsString() + @IsDefined() + @IsNotEmpty({ message: 'Integration identifier is required' }) + integrationIdentifier: string; + + @ApiPropertyOptional({ + type: String, + description: 'Identifier of the channel connection that will be created. Generated automatically if not provided.', + example: 'slack-connection-abc123', + }) + @IsString() + @IsOptional() + connectionIdentifier?: string; + + @ApiContextPayload() + @IsOptional() + @IsValidContextPayload({ maxCount: 5 }) + context?: ContextPayload; + + @ApiPropertyOptional({ + type: [String], + description: + `**Slack only**: OAuth scopes to request during authorization. ` + + `If not specified, default scopes will be used: ${SLACK_DEFAULT_OAUTH_SCOPES.join(', ')}. ` + + `**MS Teams**: ignored — uses admin consent with pre-configured Azure AD permissions.`, + example: ['chat:write', 'chat:write.public', 'channels:read'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + scope?: string[]; + + @ApiPropertyOptional({ + type: String, + description: + 'Connection mode that determines how the channel connection is scoped. ' + + '"subscriber" (default) associates the connection with a specific subscriber. ' + + '"shared" associates the connection with a context instead of a subscriber.', + enum: ['subscriber', 'shared'], + example: 'shared', + }) + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + connectionMode?: ConnectionMode; + + @ApiPropertyOptional({ + type: Boolean, + description: + 'When true (default when connectionMode is "subscriber"), after the workspace/tenant connection is created ' + + 'the OAuth flow also links the subscriber who clicked "Connect" as a personal endpoint. ' + + 'For Slack, uses the authed_user.id returned by oauth.v2.access — no extra redirect. ' + + 'For MS Teams, triggers a second OAuth redirect for delegated user-identity consent. ' + + 'Set to false to only create the workspace connection without linking the individual user.', + example: true, + }) + @IsOptional() + @IsBoolean() + autoLinkUser?: boolean; +} diff --git a/apps/api/src/app/integrations/dtos/generate-link-user-oauth-url-request.dto.ts b/apps/api/src/app/integrations/dtos/generate-link-user-oauth-url-request.dto.ts new file mode 100644 index 00000000000..dee4fa90000 --- /dev/null +++ b/apps/api/src/app/integrations/dtos/generate-link-user-oauth-url-request.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; +import { ContextPayload } from '@novu/shared'; +import { IsArray, IsDefined, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { SLACK_LINK_USER_OAUTH_SCOPES } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; + +export class GenerateLinkUserOauthUrlRequestDto { + @ApiProperty({ + type: String, + description: + 'The subscriber ID to link to their chat identity. Required — this operation always binds ' + + 'a specific subscriber to a user identity in the chat provider.', + example: 'subscriber-123', + }) + @IsString() + @IsDefined() + @IsNotEmpty({ message: 'subscriberId is required for link_user' }) + subscriberId: string; + + @ApiProperty({ + type: String, + description: 'Integration identifier', + }) + @IsString() + @IsDefined() + @IsNotEmpty({ message: 'Integration identifier is required' }) + integrationIdentifier: string; + + @ApiPropertyOptional({ + type: String, + description: + 'Identifier of the existing channel connection to associate this user endpoint with. ' + + 'Generated automatically if not provided.', + example: 'slack-connection-abc123', + }) + @IsString() + @IsOptional() + connectionIdentifier?: string; + + @ApiContextPayload() + @IsOptional() + @IsValidContextPayload({ maxCount: 5 }) + context?: ContextPayload; + + @ApiPropertyOptional({ + type: [String], + description: + `**Slack only**: User-level OAuth scopes for "Sign in with Slack". ` + + `Defaults to: ${SLACK_LINK_USER_OAUTH_SCOPES.join(', ')}. ` + + `**MS Teams**: ignored — uses delegated OpenID scopes (openid, profile, User.Read).`, + example: ['identity.basic'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + userScope?: string[]; +} diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index b60b2404190..63618686b52 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -50,6 +50,8 @@ import { AutoConfigureIntegrationResponseDto } from './dtos/auto-configure-integ import { CreateIntegrationRequestDto } from './dtos/create-integration-request.dto'; import { GenerateChatOauthUrlRequestDto } from './dtos/generate-chat-oauth-url.dto'; import { GenerateChatOAuthUrlResponseDto } from './dtos/generate-chat-oauth-url-response.dto'; +import { GenerateConnectOauthUrlRequestDto } from './dtos/generate-connect-oauth-url-request.dto'; +import { GenerateLinkUserOauthUrlRequestDto } from './dtos/generate-link-user-oauth-url-request.dto'; import { ChannelTypeLimitDto } from './dtos/get-channel-type-limit.sto'; import { UpdateIntegrationRequestDto } from './dtos/update-integration.dto'; import { AutoConfigureIntegrationCommand } from './usecases/auto-configure-integration/auto-configure-integration.command'; @@ -61,6 +63,10 @@ import { CreateIntegrationCommand } from './usecases/create-integration/create-i import { CreateIntegration } from './usecases/create-integration/create-integration.usecase'; import { GenerateChatOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.command'; import { GenerateChatOauthUrl } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase'; +import { GenerateConnectOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.command'; +import { GenerateConnectOauthUrl } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase'; +import { GenerateLinkUserOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.command'; +import { GenerateLinkUserOauthUrl } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase'; import { GetInAppActivatedCommand } from './usecases/get-in-app-activated/get-in-app-activated.command'; import { GetInAppActivated } from './usecases/get-in-app-activated/get-in-app-activated.usecase'; import { GetIntegrationsCommand } from './usecases/get-integrations/get-integrations.command'; @@ -92,6 +98,8 @@ export class IntegrationsController { private calculateLimitNovuIntegration: CalculateLimitNovuIntegration, private organizationRepository: CommunityOrganizationRepository, private generateChatOauthUrlUsecase: GenerateChatOauthUrl, + private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl, + private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl, private chatOauthCallbackUsecase: ChatOauthCallback, private featureFlagsService: FeatureFlagsService ) {} @@ -405,13 +413,18 @@ export class IntegrationsController { ); } + /** + * @deprecated Use POST /integrations/channel-connections/oauth or POST /integrations/channel-endpoints/oauth instead. + */ @Post('/chat/oauth') @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) @ApiOperation({ summary: 'Generate chat OAuth URL', - description: `Generate an OAuth URL for chat integrations like Slack and MS Teams. + description: `**Deprecated** — use \`POST /integrations/channel-connections/oauth\` (connect) or \`POST /integrations/channel-endpoints/oauth\` (link_user) instead. + Generate an OAuth URL for chat integrations like Slack and MS Teams. This URL allows subscribers to authorize the integration, enabling the system to send messages through their chat workspace. The generated URL expires after 5 minutes.`, + deprecated: true, }) @SdkMethodName('generateChatOAuthUrl') @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @@ -435,6 +448,73 @@ export class IntegrationsController { userScope: body.userScope, mode: body.mode, connectionMode: body.connectionMode, + autoLinkUser: body.autoLinkUser, + }) + ); + + return { url }; + } + + @Post('/channel-connections/oauth') + @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) + @ApiOperation({ + summary: 'Generate OAuth URL for a workspace/tenant connection', + description: `Generate an OAuth URL that creates a workspace or tenant-level channel connection (Slack workspace install or MS Teams admin consent). + The generated URL expires after 5 minutes.`, + }) + @SdkMethodName('generateConnectOAuthUrl') + @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) + @ExternalApiAccessible() + @RequireAuthentication() + async generateConnectOAuthUrl( + @UserSession() user: UserSessionData, + @Body() body: GenerateConnectOauthUrlRequestDto + ): Promise { + await this.checkFeatureEnabled(user); + + const url = await this.generateConnectOauthUrlUsecase.execute( + GenerateConnectOauthUrlCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + subscriberId: body.subscriberId, + integrationIdentifier: body.integrationIdentifier, + connectionIdentifier: body.connectionIdentifier, + context: body.context, + scope: body.scope, + connectionMode: body.connectionMode, + autoLinkUser: body.autoLinkUser, + }) + ); + + return { url }; + } + + @Post('/channel-endpoints/oauth') + @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) + @ApiOperation({ + summary: 'Generate OAuth URL to link a subscriber user identity', + description: `Generate an OAuth URL that links a specific subscriber to their chat identity (Slack user ID or MS Teams user OID). + The generated URL expires after 5 minutes.`, + }) + @SdkMethodName('generateLinkUserOAuthUrl') + @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) + @ExternalApiAccessible() + @RequireAuthentication() + async generateLinkUserOAuthUrl( + @UserSession() user: UserSessionData, + @Body() body: GenerateLinkUserOauthUrlRequestDto + ): Promise { + await this.checkFeatureEnabled(user); + + const url = await this.generateLinkUserOauthUrlUsecase.execute( + GenerateLinkUserOauthUrlCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + subscriberId: body.subscriberId, + integrationIdentifier: body.integrationIdentifier, + connectionIdentifier: body.connectionIdentifier, + context: body.context, + userScope: body.userScope, }) ); diff --git a/apps/api/src/app/integrations/integrations.module.ts b/apps/api/src/app/integrations/integrations.module.ts index 0517c64d09a..26d71bf4d6d 100644 --- a/apps/api/src/app/integrations/integrations.module.ts +++ b/apps/api/src/app/integrations/integrations.module.ts @@ -4,6 +4,7 @@ import { ChannelFactory, CompileTemplate, GetNovuProviderCredentials, + MsTeamsTokenService, } from '@novu/application-generic'; import { CommunityOrganizationRepository, CommunityUserRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; @@ -13,7 +14,13 @@ import { SharedModule } from '../shared/shared.module'; import { IntegrationsController } from './integrations.controller'; import { USE_CASES } from './usecases'; -const PROVIDERS = [ChannelFactory, CompileTemplate, GetNovuProviderCredentials, CalculateLimitNovuIntegration]; +const PROVIDERS = [ + ChannelFactory, + CompileTemplate, + GetNovuProviderCredentials, + CalculateLimitNovuIntegration, + MsTeamsTokenService, +]; @Module({ imports: [SharedModule, forwardRef(() => AuthModule), ChannelConnectionsModule, ChannelEndpointsModule], diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts index 1e65d813795..c4d679fd23d 100644 --- a/apps/api/src/app/integrations/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts @@ -33,14 +33,11 @@ export class ChatOauthCallback { ); case ChatProviderIdEnum.MsTeams: - if (!command.tenant) { - throw new BadRequestException('Missing required parameter: tenant'); - } - return await this.msTeamsOauthCallback.execute( MsTeamsOauthCallbackCommand.create({ tenant: command.tenant, adminConsent: command.adminConsent, + providerCode: command.providerCode, state: command.state, }) ); diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.command.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.command.ts index 4de9d5faa58..3bb67136fac 100644 --- a/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.command.ts +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.command.ts @@ -2,14 +2,18 @@ import { BaseCommand } from '@novu/application-generic'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class MsTeamsOauthCallbackCommand extends BaseCommand { - @IsNotEmpty() + @IsOptional() @IsString() - readonly tenant: string; + readonly tenant?: string; @IsOptional() @IsString() readonly adminConsent?: string; + @IsOptional() + @IsString() + readonly providerCode?: string; + @IsNotEmpty() @IsString() readonly state: string; diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.spec.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.spec.ts new file mode 100644 index 00000000000..7b906d8a9dd --- /dev/null +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.spec.ts @@ -0,0 +1,510 @@ +import { BadRequestException } from '@nestjs/common'; +import { MsTeamsTokenService } from '@novu/application-generic'; +import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; +import { ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/shared'; +import axios from 'axios'; +import { expect } from 'chai'; +import { createHmac } from 'crypto'; +import sinon from 'sinon'; +import { CreateChannelConnection } from '../../../../channel-connections/usecases/create-channel-connection/create-channel-connection.usecase'; +import { CreateChannelEndpoint } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; +import { encodeOAuthState } from '../../generate-chat-oath-url/chat-oauth-state.util'; +import { GenerateMsTeamsOauthUrl } from '../../generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase'; +import { ResponseTypeEnum } from '../chat-oauth-callback.response'; +import { MsTeamsOauthCallbackCommand } from './msteams-oauth-callback.command'; +import { MsTeamsOauthCallback } from './msteams-oauth-callback.usecase'; + +const MOCK_ENVIRONMENT_ID = 'env-id-123'; +const MOCK_ORGANIZATION_ID = 'org-id-456'; +const MOCK_API_KEY = 'test-api-key-for-hmac'; +const MOCK_CLIENT_ID = 'azure-client-id'; +const MOCK_TENANT_ID = 'azure-tenant-id'; +const MOCK_SECRET_KEY = 'azure-secret-key'; +const MOCK_INTEGRATION_IDENTIFIER = 'msteams-integration'; +const MOCK_SUBSCRIBER_ID = 'subscriber-abc'; +const MOCK_AAD_OID = 'aad-object-id-xyz'; +const MOCK_API_ROOT_URL = 'https://api.novu.co'; +const MOCK_TEAMS_APP_ID = 'teams-internal-app-id'; + +function buildMockIntegration(overrides: Record = {}) { + return { + _id: 'integration-id', + _environmentId: MOCK_ENVIRONMENT_ID, + _organizationId: MOCK_ORGANIZATION_ID, + identifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + channel: 'chat', + credentials: { + clientId: MOCK_CLIENT_ID, + secretKey: MOCK_SECRET_KEY, + tenantId: MOCK_TENANT_ID, + }, + ...overrides, + } as any; +} + +function buildEncodedState(payload: Record): string { + const payloadStr = JSON.stringify({ ...payload, timestamp: Date.now() }); + const signature = createHmac('sha256', MOCK_API_KEY).update(payloadStr).digest('hex'); + + return encodeOAuthState(payloadStr, signature); +} + +function buildIdToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + const signature = Buffer.from('fake-sig').toString('base64url'); + + return `${header}.${payload}.${signature}`; +} + +describe('MsTeamsOauthCallback', () => { + let usecase: MsTeamsOauthCallback; + let integrationRepository: sinon.SinonStubbedInstance; + let environmentRepository: sinon.SinonStubbedInstance; + let createChannelConnection: sinon.SinonStubbedInstance; + let createChannelEndpoint: sinon.SinonStubbedInstance; + let msTeamsTokenService: sinon.SinonStubbedInstance; + let generateMsTeamsOauthUrl: sinon.SinonStubbedInstance; + let axiosPost: sinon.SinonStub; + let axiosGet: sinon.SinonStub; + let originalApiRootUrl: string | undefined; + + beforeEach(() => { + integrationRepository = sinon.createStubInstance(IntegrationRepository); + environmentRepository = sinon.createStubInstance(EnvironmentRepository); + createChannelConnection = sinon.createStubInstance(CreateChannelConnection); + createChannelEndpoint = sinon.createStubInstance(CreateChannelEndpoint); + msTeamsTokenService = sinon.createStubInstance(MsTeamsTokenService); + generateMsTeamsOauthUrl = sinon.createStubInstance(GenerateMsTeamsOauthUrl); + + const logger = { setContext: sinon.stub(), info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }; + + usecase = new MsTeamsOauthCallback( + integrationRepository as any, + environmentRepository as any, + createChannelConnection as any, + createChannelEndpoint as any, + logger as any, + msTeamsTokenService as any, + generateMsTeamsOauthUrl as any + ); + + originalApiRootUrl = process.env.API_ROOT_URL; + process.env.API_ROOT_URL = MOCK_API_ROOT_URL; + + environmentRepository.findOne.resolves({ + _id: MOCK_ENVIRONMENT_ID, + apiKeys: [{ key: MOCK_API_KEY }], + } as any); + + integrationRepository.findOne.resolves(buildMockIntegration()); + + msTeamsTokenService.getGraphToken.resolves('mock-graph-token'); + }); + + afterEach(() => { + sinon.restore(); + process.env.API_ROOT_URL = originalApiRootUrl; + }); + + describe('admin consent (connect) mode', () => { + it('should create a ChannelConnection on valid admin consent', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + const callArg = createChannelConnection.execute.firstCall.args[0]; + expect(callArg.workspace.id).to.equal('tenant-xyz'); + expect(callArg.auth.accessToken).to.equal('app-only'); + }); + + it('should throw if adminConsent is not "True"', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + }); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'False', + state, + }); + + try { + await usecase.execute(command); + expect.fail('Expected BadRequestException but none was thrown'); + } catch (err) { + expect(err).to.be.instanceOf(BadRequestException); + expect((err as BadRequestException).message).to.equal('Admin consent was not granted'); + } + }); + + it('should chain a link_user redirect when autoLinkUser=true and subscriberId are present in the state', async () => { + const MOCK_LINK_USER_URL = 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?link_user=1'; + + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: true, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + generateMsTeamsOauthUrl.execute.resolves(MOCK_LINK_USER_URL); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + const result = await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(generateMsTeamsOauthUrl.execute.calledOnce).to.be.true; + const generateCallArg = generateMsTeamsOauthUrl.execute.firstCall.args[0]; + expect(generateCallArg.mode).to.equal('link_user'); + expect(generateCallArg.subscriberId).to.equal(MOCK_SUBSCRIBER_ID); + expect(result.result).to.equal(MOCK_LINK_USER_URL); + }); + + it('should fall through to close-tab when subscriberId is absent', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + autoLinkUser: true, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + const result = await usecase.execute(command); + + expect(generateMsTeamsOauthUrl.execute.called).to.be.false; + expect(result.result).to.include('window.close()'); + }); + + it('should fall through to close-tab when autoLinkUser=false even if subscriberId is present', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: false, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + const result = await usecase.execute(command); + + expect(generateMsTeamsOauthUrl.execute.called).to.be.false; + expect(result.result).to.include('window.close()'); + }); + + it('should fall through to close-tab when autoLinkUser is absent (raw API callers default to false)', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + subscriberId: MOCK_SUBSCRIBER_ID, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + const result = await usecase.execute(command); + + expect(generateMsTeamsOauthUrl.execute.called).to.be.false; + expect(result.result).to.include('window.close()'); + }); + + it('should fall through to close-tab and not throw when link_user chaining fails', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: true, + }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + generateMsTeamsOauthUrl.execute.rejects(new Error('Subscriber not found')); + + const command = MsTeamsOauthCallbackCommand.create({ + tenant: 'tenant-xyz', + adminConsent: 'True', + state, + }); + + const result = await usecase.execute(command); + + expect(result.result).to.include('window.close()'); + }); + + it('should throw if tenant is missing', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + }); + + const command = MsTeamsOauthCallbackCommand.create({ state }); + + try { + await usecase.execute(command); + expect.fail('Expected BadRequestException but none was thrown'); + } catch (err) { + expect(err).to.be.instanceOf(BadRequestException); + expect((err as BadRequestException).message).to.equal('Missing tenant parameter from MS Teams admin consent'); + } + }); + }); + + describe('link_user mode', () => { + beforeEach(() => { + axiosPost = sinon.stub(axios, 'post'); + axiosGet = sinon.stub(axios, 'get'); + + axiosGet.resolves({ data: { value: [{ id: MOCK_TEAMS_APP_ID }] } }); + axiosPost.onSecondCall().resolves({ status: 201, data: {} }); + }); + + function buildLinkUserState(overrides: Record = {}) { + return buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.MsTeams, + subscriberId: MOCK_SUBSCRIBER_ID, + mode: 'link_user', + ...overrides, + }); + } + + function stubTokenExchange() { + const idToken = buildIdToken({ oid: MOCK_AAD_OID, sub: 'sub-123', tid: MOCK_TENANT_ID }); + axiosPost.onFirstCall().resolves({ data: { id_token: idToken, access_token: 'at-123' } }); + } + + it('should exchange code, install bot, and create an MS_TEAMS_USER endpoint on success', async () => { + stubTokenExchange(); + createChannelEndpoint.execute.resolves({ identifier: 'ep-xyz' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + await usecase.execute(command); + + expect(msTeamsTokenService.getGraphToken.calledOnce).to.be.true; + + expect(axiosGet.calledOnce).to.be.true; + const catalogUrl: string = axiosGet.firstCall.args[0]; + expect(catalogUrl).to.include('/appCatalogs/teamsApps'); + expect(catalogUrl).to.include(MOCK_CLIENT_ID); + + expect(axiosPost.callCount).to.equal(2); + const installUrl: string = axiosPost.secondCall.args[0]; + expect(installUrl).to.include(`/users/${MOCK_AAD_OID}/teamwork/installedApps`); + + expect(createChannelEndpoint.execute.calledOnce).to.be.true; + const callArg = createChannelEndpoint.execute.firstCall.args[0]; + expect(callArg.type).to.equal(ENDPOINT_TYPES.MS_TEAMS_USER); + expect(callArg.endpoint.userId).to.equal(MOCK_AAD_OID); + expect(callArg.subscriberId).to.equal(MOCK_SUBSCRIBER_ID); + }); + + it('should treat 409 (already installed) as success', async () => { + stubTokenExchange(); + const conflict = Object.assign(new Error('Conflict'), { + isAxiosError: true, + response: { status: 409, data: {} }, + }); + sinon.stub(axios, 'isAxiosError').returns(true); + axiosPost.onSecondCall().rejects(conflict); + createChannelEndpoint.execute.resolves({ identifier: 'ep-xyz' } as any); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.calledOnce).to.be.true; + expect(result).to.not.have.property('error'); + }); + + it('should return error HTML and NOT create endpoint when install returns 403', async () => { + stubTokenExchange(); + const forbidden = Object.assign(new Error('Forbidden'), { + isAxiosError: true, + response: { status: 403, data: {} }, + }); + sinon.stub(axios, 'isAxiosError').returns(true); + axiosPost.onSecondCall().rejects(forbidden); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + expect(result.result).to.include('TeamsAppInstallation.ReadWriteSelfForUser.All'); + }); + + it('should return error HTML and NOT create endpoint when install returns 404', async () => { + stubTokenExchange(); + const notFound = Object.assign(new Error('Not Found'), { + isAxiosError: true, + response: { status: 404, data: {} }, + }); + sinon.stub(axios, 'isAxiosError').returns(true); + axiosPost.onSecondCall().rejects(notFound); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + }); + + it('should return error HTML when app catalog returns no results', async () => { + stubTokenExchange(); + axiosGet.resolves({ data: { value: [] } }); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + expect(result.result).to.include('catalog'); + }); + + it('should return error HTML when catalog lookup returns 403', async () => { + stubTokenExchange(); + const forbidden = Object.assign(new Error('Forbidden'), { + isAxiosError: true, + response: { status: 403, data: {} }, + }); + sinon.stub(axios, 'isAxiosError').returns(true); + axiosGet.rejects(forbidden); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + expect(result.result).to.include('AppCatalog.Read.All'); + }); + + it('should return error HTML when id_token is missing from token response', async () => { + axiosPost.onFirstCall().resolves({ data: { access_token: 'at-123' } }); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + }); + + it('should return error HTML when oid claim is absent from id_token', async () => { + const idToken = buildIdToken({ sub: 'sub-123', tid: MOCK_TENANT_ID }); + axiosPost.onFirstCall().resolves({ data: { id_token: idToken, access_token: 'at-123' } }); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state: buildLinkUserState(), + }); + + const result = await usecase.execute(command); + + expect(createChannelEndpoint.execute.called).to.be.false; + expect(result.result).to.include('MS Teams Bot Installation Failed'); + }); + + it('should throw if subscriberId is absent in link_user mode', async () => { + const state = buildLinkUserState({ subscriberId: undefined }); + + const command = MsTeamsOauthCallbackCommand.create({ + providerCode: 'auth-code-abc', + state, + }); + + const result = await usecase.execute(command); + + expect(result.type).to.equal(ResponseTypeEnum.HTML); + expect(result.result).to.include('subscriberId is required for link_user mode'); + }); + + it('should throw if providerCode is missing in link_user mode', async () => { + const command = MsTeamsOauthCallbackCommand.create({ state: buildLinkUserState() }); + + const result = await usecase.execute(command); + + expect(result.type).to.equal(ResponseTypeEnum.HTML); + expect(result.result).to.include('Missing authorization code for link_user mode'); + }); + }); +}); diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.ts index b84f091edfd..4bf4b826724 100644 --- a/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.ts +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/msteams-oauth-callback/msteams-oauth-callback.usecase.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { PinoLogger } from '@novu/application-generic'; +import { decryptCredentials, MsTeamsTokenService, PinoLogger } from '@novu/application-generic'; import { ChannelTypeEnum, EnvironmentRepository, @@ -7,10 +7,14 @@ import { IntegrationEntity, IntegrationRepository, } from '@novu/dal'; -import { ChatProviderIdEnum } from '@novu/shared'; +import { ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/shared'; +import axios from 'axios'; import { CreateChannelConnectionCommand } from '../../../../channel-connections/usecases/create-channel-connection/create-channel-connection.command'; import { CreateChannelConnection } from '../../../../channel-connections/usecases/create-channel-connection/create-channel-connection.usecase'; +import { CreateChannelEndpointCommand } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command'; +import { CreateChannelEndpoint } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; import { peekOAuthStatePayload } from '../../generate-chat-oath-url/chat-oauth-state.util'; +import { GenerateMsTeamsOauthUrlCommand } from '../../generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.command'; import { GenerateMsTeamsOauthUrl, StateData, @@ -18,15 +22,21 @@ import { import { ChatOauthCallbackResult, ResponseTypeEnum } from '../chat-oauth-callback.response'; import { MsTeamsOauthCallbackCommand } from './msteams-oauth-callback.command'; +const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + @Injectable() export class MsTeamsOauthCallback { private readonly SCRIPT_CLOSE_TAB = ''; + private readonly MS_TEAMS_TOKEN_URL = 'https://login.microsoftonline.com'; constructor( private integrationRepository: IntegrationRepository, private environmentRepository: EnvironmentRepository, private createChannelConnection: CreateChannelConnection, - private logger: PinoLogger + private createChannelEndpoint: CreateChannelEndpoint, + private logger: PinoLogger, + private msTeamsTokenService: MsTeamsTokenService, + private generateMsTeamsOauthUrl: GenerateMsTeamsOauthUrl ) { this.logger.setContext(MsTeamsOauthCallback.name); } @@ -36,6 +46,68 @@ export class MsTeamsOauthCallback { const integration = await this.getIntegration(stateData); const credentials = await this.getIntegrationCredentials(integration); + if (stateData.mode === 'link_user') { + try { + await this.linkUserEndpoint(command, stateData, integration, credentials); + } catch (error) { + const message = + error instanceof Error ? error.message : 'An unexpected error occurred during bot installation.'; + + return { + type: ResponseTypeEnum.HTML, + result: this.buildErrorHtml(message), + }; + } + } else { + await this.createAdminConsentConnection(command, stateData, integration); + + /* + * After admin consent, if autoLinkUser is explicitly true and a subscriberId is + * present, chain into the link_user OAuth flow so the subscriber who clicked + * "Connect" also gets their personal Teams identity linked in one go. + * + * autoLinkUser must be explicitly true — absent or false skips the chain. + * The MsTeamsConnectButton SDK component defaults autoLinkUser to true so SDK + * users get this behaviour by default; raw API callers must opt in explicitly. + */ + if (stateData.autoLinkUser === true && stateData.subscriberId) { + try { + const linkUserUrl = await this.generateMsTeamsOauthUrl.execute( + GenerateMsTeamsOauthUrlCommand.create({ + environmentId: stateData.environmentId, + organizationId: stateData.organizationId, + connectionIdentifier: stateData.identifier, + subscriberId: stateData.subscriberId, + integration, + context: stateData.context, + mode: 'link_user', + }) + ); + + return { type: ResponseTypeEnum.URL, result: linkUserUrl }; + } catch (error) { + this.logger.warn( + `Could not chain link_user redirect after admin consent: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + if (credentials.redirectUrl) { + return { type: ResponseTypeEnum.URL, result: credentials.redirectUrl }; + } + + return { + type: ResponseTypeEnum.HTML, + result: this.SCRIPT_CLOSE_TAB, + }; + } + + private async createAdminConsentConnection( + command: MsTeamsOauthCallbackCommand, + stateData: StateData, + integration: IntegrationEntity + ): Promise { if (!command.tenant) { throw new BadRequestException('Missing tenant parameter from MS Teams admin consent'); } @@ -52,14 +124,6 @@ export class MsTeamsOauthCallback { * - When sending: use client_credentials to get fresh app-only tokens * - Messages sent as bot/app identity, not as user */ - const authData = { - accessToken: 'app-only', - }; - - const workspaceData = { - id: command.tenant, - }; - await this.createChannelConnection.execute( CreateChannelConnectionCommand.create({ identifier: stateData.identifier, @@ -68,19 +132,233 @@ export class MsTeamsOauthCallback { integrationIdentifier: integration.identifier, subscriberId: stateData.subscriberId, context: stateData.context, - auth: authData, - workspace: workspaceData, + auth: { accessToken: 'app-only' }, + workspace: { id: command.tenant }, }) ); + } - if (credentials.redirectUrl) { - return { type: ResponseTypeEnum.URL, result: credentials.redirectUrl }; + private async linkUserEndpoint( + command: MsTeamsOauthCallbackCommand, + stateData: StateData, + integration: IntegrationEntity, + credentials: ICredentialsEntity + ): Promise { + if (!stateData.subscriberId) { + throw new BadRequestException('subscriberId is required for link_user mode'); } - return { - type: ResponseTypeEnum.HTML, - result: this.SCRIPT_CLOSE_TAB, + if (!command.providerCode) { + throw new BadRequestException('Missing authorization code for link_user mode'); + } + + const decrypted = decryptCredentials(credentials); + const oid = await this.exchangeCodeForAadObjectId(command.providerCode, decrypted); + + await this.installBotForUser(oid, decrypted); + + await this.createChannelEndpoint.execute( + CreateChannelEndpointCommand.create({ + organizationId: stateData.organizationId, + environmentId: stateData.environmentId, + integrationIdentifier: integration.identifier, + connectionIdentifier: stateData.identifier, + subscriberId: stateData.subscriberId, + context: stateData.context, + type: ENDPOINT_TYPES.MS_TEAMS_USER, + endpoint: { userId: oid }, + }) + ); + } + + private async installBotForUser(oid: string, credentials: ICredentialsEntity): Promise { + const { clientId, secretKey, tenantId } = credentials; + + const graphToken = await this.msTeamsTokenService.getGraphToken( + clientId as string, + secretKey as string, + tenantId as string + ); + + const teamsAppId = await this.resolveTeamsAppId(graphToken, clientId as string); + + await this.installAppForUser(graphToken, oid, teamsAppId); + } + + private async resolveTeamsAppId(graphToken: string, azureClientId: string): Promise { + /* + * We scope the query to distributionMethod eq 'organization' to avoid picking up sideloaded + * copies of the same app. Filtering server-side guarantees a unique match: the org catalog + * allows only one published entry per externalId, so the combination is effectively unique. + * + * Edge case — store apps: globally-published Teams store apps use distributionMethod='store' + * and would be missed by this filter. That is intentional here: Novu customers supply their + * own Azure bot (clientId + secretKey), which is always an org-published custom app. Store + * apps use a different identity model. If store-app support is ever needed, expand the filter + * to: distributionMethod eq 'organization' or distributionMethod eq 'store'. + */ + const url = `${MS_GRAPH_BASE_URL}/appCatalogs/teamsApps?$filter=externalId eq '${azureClientId}' and distributionMethod eq 'organization'`; + + let response: { data: { value: Array<{ id: string }> } }; + + try { + response = await axios.get(url, { + headers: { Authorization: `Bearer ${graphToken}` }, + }); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 403) { + throw new BadRequestException( + 'MS Teams bot installation failed: missing Azure permissions. ' + + 'Please grant AppCatalog.Read.All and TeamsAppInstallation.ReadWriteSelfForUser.All ' + + 'application permissions in Azure Portal and re-run admin consent.' + ); + } + + throw new BadRequestException( + `MS Teams bot installation failed while resolving Teams app ID: ${ + axios.isAxiosError(error) ? error.message : String(error) + }` + ); + } + + const apps = response.data.value; + + if (!apps || apps.length === 0) { + throw new BadRequestException( + 'MS Teams bot installation failed: app not found in your organization catalog. ' + + 'Please upload the Teams app manifest to your organization catalog first.' + ); + } + + if (apps.length > 1) { + this.logger.warn( + `Multiple org-published Teams apps found for clientId ${azureClientId} — using first match (id=${apps[0].id})` + ); + } + + return apps[0].id; + } + + private async installAppForUser(graphToken: string, userOid: string, teamsAppId: string): Promise { + const url = `${MS_GRAPH_BASE_URL}/users/${userOid}/teamwork/installedApps`; + const body = { + 'teamsApp@odata.bind': `${MS_GRAPH_BASE_URL}/appCatalogs/teamsApps/${teamsAppId}`, }; + + try { + await axios.post(url, body, { + headers: { + Authorization: `Bearer ${graphToken}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + + if (status === 409) { + return; + } + + if (status === 403) { + throw new BadRequestException( + 'MS Teams bot installation failed: missing Azure permissions. ' + + 'Please grant TeamsAppInstallation.ReadWriteSelfForUser.All ' + + 'application permission in Azure Portal and re-run admin consent.' + ); + } + + if (status === 404) { + throw new BadRequestException( + 'MS Teams bot installation failed: user or app not found. ' + + 'Ensure the app is published to your organization catalog and the user exists in the tenant.' + ); + } + } + + throw new BadRequestException( + `MS Teams bot installation failed: ${axios.isAxiosError(error) ? error.message : String(error)}` + ); + } + } + + private buildErrorHtml(message: string): string { + const escaped = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + return ` + + + + MS Teams Setup Error + + + +
+

MS Teams Bot Installation Failed

+

${escaped}

+
+ +`; + } + + private async exchangeCodeForAadObjectId(code: string, credentials: ICredentialsEntity): Promise { + const { clientId, secretKey, tenantId } = credentials; + + if (!clientId || !secretKey || !tenantId) { + throw new BadRequestException( + 'MS Teams integration missing required credentials (clientId, secretKey, tenantId)' + ); + } + + const tokenParams = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: secretKey, + code, + redirect_uri: GenerateMsTeamsOauthUrl.buildRedirectUri(), + scope: 'openid profile User.Read', + }); + + const response = await axios.post( + `${this.MS_TEAMS_TOKEN_URL}/${tenantId}/oauth2/v2.0/token`, + tokenParams.toString(), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + + const { id_token: idToken } = response.data; + + if (!idToken) { + throw new BadRequestException('MS Teams OAuth response missing id_token'); + } + + const oid = this.extractOidFromIdToken(idToken); + + if (!oid) { + throw new BadRequestException('MS Teams id_token missing oid claim — ensure the Azure app is single-tenant'); + } + + return oid; + } + + private extractOidFromIdToken(idToken: string): string | undefined { + try { + const payload = idToken.split('.')[1]; + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); + + return decoded.oid as string | undefined; + } catch { + throw new BadRequestException('Failed to decode MS Teams id_token'); + } } private async getIntegration(stateData: StateData): Promise { diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.spec.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.spec.ts new file mode 100644 index 00000000000..0a10e0cce91 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.spec.ts @@ -0,0 +1,219 @@ +import { GetNovuProviderCredentials } from '@novu/application-generic'; +import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; +import { ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/shared'; +import axios from 'axios'; +import { expect } from 'chai'; +import { createHmac } from 'crypto'; +import sinon from 'sinon'; +import { CreateChannelConnection } from '../../../../channel-connections/usecases/create-channel-connection/create-channel-connection.usecase'; +import { CreateChannelEndpoint } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; +import { encodeOAuthState } from '../../generate-chat-oath-url/chat-oauth-state.util'; +import { SlackOauthCallbackCommand } from './slack-oauth-callback.command'; +import { SlackOauthCallback } from './slack-oauth-callback.usecase'; + +const MOCK_ENVIRONMENT_ID = 'env-id-123'; +const MOCK_ORGANIZATION_ID = 'org-id-456'; +const MOCK_API_KEY = 'test-api-key-for-hmac'; +const MOCK_CLIENT_ID = 'slack-client-id'; +const MOCK_SECRET_KEY = 'slack-secret-key'; +const MOCK_INTEGRATION_IDENTIFIER = 'slack-integration'; +const MOCK_SUBSCRIBER_ID = 'subscriber-abc'; +const MOCK_SLACK_USER_ID = 'U012AB3CD'; +const MOCK_TEAM_ID = 'T012AB3CD'; +const MOCK_TEAM_NAME = 'My Workspace'; +const MOCK_ACCESS_TOKEN = 'xoxb-mock-access-token'; +const MOCK_API_ROOT_URL = 'https://api.novu.co'; + +function buildMockIntegration(overrides: Record = {}) { + return { + _id: 'integration-id', + _environmentId: MOCK_ENVIRONMENT_ID, + _organizationId: MOCK_ORGANIZATION_ID, + identifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + channel: 'chat', + credentials: { + clientId: MOCK_CLIENT_ID, + secretKey: MOCK_SECRET_KEY, + }, + ...overrides, + } as any; +} + +function buildEncodedState(payload: Record): string { + const payloadStr = JSON.stringify({ ...payload, timestamp: Date.now() }); + const signature = createHmac('sha256', MOCK_API_KEY).update(payloadStr).digest('hex'); + + return encodeOAuthState(payloadStr, signature); +} + +function buildSlackAuthResponse(overrides: Record = {}) { + return { + ok: true, + access_token: MOCK_ACCESS_TOKEN, + team: { id: MOCK_TEAM_ID, name: MOCK_TEAM_NAME }, + authed_user: { id: MOCK_SLACK_USER_ID }, + ...overrides, + }; +} + +describe('SlackOauthCallback — autoLinkUser', () => { + let usecase: SlackOauthCallback; + let integrationRepository: sinon.SinonStubbedInstance; + let environmentRepository: sinon.SinonStubbedInstance; + let createChannelConnection: sinon.SinonStubbedInstance; + let createChannelEndpoint: sinon.SinonStubbedInstance; + let getNovuProviderCredentials: sinon.SinonStubbedInstance; + let axiosPost: sinon.SinonStub; + let originalApiRootUrl: string | undefined; + + beforeEach(() => { + integrationRepository = sinon.createStubInstance(IntegrationRepository); + environmentRepository = sinon.createStubInstance(EnvironmentRepository); + createChannelConnection = sinon.createStubInstance(CreateChannelConnection); + createChannelEndpoint = sinon.createStubInstance(CreateChannelEndpoint); + getNovuProviderCredentials = sinon.createStubInstance(GetNovuProviderCredentials); + + usecase = new SlackOauthCallback( + integrationRepository as any, + environmentRepository as any, + getNovuProviderCredentials as any, + createChannelConnection as any, + createChannelEndpoint as any + ); + + originalApiRootUrl = process.env.API_ROOT_URL; + process.env.API_ROOT_URL = MOCK_API_ROOT_URL; + + environmentRepository.findOne.resolves({ + _id: MOCK_ENVIRONMENT_ID, + apiKeys: [{ key: MOCK_API_KEY }], + } as any); + + environmentRepository.getApiKeys.resolves([{ key: MOCK_API_KEY }] as any); + + integrationRepository.findOne.resolves(buildMockIntegration()); + + axiosPost = sinon.stub(axios, 'post').resolves({ data: buildSlackAuthResponse() }); + + createChannelConnection.execute.resolves({ identifier: 'conn-abc' } as any); + createChannelEndpoint.execute.resolves({} as any); + }); + + afterEach(() => { + sinon.restore(); + process.env.API_ROOT_URL = originalApiRootUrl; + }); + + it('should create connection AND endpoint when autoLinkUser=true, subscriberId, and authed_user.id are present', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: true, + }); + + const command = SlackOauthCallbackCommand.create({ + providerCode: 'slack-code', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(createChannelEndpoint.execute.calledOnce).to.be.true; + + const endpointArg = createChannelEndpoint.execute.firstCall.args[0]; + expect(endpointArg.subscriberId).to.equal(MOCK_SUBSCRIBER_ID); + expect(endpointArg.type).to.equal(ENDPOINT_TYPES.SLACK_USER); + expect(endpointArg.endpoint.userId).to.equal(MOCK_SLACK_USER_ID); + }); + + it('should create connection but skip endpoint when autoLinkUser=true and subscriberId present but authed_user.id is missing', async () => { + axiosPost.resolves({ + data: buildSlackAuthResponse({ authed_user: undefined }), + }); + + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: true, + }); + + const command = SlackOauthCallbackCommand.create({ + providerCode: 'slack-code', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(createChannelEndpoint.execute.called).to.be.false; + }); + + it('should create connection but skip endpoint when autoLinkUser=false even if subscriberId and authed_user.id are present', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + subscriberId: MOCK_SUBSCRIBER_ID, + autoLinkUser: false, + }); + + const command = SlackOauthCallbackCommand.create({ + providerCode: 'slack-code', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(createChannelEndpoint.execute.called).to.be.false; + }); + + it('should create connection but skip endpoint when autoLinkUser is absent (raw API callers default to false)', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + subscriberId: MOCK_SUBSCRIBER_ID, + }); + + const command = SlackOauthCallbackCommand.create({ + providerCode: 'slack-code', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(createChannelEndpoint.execute.called).to.be.false; + }); + + it('should create connection but skip endpoint when autoLinkUser=true but subscriberId is absent', async () => { + const state = buildEncodedState({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integrationIdentifier: MOCK_INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Slack, + autoLinkUser: true, + }); + + const command = SlackOauthCallbackCommand.create({ + providerCode: 'slack-code', + state, + }); + + await usecase.execute(command); + + expect(createChannelConnection.execute.calledOnce).to.be.true; + expect(createChannelEndpoint.execute.called).to.be.false; + }); +}); diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts index 3bf1873b3b7..f7e685569c6 100644 --- a/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts @@ -81,8 +81,7 @@ export class SlackOauthCallback { }, }) ); - - if (stateData.subscriberId && authData.authed_user?.id) { + if (stateData.autoLinkUser === true && stateData.subscriberId && authData.authed_user?.id) { await this.createChannelEndpoint.execute( CreateChannelEndpointCommand.create({ organizationId: stateData.organizationId, diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts index 97b2837f7d4..11e71e5b9f3 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts @@ -1,6 +1,6 @@ import { IsValidContextPayload } from '@novu/application-generic'; import { ConnectionMode, ContextPayload } from '@novu/shared'; -import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import type { OAuthMode } from './generate-slack-oath-url/generate-slack-oauth-url.usecase'; @@ -40,4 +40,8 @@ export class GenerateChatOauthUrlCommand extends EnvironmentCommand { @IsString() @IsIn(['subscriber', 'shared']) readonly connectionMode?: ConnectionMode; + + @IsOptional() + @IsBoolean() + readonly autoLinkUser?: boolean; } diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts index a419dbdc68b..e62417daf8f 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts @@ -33,6 +33,7 @@ export class GenerateChatOauthUrl { userScope: command.userScope, mode: command.mode, connectionMode: command.connectionMode, + autoLinkUser: command.autoLinkUser, }) ); @@ -45,6 +46,8 @@ export class GenerateChatOauthUrl { subscriberId: command.subscriberId, integration, context: command.context, + mode: command.mode, + autoLinkUser: command.autoLinkUser, }) ); diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.command.ts new file mode 100644 index 00000000000..7e52066517c --- /dev/null +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.command.ts @@ -0,0 +1,36 @@ +import { IsValidContextPayload } from '@novu/application-generic'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class GenerateConnectOauthUrlCommand extends EnvironmentCommand { + @IsNotEmpty() + @IsString() + readonly integrationIdentifier: string; + + @IsOptional() + @IsString() + readonly connectionIdentifier?: string; + + @IsOptional() + @IsString() + readonly subscriberId?: string; + + @IsOptional() + @IsValidContextPayload({ maxCount: 5 }) + readonly context?: ContextPayload; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly scope?: string[]; + + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + readonly connectionMode?: ConnectionMode; + + @IsOptional() + @IsBoolean() + readonly autoLinkUser?: boolean; +} diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase.ts new file mode 100644 index 00000000000..5eee91ebe64 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase.ts @@ -0,0 +1,75 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { IntegrationEntity, IntegrationRepository } from '@novu/dal'; +import { ChannelTypeEnum, ChatProviderIdEnum } from '@novu/shared'; +import { GenerateConnectOauthUrlCommand } from './generate-connect-oauth-url.command'; +import { GenerateMsTeamsOauthUrlCommand } from './generate-msteams-oath-url/generate-msteams-oauth-url.command'; +import { GenerateMsTeamsOauthUrl } from './generate-msteams-oath-url/generate-msteams-oauth-url.usecase'; +import { GenerateSlackOauthUrlCommand } from './generate-slack-oath-url/generate-slack-oauth-url.command'; +import { GenerateSlackOauthUrl } from './generate-slack-oath-url/generate-slack-oauth-url.usecase'; + +@Injectable() +export class GenerateConnectOauthUrl { + constructor( + private generateSlackOAuthUrl: GenerateSlackOauthUrl, + private generateMsTeamsOAuthUrl: GenerateMsTeamsOauthUrl, + private integrationRepository: IntegrationRepository + ) {} + + async execute(command: GenerateConnectOauthUrlCommand): Promise { + const integration = await this.getIntegration(command); + + switch (integration.providerId) { + case ChatProviderIdEnum.Slack: + case ChatProviderIdEnum.Novu: + return this.generateSlackOAuthUrl.execute( + GenerateSlackOauthUrlCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + connectionIdentifier: command.connectionIdentifier, + subscriberId: command.subscriberId, + integration, + context: command.context, + scope: command.scope, + connectionMode: command.connectionMode, + autoLinkUser: command.autoLinkUser, + mode: 'connect', + }) + ); + + case ChatProviderIdEnum.MsTeams: + return this.generateMsTeamsOAuthUrl.execute( + GenerateMsTeamsOauthUrlCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + connectionIdentifier: command.connectionIdentifier, + subscriberId: command.subscriberId, + integration, + context: command.context, + autoLinkUser: command.autoLinkUser, + mode: 'connect', + }) + ); + + default: + throw new BadRequestException(`OAuth not supported for provider: ${integration.providerId}`); + } + } + + private async getIntegration(command: GenerateConnectOauthUrlCommand): Promise { + const integration = await this.integrationRepository.findOne({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + channel: ChannelTypeEnum.CHAT, + providerId: { $in: [ChatProviderIdEnum.Slack, ChatProviderIdEnum.Novu, ChatProviderIdEnum.MsTeams] }, + identifier: command.integrationIdentifier, + }); + + if (!integration) { + throw new NotFoundException( + `Integration not found: ${command.integrationIdentifier} in environment ${command.environmentId}` + ); + } + + return integration; + } +} diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.command.ts new file mode 100644 index 00000000000..3bc80c75567 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.command.ts @@ -0,0 +1,27 @@ +import { IsValidContextPayload } from '@novu/application-generic'; +import { ContextPayload } from '@novu/shared'; +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class GenerateLinkUserOauthUrlCommand extends EnvironmentCommand { + @IsNotEmpty() + @IsString() + readonly integrationIdentifier: string; + + @IsNotEmpty() + @IsString() + readonly subscriberId: string; + + @IsOptional() + @IsString() + readonly connectionIdentifier?: string; + + @IsOptional() + @IsValidContextPayload({ maxCount: 5 }) + readonly context?: ContextPayload; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly userScope?: string[]; +} diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase.ts new file mode 100644 index 00000000000..5030e9be3ba --- /dev/null +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase.ts @@ -0,0 +1,72 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { IntegrationEntity, IntegrationRepository } from '@novu/dal'; +import { ChannelTypeEnum, ChatProviderIdEnum } from '@novu/shared'; +import { GenerateLinkUserOauthUrlCommand } from './generate-link-user-oauth-url.command'; +import { GenerateMsTeamsOauthUrlCommand } from './generate-msteams-oath-url/generate-msteams-oauth-url.command'; +import { GenerateMsTeamsOauthUrl } from './generate-msteams-oath-url/generate-msteams-oauth-url.usecase'; +import { GenerateSlackOauthUrlCommand } from './generate-slack-oath-url/generate-slack-oauth-url.command'; +import { GenerateSlackOauthUrl } from './generate-slack-oath-url/generate-slack-oauth-url.usecase'; + +@Injectable() +export class GenerateLinkUserOauthUrl { + constructor( + private generateSlackOAuthUrl: GenerateSlackOauthUrl, + private generateMsTeamsOAuthUrl: GenerateMsTeamsOauthUrl, + private integrationRepository: IntegrationRepository + ) {} + + async execute(command: GenerateLinkUserOauthUrlCommand): Promise { + const integration = await this.getIntegration(command); + + switch (integration.providerId) { + case ChatProviderIdEnum.Slack: + case ChatProviderIdEnum.Novu: + return this.generateSlackOAuthUrl.execute( + GenerateSlackOauthUrlCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + connectionIdentifier: command.connectionIdentifier, + subscriberId: command.subscriberId, + integration, + context: command.context, + userScope: command.userScope, + mode: 'link_user', + }) + ); + + case ChatProviderIdEnum.MsTeams: + return this.generateMsTeamsOAuthUrl.execute( + GenerateMsTeamsOauthUrlCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + connectionIdentifier: command.connectionIdentifier, + subscriberId: command.subscriberId, + integration, + context: command.context, + mode: 'link_user', + }) + ); + + default: + throw new BadRequestException(`OAuth not supported for provider: ${integration.providerId}`); + } + } + + private async getIntegration(command: GenerateLinkUserOauthUrlCommand): Promise { + const integration = await this.integrationRepository.findOne({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + channel: ChannelTypeEnum.CHAT, + providerId: { $in: [ChatProviderIdEnum.Slack, ChatProviderIdEnum.Novu, ChatProviderIdEnum.MsTeams] }, + identifier: command.integrationIdentifier, + }); + + if (!integration) { + throw new NotFoundException( + `Integration not found: ${command.integrationIdentifier} in environment ${command.environmentId}` + ); + } + + return integration; + } +} diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.command.ts index b64dfc8b84c..7030277cdb6 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.command.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.command.ts @@ -1,8 +1,9 @@ import { IsValidContextPayload } from '@novu/application-generic'; import { IntegrationEntity } from '@novu/dal'; import { ContextPayload } from '@novu/shared'; -import { IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../../shared/commands/project.command'; +import { OAuthMode } from './generate-msteams-oauth-url.usecase'; export class GenerateMsTeamsOauthUrlCommand extends EnvironmentCommand { @IsOptional() @@ -18,4 +19,13 @@ export class GenerateMsTeamsOauthUrlCommand extends EnvironmentCommand { @IsOptional() @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; + + @IsOptional() + @IsString() + @IsIn(['connect', 'link_user']) + readonly mode?: OAuthMode; + + @IsOptional() + @IsBoolean() + readonly autoLinkUser?: boolean; } diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.spec.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.spec.ts new file mode 100644 index 00000000000..a130383e4e5 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.spec.ts @@ -0,0 +1,184 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { EnvironmentRepository, SubscriberRepository } from '@novu/dal'; +import { ChatProviderIdEnum } from '@novu/shared'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { GenerateMsTeamsOauthUrlCommand } from './generate-msteams-oauth-url.command'; +import { GenerateMsTeamsOauthUrl, MS_TEAMS_LINK_USER_OAUTH_SCOPES } from './generate-msteams-oauth-url.usecase'; + +const MOCK_ENVIRONMENT_ID = 'env-id-123'; +const MOCK_ORGANIZATION_ID = 'org-id-456'; +const MOCK_API_KEY = 'test-api-key-for-hmac'; +const MOCK_CLIENT_ID = 'azure-client-id'; +const MOCK_TENANT_ID = 'azure-tenant-id'; +const MOCK_SECRET_KEY = 'azure-secret'; +const MOCK_API_ROOT_URL = 'https://api.novu.co'; + +function buildMockIntegration(overrides: Record = {}) { + return { + _id: 'integration-id', + _environmentId: MOCK_ENVIRONMENT_ID, + _organizationId: MOCK_ORGANIZATION_ID, + identifier: 'msteams-integration', + providerId: ChatProviderIdEnum.MsTeams, + credentials: { + clientId: MOCK_CLIENT_ID, + secretKey: MOCK_SECRET_KEY, + tenantId: MOCK_TENANT_ID, + }, + ...overrides, + } as any; +} + +describe('GenerateMsTeamsOauthUrl', () => { + let usecase: GenerateMsTeamsOauthUrl; + let environmentRepository: sinon.SinonStubbedInstance; + let subscriberRepository: sinon.SinonStubbedInstance; + let originalApiRootUrl: string | undefined; + + beforeEach(() => { + environmentRepository = sinon.createStubInstance(EnvironmentRepository); + subscriberRepository = sinon.createStubInstance(SubscriberRepository); + usecase = new GenerateMsTeamsOauthUrl(environmentRepository as any, subscriberRepository as any); + + originalApiRootUrl = process.env.API_ROOT_URL; + process.env.API_ROOT_URL = MOCK_API_ROOT_URL; + + environmentRepository.getApiKeys.resolves([{ key: MOCK_API_KEY } as any]); + subscriberRepository.findOne.resolves({ _id: 'sub-id', subscriberId: 'subscriber-1' } as any); + }); + + afterEach(() => { + sinon.restore(); + process.env.API_ROOT_URL = originalApiRootUrl; + }); + + describe('connect mode (admin consent)', () => { + it('should return an admin consent URL when mode is not set', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + subscriberId: 'subscriber-1', + integration: buildMockIntegration(), + }); + + const url = await usecase.execute(command); + + expect(url).to.include('login.microsoftonline.com/organizations/v2.0/adminconsent'); + expect(url).to.include(`client_id=${MOCK_CLIENT_ID}`); + expect(url).to.include('scope=https%3A%2F%2Fgraph.microsoft.com%2F.default'); + }); + + it('should throw if subscriberId and context are both missing', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + integration: buildMockIntegration(), + }); + + try { + await usecase.execute(command); + expect.fail('Expected BadRequestException but none was thrown'); + } catch (err) { + expect(err).to.be.instanceOf(BadRequestException); + expect((err as BadRequestException).message).to.equal('Either subscriberId or context must be provided'); + } + }); + }); + + describe('link_user mode', () => { + it('should return a delegated OAuth authorize URL', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + subscriberId: 'subscriber-1', + integration: buildMockIntegration(), + mode: 'link_user', + }); + + const url = await usecase.execute(command); + + expect(url).to.include(`login.microsoftonline.com/${MOCK_TENANT_ID}/oauth2/v2.0/authorize`); + expect(url).to.include(`client_id=${MOCK_CLIENT_ID}`); + expect(url).to.include('response_type=code'); + expect(url).to.include('response_mode=query'); + }); + + it('should request the correct User.Read scopes', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + subscriberId: 'subscriber-1', + integration: buildMockIntegration(), + mode: 'link_user', + }); + + const url = await usecase.execute(command); + const decodedUrl = decodeURIComponent(url); + + for (const scope of MS_TEAMS_LINK_USER_OAUTH_SCOPES) { + expect(decodedUrl).to.include(scope); + } + }); + + it('should throw BadRequestException when only context is provided (no subscriberId) for link_user mode', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + context: { workflowId: 'wf-1', stepId: 'step-1' } as any, + integration: buildMockIntegration(), + mode: 'link_user', + }); + + try { + await usecase.execute(command); + expect.fail('Expected BadRequestException but none was thrown'); + } catch (err) { + expect(err).to.be.instanceOf(BadRequestException); + expect((err as BadRequestException).message).to.equal('subscriberId is required for link_user mode'); + } + }); + + it('should throw NotFoundException when tenantId is missing for link_user mode', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + subscriberId: 'subscriber-1', + integration: buildMockIntegration({ credentials: { clientId: MOCK_CLIENT_ID, secretKey: MOCK_SECRET_KEY } }), + mode: 'link_user', + }); + + try { + await usecase.execute(command); + expect.fail('Expected NotFoundException but none was thrown'); + } catch (err) { + expect(err).to.be.instanceOf(NotFoundException); + expect((err as NotFoundException).message).to.equal('MS Teams integration missing tenantId'); + } + }); + + it('should encode mode in the OAuth state so the callback can branch correctly', async () => { + const command = GenerateMsTeamsOauthUrlCommand.create({ + environmentId: MOCK_ENVIRONMENT_ID, + organizationId: MOCK_ORGANIZATION_ID, + subscriberId: 'subscriber-1', + integration: buildMockIntegration(), + mode: 'link_user', + }); + + const url = await usecase.execute(command); + const parsed = new URL(url); + const rawState = parsed.searchParams.get('state'); + + expect(rawState).to.not.be.null; + + // State is base64url(jsonPayload.hexSignature); decode then split on last dot + const decoded = Buffer.from(rawState as string, 'base64url').toString('utf-8'); + const lastDot = decoded.lastIndexOf('.'); + const payloadStr = decoded.slice(0, lastDot); + const payload = JSON.parse(payloadStr); + + expect(payload.mode).to.equal('link_user'); + }); + }); +}); diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.ts index 38aa97d78b4..129538f86e1 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.ts @@ -6,6 +6,10 @@ import { CHAT_OAUTH_CALLBACK_PATH } from '../chat-oauth.constants'; import { encodeOAuthState, splitOAuthState } from '../chat-oauth-state.util'; import { GenerateMsTeamsOauthUrlCommand } from './generate-msteams-oauth-url.command'; +export type OAuthMode = 'connect' | 'link_user'; + +export const MS_TEAMS_LINK_USER_OAUTH_SCOPES = ['openid', 'profile', 'User.Read'] as const; + export type StateData = { identifier?: string; subscriberId?: string; @@ -15,6 +19,8 @@ export type StateData = { integrationIdentifier: string; providerId: ChatProviderIdEnum; timestamp: number; + mode?: OAuthMode; + autoLinkUser?: boolean; }; @Injectable() @@ -40,7 +46,8 @@ export class GenerateMsTeamsOauthUrl { this.validateSubscriberIdOrContext(command); await this.assertResourceExists(command); - const { clientId } = await this.getIntegrationCredentials(command.integration); + const credentials = await this.getIntegrationCredentials(command.integration); + const { clientId } = credentials; if (!clientId) { throw new NotFoundException('MS Teams integration missing clientId'); @@ -50,10 +57,27 @@ export class GenerateMsTeamsOauthUrl { command.integration, command.subscriberId, command.context, - command.connectionIdentifier + command.connectionIdentifier, + command.mode, + command.autoLinkUser ); - return this.getOAuthUrl(clientId, secureState); + if (command.mode === 'link_user') { + // the callback requires subscriberId to be present + if (!command.subscriberId) { + throw new BadRequestException('subscriberId is required for link_user mode'); + } + + const { tenantId } = credentials; + + if (!tenantId) { + throw new NotFoundException('MS Teams integration missing tenantId'); + } + + return this.getLinkUserOAuthUrl(clientId, tenantId, secureState); + } + + return this.getAdminConsentUrl(clientId, secureState); } private validateSubscriberIdOrContext(command: GenerateMsTeamsOauthUrlCommand): void { @@ -82,7 +106,7 @@ export class GenerateMsTeamsOauthUrl { return; } - private async getOAuthUrl(clientId: string, secureState: string): Promise { + private getAdminConsentUrl(clientId: string, secureState: string): string { const oauthParams = new URLSearchParams({ client_id: clientId, redirect_uri: GenerateMsTeamsOauthUrl.buildRedirectUri(), @@ -93,11 +117,26 @@ export class GenerateMsTeamsOauthUrl { return `${this.MS_TEAMS_ADMIN_CONSENT_URL}${oauthParams.toString()}`; } + private getLinkUserOAuthUrl(clientId: string, tenantId: string, secureState: string): string { + const oauthParams = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: GenerateMsTeamsOauthUrl.buildRedirectUri(), + scope: MS_TEAMS_LINK_USER_OAUTH_SCOPES.join(' '), + state: secureState, + response_mode: 'query', + }); + + return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${oauthParams.toString()}`; + } + private async createSecureState( integration: IntegrationEntity, subscriberId?: string, context?: ContextPayload, - connectionIdentifier?: string + connectionIdentifier?: string, + mode?: OAuthMode, + autoLinkUser?: boolean ): Promise { const { _environmentId, _organizationId, identifier, providerId } = integration; @@ -110,6 +149,8 @@ export class GenerateMsTeamsOauthUrl { integrationIdentifier: identifier, providerId: providerId as ChatProviderIdEnum, timestamp: Date.now(), + mode, + autoLinkUser, }; const payload = JSON.stringify(stateData); diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts index 7dc74a166fd..8ee48edc3bf 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts @@ -1,7 +1,7 @@ import { IsValidContextPayload } from '@novu/application-generic'; import { IntegrationEntity } from '@novu/dal'; import { ConnectionMode, ContextPayload } from '@novu/shared'; -import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../../shared/commands/project.command'; import { OAuthMode } from './generate-slack-oauth-url.usecase'; @@ -39,4 +39,8 @@ export class GenerateSlackOauthUrlCommand extends EnvironmentCommand { @IsString() @IsIn(['subscriber', 'shared']) readonly connectionMode?: ConnectionMode; + + @IsOptional() + @IsBoolean() + readonly autoLinkUser?: boolean; } diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts index 94bc18ed675..37e706bfe5b 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts @@ -31,6 +31,7 @@ export type StateData = { timestamp: number; mode?: OAuthMode; connectionMode?: ConnectionMode; + autoLinkUser?: boolean; }; export const SLACK_DEFAULT_OAUTH_SCOPES = [ @@ -69,7 +70,8 @@ export class GenerateSlackOauthUrl { command.context, command.connectionIdentifier, command.mode, - command.connectionMode + command.connectionMode, + command.autoLinkUser ); const resolvedScope = command.mode === 'link_user' ? undefined : await this.resolveBotScopes(command); @@ -163,7 +165,8 @@ export class GenerateSlackOauthUrl { context?: ContextPayload, connectionIdentifier?: string, mode?: OAuthMode, - connectionMode?: ConnectionMode + connectionMode?: ConnectionMode, + autoLinkUser?: boolean ): Promise { const { _environmentId, _organizationId, identifier, providerId } = integration; @@ -178,6 +181,7 @@ export class GenerateSlackOauthUrl { timestamp: Date.now(), mode, connectionMode, + autoLinkUser, }; const payload = JSON.stringify(stateData); diff --git a/apps/api/src/app/integrations/usecases/index.ts b/apps/api/src/app/integrations/usecases/index.ts index fb090bc9848..1cf2ebe3111 100644 --- a/apps/api/src/app/integrations/usecases/index.ts +++ b/apps/api/src/app/integrations/usecases/index.ts @@ -15,6 +15,8 @@ import { CheckIntegrationEMail } from './check-integration/check-integration-ema import { CreateIntegration } from './create-integration/create-integration.usecase'; import { CreateNovuIntegrations } from './create-novu-integrations/create-novu-integrations.usecase'; import { GenerateChatOauthUrl } from './generate-chat-oath-url/generate-chat-oauth-url.usecase'; +import { GenerateConnectOauthUrl } from './generate-chat-oath-url/generate-connect-oauth-url.usecase'; +import { GenerateLinkUserOauthUrl } from './generate-chat-oath-url/generate-link-user-oauth-url.usecase'; import { GenerateMsTeamsOauthUrl } from './generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase'; import { GenerateSlackOauthUrl } from './generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; import { GetInAppActivated } from './get-in-app-activated/get-in-app-activated.usecase'; @@ -43,6 +45,8 @@ export const USE_CASES = [ CreateNovuIntegrations, NormalizeVariables, GenerateChatOauthUrl, + GenerateConnectOauthUrl, + GenerateLinkUserOauthUrl, GenerateSlackOauthUrl, GenerateMsTeamsOauthUrl, SlackOauthCallback, diff --git a/apps/dashboard/src/components/agents/teams-setup-guide.tsx b/apps/dashboard/src/components/agents/teams-setup-guide.tsx index f90ca8bfc4f..59cb70bd5a2 100644 --- a/apps/dashboard/src/components/agents/teams-setup-guide.tsx +++ b/apps/dashboard/src/components/agents/teams-setup-guide.tsx @@ -1,3 +1,5 @@ +import { useUser } from '@clerk/clerk-react'; +import { MsTeamsConnectButton, NovuProvider } from '@novu/react'; import { ChatProviderIdEnum } from '@novu/shared'; import { Download } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; @@ -9,7 +11,9 @@ import { CodeBlock } from '@/components/primitives/code-block'; import { CopyButton } from '@/components/primitives/copy-button'; import { InlineToast } from '@/components/primitives/inline-toast'; import { API_HOSTNAME } from '@/config'; +import { useEnvironment } from '@/context/environment/hooks'; import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { cn } from '@/utils/ui'; import { IntegrationCredentialsSidebar, ListeningStatus, SetupButton, SetupStep } from './setup-guide-primitives'; import { deriveStepStatus } from './setup-guide-step-utils'; @@ -43,6 +47,10 @@ function buildWebhookUrl(agentId: string, integrationIdentifier: string): string return `${getApiBaseUrl()}/v1/agents/${agentId}/webhook/${integrationIdentifier}`; } +function buildOAuthCallbackUrl(): string { + return `${getApiBaseUrl()}/v1/integrations/chat/oauth/callback`; +} + function buildManifest(appId: string, agentName: string): Record { const id = appId || 'YOUR_APP_ID'; const name = agentName || 'Novu Agent'; @@ -86,6 +94,24 @@ function buildManifest(appId: string, agentName: string): Record +

OAuth callback URL

+
+ + +
+ + ); +} + function WebhookUrlSection({ webhookUrl }: { webhookUrl: string }) { return (
@@ -147,6 +173,8 @@ export function TeamsSetupGuide({ onStepsCompleted, embedded = false, }: TeamsSetupGuideProps) { + const { user } = useUser(); + const { currentEnvironment } = useEnvironment(); const [isCredentialsSidebarOpen, setIsCredentialsSidebarOpen] = useState(false); const [isConnected, setIsConnected] = useState(false); @@ -189,14 +217,14 @@ export function TeamsSetupGuide({ const firstIncomplete = useMemo(() => { if (isConnected) { - return base + 5; + return base + 7; } if (!hasCredentials) { return base; } - return base + 4; + return base + 6; }, [base, hasCredentials, isConnected]); const steps = ( @@ -225,6 +253,74 @@ export function TeamsSetupGuide({ + {'In your App Registration, go to '} + API permissions + {' → '} + Add a permission + {' → '} + Microsoft Graph + {' → '} + Application permissions + {'. Search for and add: '} + Team.ReadBasic.All + {', '} + Channel.ReadBasic.All + {', '} + AppCatalog.Read.All + {'. Then click '} + Grant admin consent + {' for your organization.'} + + } + rightContent={ + + Open App Registrations + + } + extraContent={ + + {'These Graph permissions let your bot discover Teams and channels. Optionally add '} + TeamsAppInstallation.ReadWriteSelfForTeam.All + {' and '} + TeamsAppInstallation.ReadWriteSelfForUser.All + {' to enable programmatic app installation.'} + + } + /> + } + /> + + + {'In your App Registration, go to '} + Authentication + {' → '} + Add a platform + {' → '} + Web + {'. Paste the URL below as the Redirect URI, then click '} + Configure + {'. This is where Microsoft sends the user after they grant admin consent.'} + + } + rightContent={} + /> + + } /> @@ -299,31 +395,75 @@ export function TeamsSetupGuide({ Upload a custom app {' and select the downloaded '} .zip - {' file.'} + {' file. Then use the button to grant admin consent and verify the connection.'}

-

{'Once installed, @mention the bot in a channel or send it a direct message to confirm it responds.'}

} + rightContent={ + user?.externalId && currentEnvironment?.identifier ? ( + + + + ) : null + } extraContent={ - - {'For org deployment, use the '} - - Teams Admin Center - - {' → Teams apps → Manage apps → Upload new app.'} - - } - /> +
+ + {'For org deployment, use the '} + + Teams Admin Center + + {' → Teams apps → Manage apps → Upload new app.'} + + } + /> + + {'Novu provides a '} + {''} + {' component to let your subscribers link their Teams identity for direct-message notifications. '} + + Read docs + + + } + /> +
} /> diff --git a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts index 04bcf8275c7..311135e5fe4 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CachedResponse, decryptCredentials, InstrumentUsecase } from '@novu/application-generic'; +import { Injectable } from '@nestjs/common'; +import { decryptCredentials, InstrumentUsecase, MsTeamsTokenService } from '@novu/application-generic'; import { ChannelConnectionEntity, ChannelConnectionRepository, @@ -9,7 +9,6 @@ import { } from '@novu/dal'; import { ProvidersIdEnum } from '@novu/shared'; import { ChannelData, ENDPOINT_TYPES, ENDPOINT_TYPES_REQUIRING_TOKEN } from '@novu/stateless'; -import axios from 'axios'; import { ResolveChannelEndpointsCommand } from './resolve-channel-endpoints.command'; export type IntegrationEndpoints = { @@ -41,12 +40,11 @@ export type IntegrationEndpoints = { */ @Injectable() export class ResolveChannelEndpoints { - private readonly logger = new Logger(ResolveChannelEndpoints.name); - constructor( private readonly channelEndpointRepository: ChannelEndpointRepository, private readonly channelConnectionRepository: ChannelConnectionRepository, - private readonly integrationRepository: IntegrationRepository + private readonly integrationRepository: IntegrationRepository, + private readonly msTeamsTokenService: MsTeamsTokenService ) {} @InstrumentUsecase() @@ -209,12 +207,13 @@ export class ResolveChannelEndpoints { const decryptedCredentials = decryptCredentials(integration.credentials); const { clientId, secretKey, tenantId } = decryptedCredentials; + if (!clientId || !secretKey || !tenantId) { throw new Error(`Integration ${endpoint.integrationIdentifier} missing required MS Teams credentials`); } // Fetch Bot Framework token with caching - const token = await this.getMsTeamsBotToken(clientId, secretKey, tenantId); + const token = await this.msTeamsTokenService.getBotFrameworkToken(clientId, secretKey, tenantId); // For user DMs, include clientId (bot app ID) needed to create conversation if (endpoint.type === ENDPOINT_TYPES.MS_TEAMS_USER) { @@ -242,49 +241,4 @@ export class ResolveChannelEndpoints { return 'accessToken' in connection.auth ? connection.auth.accessToken : undefined; } - - /** - * Fetches Bot Framework token for MS Teams with caching - * Cache key: msteams:bot-token:{clientId}:{appTenantId} - * TTL: 55 minutes (1 hour token - 5 min safety buffer) - * - * Note: Returns empty string on failure to allow graceful degradation. - * Provider will fail with clear error message that bubbles to customer. - */ - @CachedResponse({ - builder: (clientId: string, _secretKey: string, appTenantId: string) => - `msteams:bot-token:${clientId}:${appTenantId}`, - options: { - ttl: 3300, // 55 minutes (3600 - 300 seconds) - skipSaveToCache: (token: string) => token === '', // Don't cache failures - }, - }) - private async getMsTeamsBotToken(clientId: string, secretKey: string, appTenantId: string): Promise { - const tokenUrl = `https://login.microsoftonline.com/${appTenantId}/oauth2/v2.0/token`; - const body = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: clientId, - client_secret: secretKey, - scope: 'https://api.botframework.com/.default', - }); - - try { - const response = await axios.post<{ access_token: string; expires_in: number }>(tokenUrl, body.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - - return response.data.access_token; - } catch (error) { - // Log error but return empty string to allow graceful degradation - // Provider will fail with proper error that reaches customer - const errorMessage = - axios.isAxiosError(error) && error.response - ? `Failed to fetch MS Teams bot token: ${error.response.status} - ${JSON.stringify(error.response.data)}` - : `Failed to fetch MS Teams bot token: ${error.message || error}`; - - this.logger.error(errorMessage, error.stack); - - return ''; // Empty token will cause provider to fail with clear error - } - } } diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index 7c246a110d8..a793aa72085 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -502,6 +502,15 @@ export class SendMessageChat extends SendMessageBase { } if (!integration) { + Logger.warn( + { + hasChatWebhookUrl: Boolean(chatWebhookUrl), + hasPhoneNumber: Boolean(phoneNumber), + messageId: String(message?._id ?? ''), + }, + `${LOG_CONTEXT} — sendErrors: missing integration (unexpected if getAndValidateIntegration succeeded)` + ); + await this.sendErrorStatus( message, 'warning', @@ -644,6 +653,20 @@ export class SendMessageChat extends SendMessageBase { ? `Integration with integrationId: ${integrationId} is either deleted or not active` : `Integration is either deleted or not active`; + Logger.warn( + { + reason, + providerId, + hasIntegrationId: Boolean(integrationId), + hasIntegrationIdentifier: Boolean(integrationIdentifier), + environmentId: command.environmentId, + organizationId: command.organizationId, + jobId: String(command.job?._id ?? ''), + hasJobTenant: Boolean(command.job.tenant), + }, + `${LOG_CONTEXT} — getAndValidateIntegration: no integration from SelectIntegration` + ); + await this.createExecutionDetail( command, DetailEnum.SUBSCRIBER_NO_ACTIVE_INTEGRATION, diff --git a/apps/worker/src/app/workflow/workflow.module.ts b/apps/worker/src/app/workflow/workflow.module.ts index 34aa52ee62f..5c10d46b378 100644 --- a/apps/worker/src/app/workflow/workflow.module.ts +++ b/apps/worker/src/app/workflow/workflow.module.ts @@ -18,6 +18,7 @@ import { GetSubscriberTemplatePreference, GetTopicSubscribersUseCase, InMemoryProviderService, + MsTeamsTokenService, NormalizeVariables, ProcessTenant, RedisThrottleService, @@ -205,7 +206,7 @@ const USE_CASES = [ ResolveChannelEndpoints, ]; -const PROVIDERS: Provider[] = [RedisThrottleService]; +const PROVIDERS: Provider[] = [RedisThrottleService, MsTeamsTokenService]; const activeWorkersToken: any = { provide: 'ACTIVE_WORKERS', useFactory: (...args: any[]) => { diff --git a/libs/application-generic/src/services/index.ts b/libs/application-generic/src/services/index.ts index 9ee84a8e8da..183db85ca7b 100644 --- a/libs/application-generic/src/services/index.ts +++ b/libs/application-generic/src/services/index.ts @@ -30,6 +30,7 @@ export { MessageInteractionTrace, } from './message-interaction.service'; export * from './metrics'; +export { MsTeamsTokenService } from './ms-teams-token.service'; export * from './query-parser'; export * from './queues'; export { INovuWorker, ReadinessService } from './readiness'; diff --git a/libs/application-generic/src/services/ms-teams-token.service.spec.ts b/libs/application-generic/src/services/ms-teams-token.service.spec.ts new file mode 100644 index 00000000000..d21babe798d --- /dev/null +++ b/libs/application-generic/src/services/ms-teams-token.service.spec.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { PinoLogger } from '../logging'; +import { MsTeamsTokenService } from './ms-teams-token.service'; + +const MOCK_CLIENT_ID = 'client-id-abc'; +const MOCK_SECRET_KEY = 'secret-key-xyz'; +const MOCK_TENANT_ID = 'tenant-id-123'; +const MOCK_ACCESS_TOKEN = 'mock-access-token'; + +function buildService(): MsTeamsTokenService { + const logger = { + setContext: sinon.stub(), + error: sinon.stub(), + } as unknown as PinoLogger; + + return new MsTeamsTokenService(logger); +} + +describe('MsTeamsTokenService', () => { + let axiosPost: sinon.SinonStub; + let service: MsTeamsTokenService; + + beforeEach(() => { + axiosPost = sinon.stub(axios, 'post'); + service = buildService(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getGraphToken', () => { + it('should request a client_credentials token with the Graph scope', async () => { + axiosPost.resolves({ data: { access_token: MOCK_ACCESS_TOKEN, expires_in: 3600 } }); + + const token = await service.getGraphToken(MOCK_CLIENT_ID, MOCK_SECRET_KEY, MOCK_TENANT_ID); + + expect(token).to.equal(MOCK_ACCESS_TOKEN); + + const [url, body] = axiosPost.firstCall.args; + expect(url).to.include(`/${MOCK_TENANT_ID}/oauth2/v2.0/token`); + + const params = new URLSearchParams(body as string); + expect(params.get('grant_type')).to.equal('client_credentials'); + expect(params.get('scope')).to.equal('https://graph.microsoft.com/.default'); + expect(params.get('client_id')).to.equal(MOCK_CLIENT_ID); + }); + + it('should throw when the Graph token request fails', async () => { + axiosPost.rejects(new Error('Network error')); + + let thrownError: Error | undefined; + + try { + await service.getGraphToken(MOCK_CLIENT_ID, MOCK_SECRET_KEY, MOCK_TENANT_ID); + } catch (err) { + thrownError = err as Error; + } + + expect(thrownError).to.be.instanceOf(Error); + expect(thrownError?.message).to.include('Network error'); + }); + }); + + describe('getBotFrameworkToken', () => { + it('should request a client_credentials token with the Bot Framework scope', async () => { + axiosPost.resolves({ data: { access_token: MOCK_ACCESS_TOKEN, expires_in: 3600 } }); + + const token = await service.getBotFrameworkToken(MOCK_CLIENT_ID, MOCK_SECRET_KEY, MOCK_TENANT_ID); + + expect(token).to.equal(MOCK_ACCESS_TOKEN); + + const [url, body] = axiosPost.firstCall.args; + expect(url).to.include(`/${MOCK_TENANT_ID}/oauth2/v2.0/token`); + + const params = new URLSearchParams(body as string); + expect(params.get('grant_type')).to.equal('client_credentials'); + expect(params.get('scope')).to.equal('https://api.botframework.com/.default'); + }); + + it('should return an empty string and log on network failure (graceful degradation)', async () => { + axiosPost.rejects(new Error('Network error')); + + const token = await service.getBotFrameworkToken(MOCK_CLIENT_ID, MOCK_SECRET_KEY, MOCK_TENANT_ID); + + expect(token).to.equal(''); + }); + + it('should return an empty string and log on HTTP error response', async () => { + const axiosError = Object.assign(new Error('Unauthorized'), { + isAxiosError: true, + response: { status: 401, data: { error: 'unauthorized' } }, + }); + sinon.stub(axios, 'isAxiosError').returns(true); + axiosPost.rejects(axiosError); + + const token = await service.getBotFrameworkToken(MOCK_CLIENT_ID, MOCK_SECRET_KEY, MOCK_TENANT_ID); + + expect(token).to.equal(''); + }); + }); +}); diff --git a/libs/application-generic/src/services/ms-teams-token.service.ts b/libs/application-generic/src/services/ms-teams-token.service.ts new file mode 100644 index 00000000000..78660d40cf1 --- /dev/null +++ b/libs/application-generic/src/services/ms-teams-token.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { createHash } from 'crypto'; +import { PinoLogger } from '../logging'; +import { CachedResponse } from './cache/interceptors/cached-response.decorator'; + +function shortSecretHash(secret: string): string { + return createHash('sha256').update(secret).digest('hex').slice(0, 8); +} + +const MS_AAD_TOKEN_URL = 'https://login.microsoftonline.com'; +const BOT_FRAMEWORK_SCOPE = 'https://api.botframework.com/.default'; +const GRAPH_SCOPE = 'https://graph.microsoft.com/.default'; +const TOKEN_TTL_SECONDS = 3300; // 55 minutes (1-hour token minus 5-minute buffer) + +@Injectable() +export class MsTeamsTokenService { + constructor(private logger: PinoLogger) { + this.logger.setContext(MsTeamsTokenService.name); + } + + /** + * Acquires an app-only Microsoft Graph token using the client credentials flow. + * Required permissions: AppCatalog.Read.All, TeamsAppInstallation.ReadWriteSelfForUser.All + * Cache key: msteams:graph-token:{clientId}:{appTenantId}:{secretHash}, TTL 55 minutes. + * The secretHash (first 8 hex chars of SHA-256) ensures cache entries are + * automatically invalidated after a secret rotation. + */ + @CachedResponse({ + builder: (clientId: string, secretKey: string, appTenantId: string) => + `msteams:graph-token:${clientId}:${appTenantId}:${shortSecretHash(secretKey)}`, + options: { + ttl: TOKEN_TTL_SECONDS, + skipSaveToCache: (token: string) => token === '', + }, + }) + async getGraphToken(clientId: string, secretKey: string, appTenantId: string): Promise { + return this.fetchClientCredentialsToken(clientId, secretKey, appTenantId, GRAPH_SCOPE); + } + + /** + * Acquires a Bot Framework token using the client credentials flow. + * Migrated from ResolveChannelEndpoints.getMsTeamsBotToken. + * Cache key: msteams:bot-token:{clientId}:{appTenantId}:{secretHash}, TTL 55 minutes. + * Returns empty string on failure to allow graceful degradation in the send path. + */ + @CachedResponse({ + builder: (clientId: string, secretKey: string, appTenantId: string) => + `msteams:bot-token:${clientId}:${appTenantId}:${shortSecretHash(secretKey)}`, + options: { + ttl: TOKEN_TTL_SECONDS, + skipSaveToCache: (token: string) => token === '', + }, + }) + async getBotFrameworkToken(clientId: string, secretKey: string, appTenantId: string): Promise { + try { + return await this.fetchClientCredentialsToken(clientId, secretKey, appTenantId, BOT_FRAMEWORK_SCOPE); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && error.response + ? `Failed to fetch MS Teams bot token: ${error.response.status} - ${JSON.stringify(error.response.data)}` + : `Failed to fetch MS Teams bot token: ${(error as Error).message || error}`; + + this.logger.error(errorMessage, (error as Error).stack); + + return ''; + } + } + + private async fetchClientCredentialsToken( + clientId: string, + secretKey: string, + appTenantId: string, + scope: string + ): Promise { + const tokenUrl = `${MS_AAD_TOKEN_URL}/${appTenantId}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: secretKey, + scope, + }); + + const response = await axios.post<{ access_token: string; expires_in: number }>(tokenUrl, body.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 10000, + }); + + return response.data.access_token; + } +} diff --git a/package.json b/package.json index 57e89357a9b..d05c7e2d146 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:ws": "nx build @novu/ws", "build": "nx run-many --target=build --all --exclude=nextjs,nestjs", "clean": "rimraf **/build **/dist **/node_modules", + "cache:clean": "pnpm nx reset && pnpm store prune", "commit": "cz", "dev-environment-setup": "sh ./scripts/dev-environment-setup.sh", "docker:build": "pnpm -r --if-present --parallel docker:build", diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 4a4b9d0d960..2bb8b0ea26b 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -15,7 +15,7 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - limitInBytes: 213_000, + limitInBytes: 215_000, }, { name: 'UMD gzip', diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index 52e83a17d6e..62b04f64110 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -5,6 +5,8 @@ import type { CreateChannelConnectionArgs, CreateChannelEndpointArgs, GenerateChatOAuthUrlArgs, + GenerateConnectOAuthUrlArgs, + GenerateLinkUserOAuthUrlArgs, ListChannelConnectionsArgs, ListChannelEndpointsArgs, } from '../channel-connections/types'; @@ -34,7 +36,9 @@ const INBOX_ROUTE = '/inbox'; const INBOX_NOTIFICATIONS_ROUTE = `${INBOX_ROUTE}/notifications`; const CHAT_OAUTH_ROUTE = `${INBOX_ROUTE}/chat/oauth`; const CHANNEL_CONNECTIONS_ROUTE = `${INBOX_ROUTE}/channel-connections`; +const CHANNEL_CONNECTIONS_OAUTH_ROUTE = `${CHANNEL_CONNECTIONS_ROUTE}/oauth`; const CHANNEL_ENDPOINTS_ROUTE = `${INBOX_ROUTE}/channel-endpoints`; +const CHANNEL_ENDPOINTS_OAUTH_ROUTE = `${CHANNEL_ENDPOINTS_ROUTE}/oauth`; type ChannelListBaseArgs = { subscriberId?: string; @@ -542,6 +546,9 @@ export class InboxService { return this.#httpClient.delete(`${INBOX_ROUTE}/topics/${topicKey}/subscriptions/${identifier}`); } + /** + * @deprecated Use generateConnectOAuthUrl() or generateLinkUserOAuthUrl() instead. + */ generateChatOAuthUrl({ integrationIdentifier, connectionIdentifier, @@ -551,6 +558,7 @@ export class InboxService { userScope, mode, connectionMode, + autoLinkUser, }: GenerateChatOAuthUrlArgs): Promise<{ url: string }> { return this.#httpClient.post(CHAT_OAUTH_ROUTE, { integrationIdentifier, @@ -561,6 +569,43 @@ export class InboxService { userScope, mode, connectionMode, + autoLinkUser, + }); + } + + generateConnectOAuthUrl({ + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + autoLinkUser, + }: GenerateConnectOAuthUrlArgs): Promise<{ url: string }> { + return this.#httpClient.post(CHANNEL_CONNECTIONS_OAUTH_ROUTE, { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + autoLinkUser, + }); + } + + generateLinkUserOAuthUrl({ + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + userScope, + }: GenerateLinkUserOAuthUrlArgs): Promise<{ url: string }> { + return this.#httpClient.post(CHANNEL_ENDPOINTS_OAUTH_ROUTE, { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + userScope, }); } diff --git a/packages/js/src/channel-connections/channel-connections.ts b/packages/js/src/channel-connections/channel-connections.ts index 1b0bf3476d7..31476a440e8 100644 --- a/packages/js/src/channel-connections/channel-connections.ts +++ b/packages/js/src/channel-connections/channel-connections.ts @@ -2,11 +2,18 @@ import { InboxService } from '../api'; import { BaseModule } from '../base-module'; import { NovuEventEmitter } from '../event-emitter'; import type { Result } from '../types'; -import { deleteChannelConnection, generateChatOAuthUrl, getChannelConnection, listChannelConnections } from './helpers'; +import { + deleteChannelConnection, + generateChatOAuthUrl, + generateConnectOAuthUrl, + getChannelConnection, + listChannelConnections, +} from './helpers'; import type { ChannelConnectionResponse, DeleteChannelConnectionArgs, GenerateChatOAuthUrlArgs, + GenerateConnectOAuthUrlArgs, GetChannelConnectionArgs, ListChannelConnectionsArgs, } from './types'; @@ -22,6 +29,9 @@ export class ChannelConnections extends BaseModule { super({ inboxServiceInstance, eventEmitterInstance }); } + /** + * @deprecated Use generateConnectOAuthUrl() instead. For user-level linking use channelEndpoints.generateLinkUserOAuthUrl(). + */ async generateOAuthUrl(args: GenerateChatOAuthUrlArgs): Result<{ url: string }> { return this.callWithSession(() => generateChatOAuthUrl({ @@ -32,6 +42,16 @@ export class ChannelConnections extends BaseModule { ); } + async generateConnectOAuthUrl(args: GenerateConnectOAuthUrlArgs): Result<{ url: string }> { + return this.callWithSession(() => + generateConnectOAuthUrl({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + async list(args: ListChannelConnectionsArgs = {}): Result { return this.callWithSession(() => listChannelConnections({ diff --git a/packages/js/src/channel-connections/helpers.ts b/packages/js/src/channel-connections/helpers.ts index c030d4ef652..e364071cc09 100644 --- a/packages/js/src/channel-connections/helpers.ts +++ b/packages/js/src/channel-connections/helpers.ts @@ -6,6 +6,7 @@ import type { ChannelConnectionResponse, DeleteChannelConnectionArgs, GenerateChatOAuthUrlArgs, + GenerateConnectOAuthUrlArgs, GetChannelConnectionArgs, ListChannelConnectionsArgs, } from './types'; @@ -32,6 +33,28 @@ export const generateChatOAuthUrl = async ({ } }; +export const generateConnectOAuthUrl = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: GenerateConnectOAuthUrlArgs; +}): Result<{ url: string }> => { + try { + emitter.emit('channel-connection.oauth-url.pending', { args }); + const data = await apiService.generateConnectOAuthUrl(args); + emitter.emit('channel-connection.oauth-url.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-connection.oauth-url.resolved', { args, error }); + + return { error: new NovuError('Failed to generate connect OAuth URL', error) }; + } +}; + export const listChannelConnections = async ({ emitter, apiService, diff --git a/packages/js/src/channel-connections/types.ts b/packages/js/src/channel-connections/types.ts index fb1aa591a93..7e592e8aa90 100644 --- a/packages/js/src/channel-connections/types.ts +++ b/packages/js/src/channel-connections/types.ts @@ -13,6 +13,9 @@ export type OAuthMode = 'connect' | 'link_user'; export type ConnectionMode = 'subscriber' | 'shared'; +/** + * @deprecated Use GenerateConnectOAuthUrlArgs or GenerateLinkUserOAuthUrlArgs instead. + */ export type GenerateChatOAuthUrlArgs = { integrationIdentifier: string; connectionIdentifier?: string; @@ -22,6 +25,30 @@ export type GenerateChatOAuthUrlArgs = { userScope?: string[]; mode?: OAuthMode; connectionMode?: ConnectionMode; + autoLinkUser?: boolean; +}; + +/** Args for creating a workspace/tenant channel connection (Slack install or MS Teams admin consent). */ +export type GenerateConnectOAuthUrlArgs = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + /** Slack only: OAuth bot scopes to request. */ + scope?: string[]; + connectionMode?: ConnectionMode; + autoLinkUser?: boolean; +}; + +/** Args for linking a subscriber to their personal chat identity (Slack user or MS Teams user OID). */ +export type GenerateLinkUserOAuthUrlArgs = { + integrationIdentifier: string; + connectionIdentifier?: string; + /** Required — this operation always binds a specific subscriber to a user identity. */ + subscriberId: string; + context?: Context; + /** Slack only: user-level OAuth scopes (e.g. identity.basic). */ + userScope?: string[]; }; export type ListChannelConnectionsArgs = { diff --git a/packages/js/src/channel-endpoints/channel-endpoints.ts b/packages/js/src/channel-endpoints/channel-endpoints.ts index c0ed21a5b00..9cb601c2a9e 100644 --- a/packages/js/src/channel-endpoints/channel-endpoints.ts +++ b/packages/js/src/channel-endpoints/channel-endpoints.ts @@ -4,12 +4,19 @@ import type { ChannelEndpointResponse, CreateChannelEndpointArgs, DeleteChannelEndpointArgs, + GenerateLinkUserOAuthUrlArgs, GetChannelEndpointArgs, ListChannelEndpointsArgs, } from '../channel-connections/types'; import { NovuEventEmitter } from '../event-emitter'; import type { Result } from '../types'; -import { createChannelEndpoint, deleteChannelEndpoint, getChannelEndpoint, listChannelEndpoints } from './helpers'; +import { + createChannelEndpoint, + deleteChannelEndpoint, + generateLinkUserOAuthUrl, + getChannelEndpoint, + listChannelEndpoints, +} from './helpers'; export class ChannelEndpoints extends BaseModule { constructor({ @@ -22,6 +29,16 @@ export class ChannelEndpoints extends BaseModule { super({ inboxServiceInstance, eventEmitterInstance }); } + async generateLinkUserOAuthUrl(args: GenerateLinkUserOAuthUrlArgs): Result<{ url: string }> { + return this.callWithSession(() => + generateLinkUserOAuthUrl({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + async list(args: ListChannelEndpointsArgs = {}): Result { return this.callWithSession(() => listChannelEndpoints({ diff --git a/packages/js/src/channel-endpoints/helpers.ts b/packages/js/src/channel-endpoints/helpers.ts index cabbb3305ee..d25a535d202 100644 --- a/packages/js/src/channel-endpoints/helpers.ts +++ b/packages/js/src/channel-endpoints/helpers.ts @@ -3,6 +3,7 @@ import type { ChannelEndpointResponse, CreateChannelEndpointArgs, DeleteChannelEndpointArgs, + GenerateLinkUserOAuthUrlArgs, GetChannelEndpointArgs, ListChannelEndpointsArgs, } from '../channel-connections/types'; @@ -10,6 +11,28 @@ import type { NovuEventEmitter } from '../event-emitter'; import type { Result } from '../types'; import { NovuError } from '../utils/errors'; +export const generateLinkUserOAuthUrl = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: GenerateLinkUserOAuthUrlArgs; +}): Result<{ url: string }> => { + try { + emitter.emit('channel-endpoint.oauth-url.pending', { args }); + const data = await apiService.generateLinkUserOAuthUrl(args); + emitter.emit('channel-endpoint.oauth-url.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-endpoint.oauth-url.resolved', { args, error }); + + return { error: new NovuError('Failed to generate link user OAuth URL', error) }; + } +}; + export const listChannelEndpoints = async ({ emitter, apiService, diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index e5eaf6358d6..816a86c0673 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -5,6 +5,7 @@ import type { DeleteChannelConnectionArgs, DeleteChannelEndpointArgs, GenerateChatOAuthUrlArgs, + GenerateLinkUserOAuthUrlArgs, GetChannelConnectionArgs, GetChannelEndpointArgs, ListChannelConnectionsArgs, @@ -140,6 +141,11 @@ type ChannelConnectionGetEvents = BaseEvents< >; type ChannelConnectionDeleteEvents = BaseEvents<'channel-connection.delete', DeleteChannelConnectionArgs, void>; +type ChannelEndpointOAuthUrlEvents = BaseEvents< + 'channel-endpoint.oauth-url', + GenerateLinkUserOAuthUrlArgs, + { url: string } +>; type ChannelEndpointsFetchEvents = BaseEvents< 'channel-endpoints.list', ListChannelEndpointsArgs, @@ -204,6 +210,7 @@ export type Events = SessionInitializeEvents & ChannelConnectionsFetchEvents & ChannelConnectionGetEvents & ChannelConnectionDeleteEvents & + ChannelEndpointOAuthUrlEvents & ChannelEndpointsFetchEvents & ChannelEndpointGetEvents & ChannelEndpointCreateEvents & diff --git a/packages/js/src/ui/api/hooks/useChannelConnection.ts b/packages/js/src/ui/api/hooks/useChannelConnection.ts index 6547007c1ef..4a072d162a9 100644 --- a/packages/js/src/ui/api/hooks/useChannelConnection.ts +++ b/packages/js/src/ui/api/hooks/useChannelConnection.ts @@ -2,7 +2,7 @@ import { createEffect, createResource, createSignal, onCleanup, onMount } from ' import type { ChannelConnectionResponse, DeleteChannelConnectionArgs, - GenerateChatOAuthUrlArgs, + GenerateConnectOAuthUrlArgs, GetChannelConnectionArgs, } from '../../../channel-connections/types'; import { useNovu } from '../../context'; @@ -33,12 +33,8 @@ export const useChannelConnection = (options: UseChannelConnectionOptions) => { } }); - const connect = async (args: GenerateChatOAuthUrlArgs) => { - setLoading(true); - const response = await novuAccessor().channelConnections.generateOAuthUrl(args); - setLoading(false); - - return response; + const generateConnectOAuthUrl = async (args: GenerateConnectOAuthUrlArgs) => { + return novuAccessor().channelConnections.generateConnectOAuthUrl(args); }; const disconnect = async (identifier: string) => { @@ -109,5 +105,5 @@ export const useChannelConnection = (options: UseChannelConnectionOptions) => { setLoading(connection.loading); }); - return { connection, loading, mutate, refetch, connect, disconnect }; + return { connection, loading, mutate, refetch, generateConnectOAuthUrl, disconnect }; }; diff --git a/packages/js/src/ui/api/hooks/useChannelEndpoint.ts b/packages/js/src/ui/api/hooks/useChannelEndpoint.ts index 1107e96284d..cf73b1cf12e 100644 --- a/packages/js/src/ui/api/hooks/useChannelEndpoint.ts +++ b/packages/js/src/ui/api/hooks/useChannelEndpoint.ts @@ -1,5 +1,9 @@ import { createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js'; -import type { ChannelEndpointResponse, CreateChannelEndpointArgs } from '../../../channel-connections/types'; +import type { + ChannelEndpointResponse, + CreateChannelEndpointArgs, + GenerateLinkUserOAuthUrlArgs, +} from '../../../channel-connections/types'; import { useNovu } from '../../context'; export type UseChannelEndpointOptions = { @@ -29,6 +33,10 @@ export const useChannelEndpoint = (options: UseChannelEndpointOptions) => { } }); + const generateLinkUserOAuthUrl = async (args: GenerateLinkUserOAuthUrlArgs) => { + return novuAccessor().channelEndpoints.generateLinkUserOAuthUrl(args); + }; + const create = async (args: CreateChannelEndpointArgs) => { setLoading(true); const response = await novuAccessor().channelEndpoints.create(args); @@ -90,5 +98,5 @@ export const useChannelEndpoint = (options: UseChannelEndpointOptions) => { setLoading(endpoint.loading); }); - return { endpoint, loading, mutate, refetch, create, remove }; + return { endpoint, loading, mutate, refetch, generateLinkUserOAuthUrl, create, remove }; }; diff --git a/packages/js/src/ui/components/Renderer.tsx b/packages/js/src/ui/components/Renderer.tsx index fba2d7dd150..5106412d485 100644 --- a/packages/js/src/ui/components/Renderer.tsx +++ b/packages/js/src/ui/components/Renderer.tsx @@ -26,6 +26,8 @@ import type { import { ConnectChat } from './connect-chat/ConnectChat'; import { Bell, Root } from './elements'; import { Inbox, InboxContent, InboxContentProps, InboxPage } from './Inbox'; +import { MsTeamsConnectButton } from './msteams-connect-button/MsTeamsConnectButton'; +import { MsTeamsLinkUser } from './msteams-link-user/MsTeamsLinkUser'; import { SlackConnectButton } from './slack-connect-button/SlackConnectButton'; import { SlackLinkUser } from './slack-link-user/SlackLinkUser'; import { Subscription } from './subscription/Subscription'; @@ -66,10 +68,18 @@ export const novuComponents = { ConnectChat, SlackLinkUser, SlackConnectButton, + MsTeamsLinkUser, + MsTeamsConnectButton, }; const SUBSCRIPTION_COMPONENTS = ['Subscription', 'SubscriptionButton', 'SubscriptionPreferences']; -const CHANNEL_COMPONENTS = ['ConnectChat', 'SlackLinkUser', 'SlackConnectButton']; +const CHANNEL_COMPONENTS = [ + 'ConnectChat', + 'SlackLinkUser', + 'SlackConnectButton', + 'MsTeamsLinkUser', + 'MsTeamsConnectButton', +]; export type NovuComponent = { name: NovuComponentName; props?: any }; diff --git a/packages/js/src/ui/components/connect-chat/ConnectChat.tsx b/packages/js/src/ui/components/connect-chat/ConnectChat.tsx index 711628bdbab..a23b7bc0cb6 100644 --- a/packages/js/src/ui/components/connect-chat/ConnectChat.tsx +++ b/packages/js/src/ui/components/connect-chat/ConnectChat.tsx @@ -24,7 +24,12 @@ export type ConnectChatProps = { export const ConnectChat = (props: ConnectChatProps) => { const style = useStyle(); const novuAccessor = useNovu(); - const { connection, loading, connect, disconnect } = useChannelConnection({ + const { + connection, + loading, + generateConnectOAuthUrl: connect, + disconnect, + } = useChannelConnection({ integrationIdentifier: props.integrationIdentifier, connectionIdentifier: props.connectionIdentifier, subscriberId: props.subscriberId, diff --git a/packages/js/src/ui/components/constants.ts b/packages/js/src/ui/components/constants.ts new file mode 100644 index 00000000000..1c2605e8f43 --- /dev/null +++ b/packages/js/src/ui/components/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_MSTEAMS_CONNECTION_IDENTIFIER = 'chconn-msteams-default'; +export const DEFAULT_SLACK_CONNECTION_IDENTIFIER = 'chconn-slack-default'; diff --git a/packages/js/src/ui/components/index.ts b/packages/js/src/ui/components/index.ts index babdf5aef98..68044551113 100644 --- a/packages/js/src/ui/components/index.ts +++ b/packages/js/src/ui/components/index.ts @@ -1,6 +1,8 @@ export * from './connect-chat/ConnectChat'; export * from './elements'; export * from './Inbox'; +export * from './msteams-connect-button/MsTeamsConnectButton'; +export * from './msteams-link-user/MsTeamsLinkUser'; export * from './primitives'; export * from './slack-connect-button/SlackConnectButton'; export * from './slack-link-user/SlackLinkUser'; diff --git a/packages/js/src/ui/components/msteams-connect-button/MsTeamsConnectButton.tsx b/packages/js/src/ui/components/msteams-connect-button/MsTeamsConnectButton.tsx new file mode 100644 index 00000000000..149d5a28a07 --- /dev/null +++ b/packages/js/src/ui/components/msteams-connect-button/MsTeamsConnectButton.tsx @@ -0,0 +1,329 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js'; +import type { ConnectionMode } from '../../../channel-connections/types'; +import type { Context } from '../../../types'; +import { useChannelConnection } from '../../api/hooks/useChannelConnection'; +import { useNovu } from '../../context'; +import { useStyle } from '../../helpers/useStyle'; +import { CheckCircleFill } from '../../icons/CheckCircleFill'; +import { Loader } from '../../icons/Loader'; +import { MsTeamsColored } from '../../icons/MsTeamsColored'; +import type { ChannelConnectButtonAppearanceCallback } from '../../types'; +import { DEFAULT_MSTEAMS_CONNECTION_IDENTIFIER } from '../constants'; +import { Button, Motion } from '../primitives'; +import { Tooltip } from '../primitives/Tooltip'; +import { IconRendererWrapper } from '../shared/IconRendererWrapper'; + +export type MsTeamsConnectButtonProps = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + scope?: string[]; + connectionMode?: ConnectionMode; + /** + * When true (default), after the admin consent step completes the OAuth flow automatically + * chains into a delegated user-identity step, linking the subscriber who clicked "Connect" + * as a personal MS Teams endpoint in the same popup window. + * Set to false to perform only the tenant-level admin consent without linking the user. + */ + autoLinkUser?: boolean; + onConnectSuccess?: (connectionIdentifier: string) => void; + onConnectError?: (error: unknown) => void; + onDisconnectSuccess?: () => void; + onDisconnectError?: (error: unknown) => void; + connectLabel?: string; + connectedLabel?: string; +}; + +const POLL_INITIAL_INTERVAL_MS = 2_500; // 2.5 seconds +const POLL_MAX_INTERVAL_MS = 30_000; // 30 seconds +const POLL_BACKOFF_FACTOR = 1.5; +const POLL_TIMEOUT_MS = 300_000; // 5 minutes + +export const MsTeamsConnectButton = (props: MsTeamsConnectButtonProps) => { + const style = useStyle(); + const novuAccessor = useNovu(); + const integrationIdentifier = () => props.integrationIdentifier; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_MSTEAMS_CONNECTION_IDENTIFIER; + + const { connection, loading, disconnect, mutate, generateConnectOAuthUrl } = useChannelConnection({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: props.subscriberId, + }); + + const [actionLoading, setActionLoading] = createSignal(false); + + const connectionMode = () => props.connectionMode ?? 'subscriber'; + const resolvedContext = () => props.context ?? novuAccessor().context; + const isMisconfigured = createMemo(() => connectionMode() === 'shared' && !resolvedContext()); + + createEffect(() => { + if (isMisconfigured()) { + console.warn( + '[Novu] MsTeamsConnectButton: "context" is required when connectionMode is "shared". ' + + 'Provide it via the context prop on MsTeamsConnectButton or on NovuProvider.' + ); + } + }); + + const isConnected = () => !!connection(); + const isLoading = () => loading() || actionLoading(); + + const timeoutIdRef: { current: ReturnType | null } = { current: null }; + + onCleanup(() => { + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + }); + + const startPolling = () => { + const connId = connectionIdentifier(); + + if (timeoutIdRef.current !== null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + + const startedAt = Date.now(); + + const schedulePoll = (intervalMs: number) => { + timeoutIdRef.current = setTimeout(async () => { + try { + const response = await novuAccessor().channelConnections.get({ + identifier: connId, + }); + + if (response.data) { + timeoutIdRef.current = null; + setActionLoading(false); + mutate(response.data); + props.onConnectSuccess?.(connId); + + return; + } + } catch { + // ignore transient errors during polling + } + + if (Date.now() - startedAt >= POLL_TIMEOUT_MS) { + timeoutIdRef.current = null; + setActionLoading(false); + props.onConnectError?.(new Error('MS Teams OAuth timed out. Please try again.')); + + return; + } + + const nextInterval = Math.min(intervalMs * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL_MS); + schedulePoll(nextInterval); + }, intervalMs); + }; + + schedulePoll(POLL_INITIAL_INTERVAL_MS); + }; + + const handleClick = async () => { + if (isConnected()) { + const identifier = connection()?.identifier; + if (!identifier) return; + + const result = await disconnect(identifier); + if (result.error) { + props.onDisconnectError?.(result.error); + } else { + props.onDisconnectSuccess?.(); + } + } else { + setActionLoading(true); + + const mode = connectionMode(); + const ctx = resolvedContext(); + const resolvedSubscriberId = + mode === 'subscriber' ? (props.subscriberId ?? novuAccessor().subscriberId) : undefined; + + const result = await generateConnectOAuthUrl({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: resolvedSubscriberId, + context: ctx, + scope: props.scope, + connectionMode: mode, + autoLinkUser: mode === 'subscriber' ? (props.autoLinkUser ?? true) : false, + }); + + if (result.error) { + setActionLoading(false); + props.onConnectError?.(result.error); + + return; + } + + if (result.data?.url) { + window.open(result.data.url, '_blank', 'noopener,noreferrer'); + startPolling(); + } else { + setActionLoading(false); + props.onConnectError?.(new Error('OAuth URL was not returned. Please try again.')); + } + } + }; + + const buttonContent = () => ( + [0], + })} + > + + {isConnected() ? ( + [0], + })} + fallback={ + [0], + })} + > + + + } + /> + ) : ( + [0], + })} + fallback={ + [0], + })} + /> + } + /> + )} + [0], + })} + > + {isConnected() ? (props.connectedLabel ?? 'Connected') : (props.connectLabel ?? 'Connect MS Teams')} + + + + + + + ); + + return ( + }> +
[0], + })} + > + + ( + + )} + /> + + Missing context — provide a context prop on MsTeamsConnectButton or{' '} + NovuProvider when using connectionMode="shared" + + + } + > + + +
+
+ ); +}; diff --git a/packages/js/src/ui/components/msteams-link-user/MsTeamsLinkUser.tsx b/packages/js/src/ui/components/msteams-link-user/MsTeamsLinkUser.tsx new file mode 100644 index 00000000000..83a4253154a --- /dev/null +++ b/packages/js/src/ui/components/msteams-link-user/MsTeamsLinkUser.tsx @@ -0,0 +1,290 @@ +import { createResource, createSignal, onCleanup, onMount, Show } from 'solid-js'; +import type { ChannelEndpointResponse } from '../../../channel-connections/types'; +import type { Context } from '../../../types'; +import { useChannelEndpoint } from '../../api/hooks/useChannelEndpoint'; +import { useNovu } from '../../context'; +import { useStyle } from '../../helpers/useStyle'; +import { CheckCircleFill } from '../../icons/CheckCircleFill'; +import { Loader } from '../../icons/Loader'; +import { MsTeamsColored } from '../../icons/MsTeamsColored'; +import type { MsTeamsLinkUserAppearanceCallback } from '../../types'; +import { DEFAULT_MSTEAMS_CONNECTION_IDENTIFIER } from '../constants'; +import { Button, Motion } from '../primitives'; +import { IconRendererWrapper } from '../shared/IconRendererWrapper'; + +export type MsTeamsLinkUserProps = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + onLinkSuccess?: (endpoint: { identifier: string }) => void; + onLinkError?: (error: unknown) => void; + onUnlinkSuccess?: () => void; + onUnlinkError?: (error: unknown) => void; + linkLabel?: string; + unlinkLabel?: string; +}; + +const POLL_INTERVAL_MS = 2500; +const POLL_TIMEOUT_MS = 120_000; + +export const MsTeamsLinkUser = (props: MsTeamsLinkUserProps) => { + const style = useStyle(); + const novuAccessor = useNovu(); + const integrationIdentifier = () => props.integrationIdentifier; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_MSTEAMS_CONNECTION_IDENTIFIER; + + const { generateLinkUserOAuthUrl } = useChannelEndpoint({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: props.subscriberId, + }); + + const [endpoint, setEndpoint] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [actionLoading, setActionLoading] = createSignal(false); + + let pollingIntervalId: ReturnType | undefined; + + onCleanup(() => { + clearInterval(pollingIntervalId); + }); + + const isLinked = () => !!endpoint(); + const isLoading = () => loading() || actionLoading(); + + createResource( + () => ({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + }), + async ({ integrationIdentifier: intId, connectionIdentifier: connId }) => { + setLoading(true); + + try { + const response = await novuAccessor().channelEndpoints.list({ + integrationIdentifier: intId, + connectionIdentifier: connId, + }); + const existing = response.data?.find((ep) => ep.type === 'ms_teams_user') ?? null; + setEndpoint(existing); + } catch { + setEndpoint(null); + } finally { + setLoading(false); + } + } + ); + + onMount(() => { + const currentNovu = novuAccessor(); + + const cleanupDelete = currentNovu.on('channel-endpoint.delete.resolved', ({ args }) => { + if (args?.identifier && args.identifier === endpoint()?.identifier) { + setEndpoint(null); + } + }); + + onCleanup(() => { + cleanupDelete(); + }); + }); + + const startPolling = () => { + const startedAt = Date.now(); + + pollingIntervalId = setInterval(async () => { + try { + const response = await novuAccessor().channelEndpoints.list({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + }); + const found = response.data?.find((ep) => ep.type === 'ms_teams_user') ?? null; + + if (found) { + clearInterval(pollingIntervalId); + setActionLoading(false); + setEndpoint(found); + props.onLinkSuccess?.({ identifier: found.identifier }); + + return; + } + } catch { + // ignore transient errors during polling + } + + if (Date.now() - startedAt >= POLL_TIMEOUT_MS) { + clearInterval(pollingIntervalId); + setActionLoading(false); + props.onLinkError?.(new Error('MS Teams OAuth timed out. Please try again.')); + } + }, POLL_INTERVAL_MS); + }; + + const handleClick = async () => { + if (isLinked()) { + const identifier = endpoint()?.identifier; + if (!identifier) return; + + setActionLoading(true); + const result = await novuAccessor().channelEndpoints.delete({ identifier }); + setActionLoading(false); + + if (result.error) { + props.onUnlinkError?.(result.error); + } else { + setEndpoint(null); + props.onUnlinkSuccess?.(); + } + } else { + const resolvedSubscriberId = props.subscriberId ?? novuAccessor().subscriberId; + if (!resolvedSubscriberId) { + props.onLinkError?.(new Error('subscriberId is required to link an MS Teams user')); + + return; + } + + setActionLoading(true); + + const result = await generateLinkUserOAuthUrl({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: resolvedSubscriberId, + context: props.context, + }); + + if (result.error) { + setActionLoading(false); + props.onLinkError?.(result.error); + + return; + } + + if (result.data?.url) { + window.open(result.data.url, '_blank', 'noopener,noreferrer'); + startPolling(); + } else { + setActionLoading(false); + props.onLinkError?.(new Error('OAuth URL was not returned. Please try again.')); + } + } + }; + + return ( +
[0], + })} + > + +
+ ); +}; diff --git a/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx b/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx index be6fad8d00f..4dae7cc5a2b 100644 --- a/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx +++ b/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx @@ -8,18 +8,27 @@ import { CheckCircleFill } from '../../icons/CheckCircleFill'; import { Loader } from '../../icons/Loader'; import { SlackColored } from '../../icons/SlackColored'; import type { ChannelConnectButtonAppearanceCallback } from '../../types'; +import { DEFAULT_SLACK_CONNECTION_IDENTIFIER } from '../constants'; import { Button, Motion } from '../primitives'; import { Tooltip } from '../primitives/Tooltip'; import { IconRendererWrapper } from '../shared/IconRendererWrapper'; -import { DEFAULT_CONNECTION_IDENTIFIER, DEFAULT_INTEGRATION_IDENTIFIER } from '../slack-constants'; export type SlackConnectButtonProps = { - integrationIdentifier?: string; + integrationIdentifier: string; connectionIdentifier?: string; subscriberId?: string; context?: Context; scope?: string[]; connectionMode?: ConnectionMode; + /** + * When true (default), after the workspace connection is created the OAuth + * flow also links the subscriber who clicked "Connect" as a personal Slack + * endpoint using the authed_user.id already returned by oauth.v2.access. + * Set to false to skip the user-linking step and only create the workspace + * connection. Raw API callers must pass true explicitly; this component + * defaults to true. + */ + autoLinkUser?: boolean; onConnectSuccess?: (connectionIdentifier: string) => void; onConnectError?: (error: unknown) => void; onDisconnectSuccess?: () => void; @@ -34,10 +43,10 @@ const POLL_TIMEOUT_MS = 120_000; export const SlackConnectButton = (props: SlackConnectButtonProps) => { const style = useStyle(); const novuAccessor = useNovu(); - const integrationIdentifier = () => props.integrationIdentifier ?? DEFAULT_INTEGRATION_IDENTIFIER; - const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_CONNECTION_IDENTIFIER; + const integrationIdentifier = () => props.integrationIdentifier; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_SLACK_CONNECTION_IDENTIFIER; - const { connection, loading, connect, disconnect, mutate } = useChannelConnection({ + const { connection, loading, disconnect, mutate, generateConnectOAuthUrl } = useChannelConnection({ integrationIdentifier: integrationIdentifier(), connectionIdentifier: connectionIdentifier(), subscriberId: props.subscriberId, @@ -71,6 +80,8 @@ export const SlackConnectButton = (props: SlackConnectButtonProps) => { }); const startPolling = () => { + const connId = connectionIdentifier(); + if (intervalIdRef.current !== null) { clearInterval(intervalIdRef.current); intervalIdRef.current = null; @@ -81,7 +92,7 @@ export const SlackConnectButton = (props: SlackConnectButtonProps) => { intervalIdRef.current = setInterval(async () => { try { const response = await novuAccessor().channelConnections.get({ - identifier: connectionIdentifier(), + identifier: connId, }); if (response.data) { @@ -92,7 +103,7 @@ export const SlackConnectButton = (props: SlackConnectButtonProps) => { setActionLoading(false); mutate(response.data); - props.onConnectSuccess?.(connectionIdentifier()); + props.onConnectSuccess?.(connId); return; } @@ -131,13 +142,14 @@ export const SlackConnectButton = (props: SlackConnectButtonProps) => { const resolvedSubscriberId = mode === 'subscriber' ? (props.subscriberId ?? novuAccessor().subscriberId) : undefined; - const result = await connect({ + const result = await generateConnectOAuthUrl({ integrationIdentifier: integrationIdentifier(), connectionIdentifier: connectionIdentifier(), subscriberId: resolvedSubscriberId, context: ctx, scope: props.scope, connectionMode: mode, + autoLinkUser: mode === 'subscriber' ? (props.autoLinkUser ?? true) : false, }); if (result.error) { @@ -150,6 +162,9 @@ export const SlackConnectButton = (props: SlackConnectButtonProps) => { if (result.data?.url) { window.open(result.data.url, '_blank', 'noopener,noreferrer'); startPolling(); + } else { + setActionLoading(false); + props.onConnectError?.(new Error('OAuth URL was not returned. Please try again.')); } } }; diff --git a/packages/js/src/ui/components/slack-constants.ts b/packages/js/src/ui/components/slack-constants.ts deleted file mode 100644 index 826597a2c34..00000000000 --- a/packages/js/src/ui/components/slack-constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_CONNECTION_IDENTIFIER = 'chconn-slack-default'; -export const DEFAULT_INTEGRATION_IDENTIFIER = 'slack'; diff --git a/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx b/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx index 879d3f35f73..6a0b8864748 100644 --- a/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx +++ b/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx @@ -1,18 +1,19 @@ import { createResource, createSignal, onCleanup, onMount, Show } from 'solid-js'; import type { ChannelEndpointResponse } from '../../../channel-connections/types'; import type { Context } from '../../../types'; +import { useChannelEndpoint } from '../../api/hooks/useChannelEndpoint'; import { useNovu } from '../../context'; import { useStyle } from '../../helpers/useStyle'; import { CheckCircleFill } from '../../icons/CheckCircleFill'; import { Loader } from '../../icons/Loader'; import { SlackColored } from '../../icons/SlackColored'; import type { SlackLinkUserAppearanceCallback } from '../../types'; +import { DEFAULT_SLACK_CONNECTION_IDENTIFIER } from '../constants'; import { Button, Motion } from '../primitives'; import { IconRendererWrapper } from '../shared/IconRendererWrapper'; -import { DEFAULT_CONNECTION_IDENTIFIER, DEFAULT_INTEGRATION_IDENTIFIER } from '../slack-constants'; export type SlackLinkUserProps = { - integrationIdentifier?: string; + integrationIdentifier: string; connectionIdentifier?: string; subscriberId?: string; context?: Context; @@ -30,8 +31,14 @@ const POLL_TIMEOUT_MS = 120_000; export const SlackLinkUser = (props: SlackLinkUserProps) => { const style = useStyle(); const novuAccessor = useNovu(); - const integrationIdentifier = () => props.integrationIdentifier ?? DEFAULT_INTEGRATION_IDENTIFIER; - const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_CONNECTION_IDENTIFIER; + const integrationIdentifier = () => props.integrationIdentifier; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_SLACK_CONNECTION_IDENTIFIER; + + const { generateLinkUserOAuthUrl } = useChannelEndpoint({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: props.subscriberId, + }); const [endpoint, setEndpoint] = createSignal(null); const [loading, setLoading] = createSignal(true); @@ -130,14 +137,20 @@ export const SlackLinkUser = (props: SlackLinkUserProps) => { props.onUnlinkSuccess?.(); } } else { + const resolvedSubscriberId = props.subscriberId ?? novuAccessor().subscriberId; + if (!resolvedSubscriberId) { + props.onLinkError?.(new Error('subscriberId is required to link a Slack user')); + + return; + } + setActionLoading(true); - const result = await novuAccessor().channelConnections.generateOAuthUrl({ + const result = await generateLinkUserOAuthUrl({ integrationIdentifier: integrationIdentifier(), connectionIdentifier: connectionIdentifier(), - subscriberId: props.subscriberId ?? novuAccessor().subscriberId, + subscriberId: resolvedSubscriberId, context: props.context, - mode: 'link_user', userScope: ['identity.basic'], }); @@ -151,6 +164,9 @@ export const SlackLinkUser = (props: SlackLinkUserProps) => { if (result.data?.url) { window.open(result.data.url, '_blank', 'noopener,noreferrer'); startPolling(); + } else { + setActionLoading(false); + props.onLinkError?.(new Error('OAuth URL was not returned. Please try again.')); } } }; diff --git a/packages/js/src/ui/config/appearanceKeys.ts b/packages/js/src/ui/config/appearanceKeys.ts index aaf1bbfada3..0569e7634cd 100644 --- a/packages/js/src/ui/config/appearanceKeys.ts +++ b/packages/js/src/ui/config/appearanceKeys.ts @@ -362,11 +362,20 @@ export const linkSlackUserAppearanceKeys = [ 'linkSlackUserButtonLabel', ] as const; +export const linkMsTeamsUserAppearanceKeys = [ + 'linkMsTeamsUserContainer', + 'linkMsTeamsUserButton', + 'linkMsTeamsUserButtonContainer', + 'linkMsTeamsUserButtonIcon', + 'linkMsTeamsUserButtonLabel', +] as const; + export const appearanceKeys = [ ...commonAppearanceKeys, ...inboxAppearanceKeys, ...subscriptionAppearanceKeys, ...connectChatAppearanceKeys, ...linkSlackUserAppearanceKeys, + ...linkMsTeamsUserAppearanceKeys, ...channelConnectButtonAppearanceKeys, ]; diff --git a/packages/js/src/ui/icons/ArrowDown.tsx b/packages/js/src/ui/icons/ArrowDown.tsx index 43064a27fc8..17d339fcfc8 100644 --- a/packages/js/src/ui/icons/ArrowDown.tsx +++ b/packages/js/src/ui/icons/ArrowDown.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const ArrowDown = (props?: JSX.HTMLAttributes) => { +export const ArrowDown = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const ArrowDropDown = (props?: JSX.SvgSVGAttributes) => { return ( diff --git a/packages/js/src/ui/icons/ArrowLeft.tsx b/packages/js/src/ui/icons/ArrowLeft.tsx index ed9a7787f8d..883dd35a6c9 100644 --- a/packages/js/src/ui/icons/ArrowLeft.tsx +++ b/packages/js/src/ui/icons/ArrowLeft.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const ArrowLeft = (props?: JSX.HTMLAttributes) => { +export const ArrowLeft = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const ArrowRight = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const ArrowUpRight = (props?: JSX.SvgSVGAttributes) => { return ( ; +type BellProps = JSX.SvgSVGAttributes; export function Bell(props: BellProps) { return ( diff --git a/packages/js/src/ui/icons/BellCross.tsx b/packages/js/src/ui/icons/BellCross.tsx index f7a3d9ba1e1..861ddffe478 100644 --- a/packages/js/src/ui/icons/BellCross.tsx +++ b/packages/js/src/ui/icons/BellCross.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export function BellCross(props?: JSX.HTMLAttributes) { +export function BellCross(props?: JSX.SvgSVGAttributes) { return ( ) { +export function BellPlus(props?: JSX.SvgSVGAttributes) { return ( ) => { +export const CalendarSchedule = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Chat = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Check = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const CheckCircleFill = (props?: JSX.SvgSVGAttributes) => { return ( diff --git a/packages/js/src/ui/icons/Clock.tsx b/packages/js/src/ui/icons/Clock.tsx index 851ac00bd1e..208746ef5cf 100644 --- a/packages/js/src/ui/icons/Clock.tsx +++ b/packages/js/src/ui/icons/Clock.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const Clock = (props?: JSX.HTMLAttributes) => { +export const Clock = (props?: JSX.SvgSVGAttributes) => { return ( diff --git a/packages/js/src/ui/icons/Cogs.tsx b/packages/js/src/ui/icons/Cogs.tsx index c2ee3721766..663d6d12a5b 100644 --- a/packages/js/src/ui/icons/Cogs.tsx +++ b/packages/js/src/ui/icons/Cogs.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const Cogs = (props?: JSX.HTMLAttributes) => { +export const Cogs = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Copy = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Dots = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Email = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const InApp = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Info = (props?: JSX.SvgSVGAttributes) => { return ( ) { +export function Key(props?: JSX.SvgSVGAttributes) { return ( ) { +export function Loader(props?: JSX.SvgSVGAttributes) { return ( diff --git a/packages/js/src/ui/icons/MarkAsArchived.tsx b/packages/js/src/ui/icons/MarkAsArchived.tsx index 27a18820aa1..865413a7e32 100644 --- a/packages/js/src/ui/icons/MarkAsArchived.tsx +++ b/packages/js/src/ui/icons/MarkAsArchived.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const MarkAsArchived = (props?: JSX.HTMLAttributes) => { +export const MarkAsArchived = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const MarkAsArchivedRead = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const MarkAsRead = (props?: JSX.SvgSVGAttributes) => { return ( diff --git a/packages/js/src/ui/icons/MarkAsUnarchived.tsx b/packages/js/src/ui/icons/MarkAsUnarchived.tsx index d51688a786e..1322c4cef54 100644 --- a/packages/js/src/ui/icons/MarkAsUnarchived.tsx +++ b/packages/js/src/ui/icons/MarkAsUnarchived.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const MarkAsUnarchived = (props?: JSX.HTMLAttributes) => { +export const MarkAsUnarchived = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const MarkAsUnread = (props?: JSX.SvgSVGAttributes) => { return ( ) => { + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/packages/js/src/ui/icons/NodeTree.tsx b/packages/js/src/ui/icons/NodeTree.tsx index 6219885b66b..b39df505a79 100644 --- a/packages/js/src/ui/icons/NodeTree.tsx +++ b/packages/js/src/ui/icons/NodeTree.tsx @@ -1,6 +1,6 @@ import { JSX } from 'solid-js'; -export const NodeTree = (props?: JSX.HTMLAttributes) => { +export const NodeTree = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Novu = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Push = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const RouteFill = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const SlackColored = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Sms = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Unread = (props?: JSX.SvgSVGAttributes) => { return ( ) => { +export const Unsnooze = (props?: JSX.SvgSVGAttributes) => { return ( = SlackLinkUserAppearanceCallback[K]; +// MS TEAMS LINK USER APPEARANCE +export type MsTeamsLinkUserAppearanceCallback = { + linkMsTeamsUserContainer: (context: { linked: boolean }) => string; + linkMsTeamsUserButton: (context: { linked: boolean }) => string; + linkMsTeamsUserButtonContainer: (context: { linked: boolean }) => string; + linkMsTeamsUserButtonIcon: (context: { linked: boolean }) => string; + linkMsTeamsUserButtonLabel: (context: { linked: boolean }) => string; +}; +export type MsTeamsLinkUserAppearanceCallbackKeys = keyof MsTeamsLinkUserAppearanceCallback; +export type MsTeamsLinkUserAppearanceCallbackFunction = + MsTeamsLinkUserAppearanceCallback[K]; + // CHANNEL CONNECT BUTTON APPEARANCE export type ChannelConnectButtonAppearanceCallback = { channelConnectButtonContainer: (context: { connected: boolean }) => string; @@ -376,6 +390,7 @@ export type AllAppearanceCallbackKeys = | InboxAppearanceCallbackKeys | SubscriptionAppearanceCallbackKeys | SlackLinkUserAppearanceCallbackKeys + | MsTeamsLinkUserAppearanceCallbackKeys | ChannelConnectButtonAppearanceCallbackKeys; export type AllAppearanceCallbackFunction = K extends InboxAppearanceCallbackKeys ? InboxAppearanceCallbackFunction @@ -383,15 +398,18 @@ export type AllAppearanceCallbackFunction = ? SubscriptionAppearanceCallbackFunction : K extends SlackLinkUserAppearanceCallbackKeys ? SlackLinkUserAppearanceCallbackFunction - : K extends ChannelConnectButtonAppearanceCallbackKeys - ? ChannelConnectButtonAppearanceCallbackFunction - : never; + : K extends MsTeamsLinkUserAppearanceCallbackKeys + ? MsTeamsLinkUserAppearanceCallbackFunction + : K extends ChannelConnectButtonAppearanceCallbackKeys + ? ChannelConnectButtonAppearanceCallbackFunction + : never; export type AllAppearanceKey = | CommonAppearanceKey | InboxAppearanceKey | SubscriptionAppearanceKey | ConnectChatAppearanceKey | SlackLinkUserAppearanceKey + | MsTeamsLinkUserAppearanceKey | ChannelConnectButtonAppearanceKey; export type AllElements = Partial< { diff --git a/packages/nextjs/src/app-router/index.ts b/packages/nextjs/src/app-router/index.ts index 33869f7edcf..06b16323fa7 100644 --- a/packages/nextjs/src/app-router/index.ts +++ b/packages/nextjs/src/app-router/index.ts @@ -5,6 +5,8 @@ export type * from '@novu/react'; export { Bell, InboxContent, + MsTeamsConnectButton, + MsTeamsLinkUser, Notifications, NovuProvider, PreferenceLevel, diff --git a/packages/nextjs/src/pages-router/index.ts b/packages/nextjs/src/pages-router/index.ts index 33869f7edcf..06b16323fa7 100644 --- a/packages/nextjs/src/pages-router/index.ts +++ b/packages/nextjs/src/pages-router/index.ts @@ -5,6 +5,8 @@ export type * from '@novu/react'; export { Bell, InboxContent, + MsTeamsConnectButton, + MsTeamsLinkUser, Notifications, NovuProvider, PreferenceLevel, diff --git a/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts b/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts index e6cd5cb120d..9d5bdf1e374 100644 --- a/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts +++ b/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts @@ -184,7 +184,12 @@ export class MsTeamsProvider extends BaseProvider implements IChatProvider { const errorMessage = data?.error?.message || data?.message || ''; // Map Bot Framework errors to descriptive messages - if (errorCode === 'BotNotInConversationRoster' || errorMessage.includes('BotNotInConversationRoster')) { + if ( + errorCode === 'BotNotInConversationRoster' || + errorMessage.includes('BotNotInConversationRoster') || + errorMessage.includes('Bot is not installed in user') || + errorMessage.toLowerCase().includes('not installed') + ) { throw new Error('MSTEAMS_BOT_NOT_INSTALLED: Bot is not installed in this team/channel or for this user'); } diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index b62f13cec71..f639cef7654 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -2,6 +2,8 @@ export * from '../hooks/NovuProvider'; export * from './Bell'; export * from './Inbox'; export * from './InboxContent'; +export * from './msteams-connect-button/MsTeamsConnectButton'; +export * from './msteams-link-user/MsTeamsLinkUser'; export * from './Notifications'; export * from './Preferences'; export * from './slack-connect-button/SlackConnectButton'; diff --git a/packages/react/src/components/msteams-connect-button/DefaultMsTeamsConnectButton.tsx b/packages/react/src/components/msteams-connect-button/DefaultMsTeamsConnectButton.tsx new file mode 100644 index 00000000000..255259a95ef --- /dev/null +++ b/packages/react/src/components/msteams-connect-button/DefaultMsTeamsConnectButton.tsx @@ -0,0 +1,82 @@ +import { MsTeamsConnectButtonProps } from '@novu/js/ui'; +import { useCallback } from 'react'; +import { useNovuUI } from '../../context/NovuUIContext'; +import { Mounter } from '../Mounter'; + +export type DefaultMsTeamsConnectButtonProps = Pick< + MsTeamsConnectButtonProps, + | 'integrationIdentifier' + | 'connectionIdentifier' + | 'subscriberId' + | 'context' + | 'scope' + | 'connectionMode' + | 'autoLinkUser' + | 'onConnectSuccess' + | 'onConnectError' + | 'onDisconnectSuccess' + | 'onDisconnectError' + | 'connectLabel' + | 'connectedLabel' +>; + +export const DefaultMsTeamsConnectButton = (props: DefaultMsTeamsConnectButtonProps) => { + const { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + autoLinkUser, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + } = props; + const { novuUI } = useNovuUI(); + + const mount = useCallback( + (element: HTMLElement) => { + return novuUI.mountComponent({ + name: 'MsTeamsConnectButton', + props: { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + autoLinkUser, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + }, + element, + }); + }, + [ + novuUI, + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + autoLinkUser, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + ] + ); + + return ; +}; diff --git a/packages/react/src/components/msteams-connect-button/MsTeamsConnectButton.tsx b/packages/react/src/components/msteams-connect-button/MsTeamsConnectButton.tsx new file mode 100644 index 00000000000..9ac8ae070c1 --- /dev/null +++ b/packages/react/src/components/msteams-connect-button/MsTeamsConnectButton.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { useNovu } from '../../hooks/NovuProvider'; +import { NovuUI, NovuUIOptions } from '../NovuUI'; +import { withRenderer } from '../Renderer'; +import { DefaultMsTeamsConnectButton, DefaultMsTeamsConnectButtonProps } from './DefaultMsTeamsConnectButton'; + +export type MsTeamsConnectButtonProps = DefaultMsTeamsConnectButtonProps & + Pick; + +const MsTeamsConnectButtonInternal = withRenderer((props) => { + const { container, appearance, ...defaultProps } = props; + const novu = useNovu(); + + const options: NovuUIOptions = useMemo(() => { + return { + container, + appearance, + options: novu.options, + }; + }, [container, appearance, novu.options]); + + return ( + + + + ); +}); + +MsTeamsConnectButtonInternal.displayName = 'MsTeamsConnectButtonInternal'; + +export const MsTeamsConnectButton = React.memo((props: MsTeamsConnectButtonProps) => { + return ; +}); + +MsTeamsConnectButton.displayName = 'MsTeamsConnectButton'; diff --git a/packages/react/src/components/msteams-link-user/DefaultMsTeamsLinkUser.tsx b/packages/react/src/components/msteams-link-user/DefaultMsTeamsLinkUser.tsx new file mode 100644 index 00000000000..071216447fe --- /dev/null +++ b/packages/react/src/components/msteams-link-user/DefaultMsTeamsLinkUser.tsx @@ -0,0 +1,66 @@ +import { MsTeamsLinkUserProps } from '@novu/js/ui'; +import { useCallback } from 'react'; +import { useNovuUI } from '../../context/NovuUIContext'; +import { Mounter } from '../Mounter'; + +export type DefaultMsTeamsLinkUserProps = Pick< + MsTeamsLinkUserProps, + | 'integrationIdentifier' + | 'connectionIdentifier' + | 'context' + | 'onLinkSuccess' + | 'onLinkError' + | 'onUnlinkSuccess' + | 'onUnlinkError' + | 'linkLabel' + | 'unlinkLabel' +>; + +export const DefaultMsTeamsLinkUser = (props: DefaultMsTeamsLinkUserProps) => { + const { + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + } = props; + const { novuUI } = useNovuUI(); + + const mount = useCallback( + (element: HTMLElement) => { + return novuUI.mountComponent({ + name: 'MsTeamsLinkUser', + props: { + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + }, + element, + }); + }, + [ + novuUI, + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + ] + ); + + return ; +}; diff --git a/packages/react/src/components/msteams-link-user/MsTeamsLinkUser.tsx b/packages/react/src/components/msteams-link-user/MsTeamsLinkUser.tsx new file mode 100644 index 00000000000..615b903fc21 --- /dev/null +++ b/packages/react/src/components/msteams-link-user/MsTeamsLinkUser.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { useNovu } from '../../hooks/NovuProvider'; +import { NovuUI, NovuUIOptions } from '../NovuUI'; +import { withRenderer } from '../Renderer'; +import { DefaultMsTeamsLinkUser, DefaultMsTeamsLinkUserProps } from './DefaultMsTeamsLinkUser'; + +export type MsTeamsLinkUserProps = DefaultMsTeamsLinkUserProps & Pick; + +const MsTeamsLinkUserInternal = withRenderer((props) => { + const { container, appearance, ...defaultProps } = props; + const novu = useNovu(); + + const options: NovuUIOptions = useMemo(() => { + return { + container, + appearance, + options: novu.options, + }; + }, [container, appearance, novu.options]); + + return ( + + + + ); +}); + +MsTeamsLinkUserInternal.displayName = 'MsTeamsLinkUserInternal'; + +export const MsTeamsLinkUser = React.memo((props: MsTeamsLinkUserProps) => { + return ; +}); + +MsTeamsLinkUser.displayName = 'MsTeamsLinkUser'; diff --git a/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx b/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx index 04aed1405eb..453441b127a 100644 --- a/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx +++ b/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx @@ -11,6 +11,7 @@ export type DefaultSlackConnectButtonProps = Pick< | 'context' | 'scope' | 'connectionMode' + | 'autoLinkUser' | 'onConnectSuccess' | 'onConnectError' | 'onDisconnectSuccess' @@ -27,6 +28,7 @@ export const DefaultSlackConnectButton = (props: DefaultSlackConnectButtonProps) context, scope, connectionMode, + autoLinkUser, onConnectSuccess, onConnectError, onDisconnectSuccess, @@ -47,6 +49,7 @@ export const DefaultSlackConnectButton = (props: DefaultSlackConnectButtonProps) context, scope, connectionMode, + autoLinkUser, onConnectSuccess, onConnectError, onDisconnectSuccess, @@ -65,6 +68,7 @@ export const DefaultSlackConnectButton = (props: DefaultSlackConnectButtonProps) context, scope, connectionMode, + autoLinkUser, onConnectSuccess, onConnectError, onDisconnectSuccess, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b992fe0c565..533b556add3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -37,6 +37,8 @@ export type { BellProps, InboxContentProps, InboxProps, + MsTeamsConnectButtonProps, + MsTeamsLinkUserProps, NotificationProps, NovuProviderProps, SlackConnectButtonProps, @@ -49,6 +51,8 @@ export { Bell, Inbox, InboxContent, + MsTeamsConnectButton, + MsTeamsLinkUser, Notifications, NovuProvider, Preferences, diff --git a/playground/nextjs/.env.example b/playground/nextjs/.env.example index 2ec1b346116..eadd8ca062e 100644 --- a/playground/nextjs/.env.example +++ b/playground/nextjs/.env.example @@ -16,6 +16,16 @@ SLACK_BOT_USER_OAUTH_TOKEN= # Workflow ID to trigger when clicking "Send Test Message" on the connect-chat playground page NEXT_PUBLIC_NOVU_SLACK_TEST_WORKFLOW_ID= +# MS Teams Connect demo +NEXT_PUBLIC_NOVU_MSTEAMS_INTEGRATION_IDENTIFIER= +# Fallback AAD Object ID used by the server-side /api/msteams-dm-endpoint route +# when no aadObjectIdOverride is passed in the request body. +# Find this in Microsoft Entra admin center or use delegated OAuth instead. +NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID= +NOVU_MSTEAMS_INTEGRATION_IDENTIFIER= +# Workflow ID to trigger when clicking "Send Test Message" on the connect-msteams playground page +NEXT_PUBLIC_NOVU_MSTEAMS_TEST_WORKFLOW_ID= + # SMTP settings for scripts/send-email.mjs # Defaults work with a local Mailpit instance: # docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit diff --git a/playground/nextjs/src/components/SideNav.tsx b/playground/nextjs/src/components/SideNav.tsx index 06458fc3656..d9479aca13c 100644 --- a/playground/nextjs/src/components/SideNav.tsx +++ b/playground/nextjs/src/components/SideNav.tsx @@ -15,7 +15,8 @@ const LINKS: LinkType[] = [ { href: '/render-notification', label: 'Render Notification', category: 'Components' }, { href: '/notifications', label: 'Notifications', category: 'Components' }, { href: '/preferences', label: 'Preferences', category: 'Components' }, - { href: '/connect-chat', label: 'Connect Chat', category: 'Components' }, + { href: '/connect-chat', label: 'Connect Chat (Slack)', category: 'Components' }, + { href: '/connect-msteams', label: 'Connect MS Teams', category: 'Components' }, { href: '/subscription', label: 'Subscription', category: 'Components' }, { href: '/subscription-components', label: 'Subscription Components', category: 'Components' }, { href: '/subscription-hooks', label: 'Subscription Hooks', category: 'Components' }, diff --git a/playground/nextjs/src/lib/msteams-dm-endpoint-connect.ts b/playground/nextjs/src/lib/msteams-dm-endpoint-connect.ts new file mode 100644 index 00000000000..324000ba7cd --- /dev/null +++ b/playground/nextjs/src/lib/msteams-dm-endpoint-connect.ts @@ -0,0 +1,129 @@ +/** + * Server-side helper for registering an MS Teams DM channel endpoint in Novu. + * + * WHY THIS MUST BE SERVER-SIDE + * Creating a ChannelEndpoint requires the Novu secret key (sk_...) which must + * never be exposed to the browser. Use this from a Next.js API route or any + * server-side handler. + * + * FULL FLOW + * 1. IT admin completes the MS Teams admin consent flow in the Novu Dashboard → + * stores a ChannelConnection for the tenant. + * 2. Individual users link their identity via (delegated OAuth) + * OR your backend uses `ensureMsTeamsUserDmEndpoint` with a known AAD Object ID. + * 3. Novu uses the registered ms_teams_user ChannelEndpoint to send direct messages + * via the MS Teams Bot Framework. + * + * REQUIRED ENV VARS + * NOVU_SECRET_KEY Novu API secret key (sk_...) + * NOVU_API_BASE_URL Novu API base URL (optional; falls back to NEXT_PUBLIC_NOVU_BACKEND_URL, then https://api.novu.co) + * NOVU_MSTEAMS_INTEGRATION_IDENTIFIER Novu integration identifier for the MS Teams integration + * NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID Fallback AAD Object ID for testing (public, optional) + */ + +import { Novu } from '@novu/api'; + +const MS_TEAMS_USER_TYPE = 'ms_teams_user' as const; + +export type EnsureMsTeamsUserDmEndpointResult = { ok: true; aadObjectId: string } | { ok: false; error: string }; + +function getNovuClient(): Novu { + const secretKey = process.env.NOVU_SECRET_KEY?.trim(); + + if (!secretKey) { + throw new Error('NOVU_SECRET_KEY is required'); + } + + const serverURL = ( + process.env.NOVU_API_BASE_URL ?? + process.env.NEXT_PUBLIC_NOVU_BACKEND_URL ?? + 'https://api.novu.co' + ).replace(/\/v1$/, ''); + + return new Novu({ security: { secretKey }, serverURL }); +} + +/** + * Register an MS Teams DM ChannelEndpoint for a subscriber using their AAD Object ID. + * + * Idempotent: if an endpoint with this AAD Object ID already exists, returns immediately. + * + * The AAD Object ID (`oid`) is available from: + * - The delegated OAuth flow (automatic) + * - Your own Microsoft Entra / Azure AD directory + * - The Microsoft Graph API (`GET /v1.0/users/{email}` → `.id`) + * + * @param subscriberId The Novu subscriber ID + * @param integrationIdentifier The Novu MS Teams integration identifier + * @param aadObjectIdOverride The subscriber's AAD Object ID (falls back to NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID) + */ +export async function ensureMsTeamsUserDmEndpoint(args: { + subscriberId: string; + integrationIdentifier: string; + aadObjectIdOverride?: string; +}): Promise { + const novu = getNovuClient(); + const { subscriberId, integrationIdentifier } = args; + + const aadObjectId = args.aadObjectIdOverride?.trim() || process.env.NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID?.trim() || ''; + + if (!aadObjectId) { + return { + ok: false, + error: + 'AAD Object ID is required. Pass aadObjectIdOverride or set NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID.' + + ' You can find the AAD Object ID in Microsoft Entra admin center or via OAuth.', + }; + } + + const endpoints = await novu.channelEndpoints.list({ + subscriberId, + integrationIdentifier, + limit: 100, + }); + + const alreadyLinked = endpoints.result.data.some((ep) => { + const endpointData = ep.endpoint as Record; + + return ep.type === MS_TEAMS_USER_TYPE && endpointData.userId === aadObjectId; + }); + + if (alreadyLinked) { + return { ok: true, aadObjectId }; + } + + const connections = await novu.channelConnections.list({ + subscriberId, + integrationIdentifier, + limit: 100, + }); + + const connectionIdentifier = connections.result.data.find( + (c) => c.identifier && c.providerId === 'msteams' + )?.identifier; + + if (!connectionIdentifier) { + return { + ok: false, + error: + 'No MS Teams channel connection found for this subscriber. ' + + 'An IT admin must complete the admin consent flow in the Novu Dashboard first.', + }; + } + + try { + await novu.channelEndpoints.create({ + subscriberId, + integrationIdentifier, + connectionIdentifier, + type: MS_TEAMS_USER_TYPE, + endpoint: { userId: aadObjectId }, + }); + + return { ok: true, aadObjectId }; + } catch (e) { + const message = e instanceof Error ? e.message : 'channelEndpoints.create failed'; + + return { ok: false, error: message }; + } +} diff --git a/playground/nextjs/src/pages/api/msteams-dm-endpoint.ts b/playground/nextjs/src/pages/api/msteams-dm-endpoint.ts new file mode 100644 index 00000000000..ca722b8609d --- /dev/null +++ b/playground/nextjs/src/pages/api/msteams-dm-endpoint.ts @@ -0,0 +1,79 @@ +/** + * POST /api/msteams-dm-endpoint + * + * Server-side companion for the SDK component. + * + * Use this route when you already know the subscriber's AAD Object ID (e.g. from + * your own Microsoft Entra / Azure AD directory) and want to register it directly, + * bypassing the delegated OAuth flow that provides. + * + * Required ENV vars: + * NOVU_SECRET_KEY Novu API secret (sk_...) + * NOVU_API_BASE_URL Optional Novu API base URL + * NOVU_MSTEAMS_INTEGRATION_IDENTIFIER Novu MS Teams integration identifier + * + * Optional ENV vars: + * NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID Fallback AAD Object ID used when none is + * provided in the request body + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { ensureMsTeamsUserDmEndpoint } from '@/lib/msteams-dm-endpoint-connect'; + +type RequestBody = { + subscriberId?: string; + integrationIdentifier?: string; + aadObjectIdOverride?: string; +}; + +type ResponseData = { aadObjectId: string } | { error: string }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).json({ error: 'Method not allowed' }); + + return; + } + + try { + const body = req.body as RequestBody; + const subscriberId = typeof body.subscriberId === 'string' ? body.subscriberId.trim() : ''; + + if (!subscriberId) { + res.status(400).json({ error: 'subscriberId is required' }); + + return; + } + + const integrationIdentifier = + (typeof body.integrationIdentifier === 'string' && body.integrationIdentifier.trim()) || + process.env.NOVU_MSTEAMS_INTEGRATION_IDENTIFIER; + + if (!integrationIdentifier) { + res + .status(400) + .json({ error: 'integrationIdentifier is required (body or NOVU_MSTEAMS_INTEGRATION_IDENTIFIER)' }); + + return; + } + + const result = await ensureMsTeamsUserDmEndpoint({ + subscriberId, + integrationIdentifier, + aadObjectIdOverride: typeof body.aadObjectIdOverride === 'string' ? body.aadObjectIdOverride : undefined, + }); + + if (!result.ok) { + res.status(422).json({ error: result.error }); + + return; + } + + res.status(200).json({ aadObjectId: result.aadObjectId }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + + res.status(500).json({ error: message }); + } +} diff --git a/playground/nextjs/src/pages/connect-chat/index.tsx b/playground/nextjs/src/pages/connect-chat/index.tsx index f764b467e86..966a73fdfd5 100644 --- a/playground/nextjs/src/pages/connect-chat/index.tsx +++ b/playground/nextjs/src/pages/connect-chat/index.tsx @@ -107,11 +107,12 @@ export default function ConnectChatPage() { }} // connectionIdentifier={CONNECTION_IDENTIFIER} // connectionStrategy: 'subscriber' | 'shared' DEFAULT 'subscriber' - connectionMode="shared" + // connectionMode="shared" // in NovuProvider // subscriberId: string // redundant // ...(context && { context: context }), onConnectError={(error) => console.error(error)} + autoLinkUser={false} /> diff --git a/playground/nextjs/src/pages/connect-msteams/index.tsx b/playground/nextjs/src/pages/connect-msteams/index.tsx new file mode 100644 index 00000000000..839b448dcdf --- /dev/null +++ b/playground/nextjs/src/pages/connect-msteams/index.tsx @@ -0,0 +1,263 @@ +import { MsTeamsConnectButton, MsTeamsLinkUser, NovuProvider } from '@novu/nextjs'; +import { useState } from 'react'; +import Title from '@/components/Title'; +import { novuConfig } from '@/utils/config'; + +const INTEGRATION_IDENTIFIER = process.env.NEXT_PUBLIC_NOVU_MSTEAMS_INTEGRATION_IDENTIFIER ?? 'msteams'; +// const CONNECTION_IDENTIFIER = 'msteams-workspace-connection'; +const MS_TEAMS_TEST_WORKFLOW_ID = process.env.NEXT_PUBLIC_NOVU_MSTEAMS_TEST_WORKFLOW_ID ?? ''; +// const context = { key: 'value2' }; +const context = undefined; + +export default function ConnectMsTeamsPage() { + const [aadOidOverride, setAadOidOverride] = useState(''); + const [dmStatus, setDmStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [dmLoading, setDmLoading] = useState(false); + const [triggerWorkflowId, setTriggerWorkflowId] = useState(MS_TEAMS_TEST_WORKFLOW_ID); + const [triggerStatus, setTriggerStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [triggerLoading, setTriggerLoading] = useState(false); + + const handleCreateDmEndpoint = async () => { + setDmLoading(true); + setDmStatus(null); + + try { + const res = await fetch('/api/msteams-dm-endpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriberId: novuConfig.subscriberId, + integrationIdentifier: INTEGRATION_IDENTIFIER, + ...(aadOidOverride.trim() && { aadObjectIdOverride: aadOidOverride.trim() }), + }), + }); + + const data = (await res.json()) as { aadObjectId?: string; error?: string }; + + if (!res.ok || data.error) { + setDmStatus({ type: 'error', message: data.error ?? 'Unknown error' }); + } else { + setDmStatus({ + type: 'success', + message: `MS_TEAMS_USER endpoint created for AAD Object ID: ${data.aadObjectId}`, + }); + } + } catch (err) { + setDmStatus({ type: 'error', message: err instanceof Error ? err.message : 'Request failed' }); + } finally { + setDmLoading(false); + } + }; + + const handleSendTestMessage = async () => { + if (!triggerWorkflowId.trim()) { + setTriggerStatus({ type: 'error', message: 'Workflow ID is required' }); + + return; + } + + setTriggerLoading(true); + setTriggerStatus(null); + + try { + const res = await fetch('/api/trigger-event', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: triggerWorkflowId.trim(), + to: { subscriberId: novuConfig.subscriberId }, + payload: { message: 'Test message from connect-msteams playground' }, + ...(context ? { context } : {}), + }), + }); + + const data = (await res.json()) as { data?: { transactionId?: string }; error?: string; message?: string }; + + if (!res.ok) { + setTriggerStatus({ type: 'error', message: data.message ?? data.error ?? `HTTP ${res.status}` }); + } else { + const txId = data.data?.transactionId ?? '—'; + + setTriggerStatus({ type: 'success', message: `Triggered ✓ transactionId: ${txId}` }); + } + } catch (err) { + setTriggerStatus({ type: 'error', message: err instanceof Error ? err.message : 'Request failed' }); + } finally { + setTriggerLoading(false); + } + }; + + return ( + <> + +
+
+

+ Step 1 — MsTeamsConnectButton: OAuth admin consent with endpoint configuration +

+

+ Starts the MS Teams admin consent flow (/adminconsent). OAuth stores a{' '} + ChannelConnection for the tenant automatically — the Step 2 Link User flow is optional. +

+ + (connected ? 'nt-hidden' : ''), + }, + }} + // connectionIdentifier={CONNECTION_IDENTIFIER} + // connectionMode="shared" + onConnectError={(error) => console.error(error)} + autoLinkUser={false} + /> + + + + ( + + + + + ), + channelConnected: ({ class: cls }) => ( + + + + + ), + }, + }} + /> + +
+ +
+

Step 2 — MsTeamsLinkUser: Link subscriber via delegated OAuth

+

+ Starts a Microsoft delegated OAuth flow (User.Read scope) to resolve the subscriber's AAD + Object ID and create a ChannelEndpoint of type ms_teams_user. Requires admin + consent from Step 1. +

+ + (linked ? '' : 'nt-hidden'), + }, + }} + // connectionIdentifier={CONNECTION_IDENTIFIER} + /> + + + + ( + + + + + ), + channelConnected: ({ class: cls }) => ( + + + + + ), + }, + }} + /> + +
+ +
+

Step 3 — Server-side DM Endpoint: Register AAD Object ID directly

+

+ Calls /api/msteams-dm-endpoint to create an ms_teams_user{' '} + ChannelEndpoint using a known AAD Object ID. Use this when you already have the user's AAD + Object ID from your own directory (e.g. Microsoft Entra / Azure AD), bypassing the delegated OAuth flow. +

+ setAadOidOverride(e.target.value)} + placeholder="AAD Object ID (optional — uses NEXT_PUBLIC_MS_TEAMS_AAD_OBJECT_ID if unset)" + className="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + + {dmStatus && ( +

+ {dmStatus.message} +

+ )} +
+ +
+

+ Step 4 — Send Test Message: Trigger a workflow via /v1/events/trigger +

+

+ Calls the Novu trigger engine to dispatch a workflow to the current subscriber. Use this to verify the full + e2e path: admin consent → user linking → message delivery via the MS Teams bot. +

+
+ setTriggerWorkflowId(e.target.value)} + placeholder="workflow-id (e.g. msteams-dm-test)" + className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+

+ Subscriber: {novuConfig.subscriberId} +

+ {triggerStatus && ( +

+ {triggerStatus.message} +

+ )} +
+
+ + ); +}