diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.conferencing-apps.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.conferencing-apps.controller.ts index 58b827d35203c0..6be741ffb53800 100644 --- a/apps/api/v2/src/modules/atoms/controllers/atoms.conferencing-apps.controller.ts +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.conferencing-apps.controller.ts @@ -31,6 +31,22 @@ These endpoints should not be recommended for use by third party and are exclude export class AtomsConferencingAppsController { constructor(private readonly conferencingService: ConferencingAtomsService) {} + @Get("/organizations/:orgId/conferencing") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Version(VERSION_NEUTRAL) + async listOrgInstalledConferencingApps( + @GetUser() user: UserWithProfile, + @Param("orgId", ParseIntPipe) orgId: number + ): Promise> { + const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, orgId); + return { + status: SUCCESS_STATUS, + data: conferencingApps, + }; + } + @Get("/organizations/:orgId/teams/:teamId/conferencing") @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") diff --git a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts index 1a79e351db08be..6a87c7751cc58c 100644 --- a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts @@ -18,6 +18,7 @@ export class ConferencingAtomsService { input: { variant: "conferencing", onlyInstalled: true, + includeTeamInstalledApps: true, }, prisma: this.dbWrite.prisma as unknown as PrismaClient, }); diff --git a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts index 0b3ed6b1c0bbcf..c06bc8e58ea8cf 100644 --- a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -17,25 +17,25 @@ import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; import { UserWithProfile } from "@/modules/users/users.repository"; import { HttpService } from "@nestjs/axios"; -import { Logger } from "@nestjs/common"; +import { Logger, ParseIntPipe } from "@nestjs/common"; import { Controller, + Delete, Get, - Query, HttpCode, HttpStatus, - UseGuards, - Post, Param, + Post, + Query, + Req, + UseGuards, + HttpException, BadRequestException, - Delete, Headers, Redirect, - Req, - HttpException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger"; +import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; import { Request } from "express"; @@ -80,7 +80,10 @@ export class ConferencingController { @Param("app") app: string ): Promise { const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id); - return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) }; + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOutputDto, credential, { strategy: "excludeAll" }), + }; } @Get("/:app/oauth/auth-url") @@ -169,6 +172,21 @@ export class ConferencingController { const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; return { url: fallbackUrl }; } + } else if (decodedCallbackState.orgId) { + const apiUrl = this.config.get("api.url"); + const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/conferencing/${app}/oauth/callback`; + const params: Record = { state, code, error, error_description }; + const headers = { + Authorization: `Bearer ${decodedCallbackState.accessToken}`, + }; + try { + const response = await this.httpService.axiosRef.get(url, { params, headers }); + const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || ""; + return { url: redirectUrl }; + } catch (err) { + const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; + return { url: fallbackUrl }; + } } return this.conferencingService.connectOauthApps(app, code, decodedCallbackState); @@ -190,10 +208,12 @@ export class ConferencingController { async listInstalledConferencingApps( @GetUser() user: UserWithProfile ): Promise { - const conferencingApps = await this.conferencingService.getConferencingApps(user.id); + const conferencingApps = await this.conferencingService.getConferencingApps(user); return { status: SUCCESS_STATUS, - data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)), + data: conferencingApps.map((app) => + plainToInstance(ConferencingAppsOutputDto, app, { strategy: "excludeAll" }) + ), }; } @@ -216,6 +236,32 @@ export class ConferencingController { return { status: SUCCESS_STATUS }; } + @Post("/:app/default/:credentialId") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ summary: "Set your default conferencing application with specific credential ID" }) + @ApiParam({ + name: "app", + description: "Conferencing application type", + enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO], + required: true, + }) + @ApiParam({ + name: "credentialId", + description: "Specific credential ID to use for the conferencing app", + type: Number, + required: true, + }) + async defaultWithCredential( + @GetUser() user: UserWithProfile, + @Param("app") app: string, + @Param("credentialId", ParseIntPipe) credentialId: number + ): Promise { + await this.conferencingService.setDefaultConferencingApp(user, app, credentialId); + return { status: SUCCESS_STATUS }; + } + @Get("/default") @HttpCode(HttpStatus.OK) @UseGuards(ApiAuthGuard) diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts index 9fa38161ab07f4..15c08404fcaf86 100644 --- a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts +++ b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts @@ -10,6 +10,12 @@ export class ConferencingAppsOutputDto { @ApiProperty({ description: "Id of the conferencing app credentials" }) id!: number; + @ApiProperty({ example: "zoom", description: "App identifier" }) + @Expose() + @IsString() + @IsOptional() + appId?: string; + @ApiProperty({ example: GOOGLE_MEET_TYPE, description: "Type of conferencing app" }) @Expose() @IsString() @@ -18,7 +24,17 @@ export class ConferencingAppsOutputDto { @ApiProperty({ description: "Id of the user associated to the conferencing app" }) @Expose() @IsNumber() - userId!: number; + @IsOptional() + userId!: number | null; + + @ApiPropertyOptional({ + description: "Team ID if the credential belongs to a team", + nullable: true, + }) + @Expose() + @IsNumber() + @IsOptional() + teamId?: number | null; @ApiPropertyOptional({ example: true, @@ -29,6 +45,15 @@ export class ConferencingAppsOutputDto { @IsBoolean() @IsOptional() invalid?: boolean | null; + + @ApiPropertyOptional({ + description: "Delegation credential ID", + nullable: true, + }) + @IsNumber() + @Expose() + @IsOptional() + delegationCredentialId?: number | null; } export class ConferencingAppsOutputResponseDto { diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts index 4ed25b2099610f..7eace40fbe8a62 100644 --- a/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts +++ b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; +import { IsEnum, IsOptional, IsString, IsNumber, ValidateNested } from "class-validator"; import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; @@ -8,12 +8,20 @@ export class DefaultConferencingAppsOutputDto { @IsString() @IsOptional() @Expose() + @ApiProperty() readonly appSlug?: string; @IsString() @IsOptional() @Expose() + @ApiProperty() readonly appLink?: string; + + @IsNumber() + @IsOptional() + @Expose() + @ApiProperty() + readonly credentialId?: number; } export class GetDefaultConferencingAppOutputResponseDto { @@ -25,5 +33,6 @@ export class GetDefaultConferencingAppOutputResponseDto { @ValidateNested() @IsOptional() @Type(() => DefaultConferencingAppsOutputDto) + @ApiPropertyOptional({ type: DefaultConferencingAppsOutputDto }) data?: DefaultConferencingAppsOutputDto; } diff --git a/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts b/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts index e66e1de8d06d1a..081b1e0ea043e0 100644 --- a/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts +++ b/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts @@ -33,9 +33,25 @@ export class ConferencingRepository { } async findTeamConferencingApps(teamId: number) { + const parentTeam = await this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + parentId: true, + }, + }); + + const teamIds = [teamId]; + if (parentTeam?.parentId) { + teamIds.push(parentTeam.parentId); + } + return this.dbRead.prisma.credential.findMany({ where: { - teamId, + teamId: { + in: teamIds, + }, type: { endsWith: "_video" }, }, select: this.credentialSelect, @@ -62,4 +78,11 @@ export class ConferencingRepository { select: this.credentialSelect, }); } + + async findMultipleTeamsConferencingApp(teamIds: number[], app: string) { + return this.dbRead.prisma.credential.findMany({ + where: { teamId: { in: teamIds }, appId: app }, + select: this.credentialSelect, + }); + } } diff --git a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts index db2058f66ad407..509269fed9c42b 100644 --- a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts @@ -22,7 +22,7 @@ import { OFFICE_365_VIDEO, } from "@calcom/platform-constants"; import { userMetadata } from "@calcom/platform-libraries"; -import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; +import { getUsersAndTeamsCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store"; @Injectable() @@ -30,7 +30,6 @@ export class ConferencingService { private logger = new Logger("ConferencingService"); constructor( - private readonly conferencingRepository: ConferencingRepository, private readonly usersRepository: UsersRepository, private readonly tokensRepository: TokensRepository, private readonly googleMeetService: GoogleMeetService, @@ -38,8 +37,14 @@ export class ConferencingService { private readonly office365VideoService: Office365VideoService ) {} - async getConferencingApps(userId: number) { - return this.conferencingRepository.findConferencingApps(userId); + async getConferencingApps(user: UserWithProfile) { + const credentials = await getUsersAndTeamsCredentialsIncludeServiceAccountKey(user); + + // Remove sensitive key field from each credential + return credentials.map((credential) => { + const { key: _key, ...credentialWithoutKey } = credential; + return credentialWithoutKey; + }); } async connectUserNonOauthApp(app: string, userId: number) { @@ -87,14 +92,20 @@ export class ConferencingService { return userMetadata.parse(user?.metadata)?.defaultConferencingApp; } - async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) { + async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string, credentialId?: number) { if (!CONFERENCING_APPS.includes(appSlug)) { throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", ")); } - const credentials = await getUsersCredentialsIncludeServiceAccountKey(user); + const credentials = await getUsersAndTeamsCredentialsIncludeServiceAccountKey(user); - const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0]; + if (credentialId) { + const specificCredential = credentials.find((cred) => cred.id === credentialId); + if (!specificCredential) { + throw new BadRequestException(`Credential with ID ${credentialId} not found for app ${appSlug}.`); + } + } + const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0]; const appLocation = foundApp?.appData?.location; if (!foundApp || !appLocation) { @@ -112,12 +123,17 @@ export class ConferencingService { }); } - async setDefaultConferencingApp(user: UserWithProfile, app: string) { + async setDefaultConferencingApp(user: UserWithProfile, app: string, credentialId?: number) { // cal-video is global, so we can skip this check if (app !== CAL_VIDEO) { - await this.checkAppIsValidAndConnected(user, app); + await this.checkAppIsValidAndConnected(user, app, credentialId); } - const updatedUser = await this.usersRepository.setDefaultConferencingApp(user.id, app); + const updatedUser = await this.usersRepository.setDefaultConferencingApp( + user.id, + app, + undefined, + credentialId + ); const metadata = updatedUser.metadata as { defaultConferencingApp?: { appSlug?: string } }; if (metadata?.defaultConferencingApp?.appSlug !== app) { throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`); diff --git a/apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-conferencing.controller.ts b/apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-conferencing.controller.ts new file mode 100644 index 00000000000000..1c3e615e3cb69d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-conferencing.controller.ts @@ -0,0 +1,227 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { + ConferencingAppsOauthUrlOutputDto, + GetConferencingAppsOauthUrlResponseDto, +} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url"; +import { + ConferencingAppsOutputResponseDto, + ConferencingAppsOutputDto, + DisconnectConferencingAppOutputResponseDto, +} from "@/modules/conferencing/outputs/get-conferencing-apps.output"; +import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output"; +import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output"; +import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; +import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Get, + Query, + HttpCode, + HttpStatus, + UseGuards, + Post, + Param, + Delete, + Headers, + Req, + ParseIntPipe, + BadRequestException, + Redirect, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; +import { Request } from "express"; + +import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants"; + +export type OAuthCallbackState = { + accessToken: string; + teamId?: string; + orgId?: string; + fromApp?: boolean; + returnTo?: string; + onErrorReturnTo?: string; +}; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Organizations / Conferencing") +export class OrganizationsConferencingController { + constructor( + private readonly conferencingService: ConferencingService, + private readonly organizationsConferencingService: OrganizationsConferencingService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiParam({ + name: "app", + description: "Conferencing application type", + enum: [ZOOM, OFFICE_365_VIDEO], + required: true, + }) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/conferencing/:app/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get OAuth conferencing app's auth url for a organization" }) + async getOrgOAuthUrl( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @Param("orgId") orgId: string, + @Param("app") app: string, + @Query("returnTo") returnTo?: string, + @Query("onErrorReturnTo") onErrorReturnTo?: string + ): Promise { + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state: OAuthCallbackState = { + returnTo: returnTo ?? origin, + onErrorReturnTo: onErrorReturnTo ?? origin, + fromApp: false, + accessToken, + orgId, + }; + + const credential = await this.conferencingService.generateOAuthUrl(app, state); + + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/conferencing") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "List organization conferencing applications" }) + async listOrgConferencingApps( + @Param("orgId", ParseIntPipe) orgId: number + ): Promise { + const conferencingApps = await this.organizationsConferencingService.getConferencingApps({ + teamId: orgId, + }); + + return { + status: SUCCESS_STATUS, + data: conferencingApps.map((app) => + plainToInstance(ConferencingAppsOutputDto, app, { strategy: "excludeAll" }) + ), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Post("/conferencing/:app/default") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Set organization default conferencing application" }) + @ApiParam({ + name: "app", + description: "Conferencing application type", + enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO], + required: true, + }) + async setOrgDefaultApp( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string + ): Promise { + await this.organizationsConferencingService.setDefaultConferencingApp({ + teamId: orgId, + app, + }); + + return { status: SUCCESS_STATUS }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/conferencing/default") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get organization default conferencing application" }) + async getOrgDefaultApp( + @Param("orgId", ParseIntPipe) orgId: number + ): Promise { + const defaultConferencingApp = await this.organizationsConferencingService.getDefaultConferencingApp({ + teamId: orgId, + }); + + return { status: SUCCESS_STATUS, data: defaultConferencingApp }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Delete("/conferencing/:app/disconnect") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Disconnect organization conferencing application" }) + @ApiParam({ + name: "app", + description: "Conferencing application type", + enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO], + required: true, + }) + async disconnectOrgApp( + @GetUser() user: UserWithProfile, + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string + ): Promise { + await this.organizationsConferencingService.disconnectConferencingApp({ + teamId: orgId, + user, + app, + }); + + return { status: SUCCESS_STATUS }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/conferencing/:app/oauth/callback") + @Redirect(undefined, 301) + @ApiOperation({ summary: "Save conferencing app OAuth credentials" }) + async handleOrgOauthCallback( + @Query("state") state: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined, + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string + ): Promise<{ url: string }> { + if (!state) { + throw new BadRequestException("Missing `state` query param"); + } + + const decodedCallbackState: OAuthCallbackState = JSON.parse(state); + try { + return await this.organizationsConferencingService.connectTeamOauthApps({ + decodedCallbackState, + code, + app, + teamId: orgId, + }); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } + } +} diff --git a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts b/apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-teams-conferencing.controller.ts similarity index 89% rename from apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts rename to apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-teams-conferencing.controller.ts index 14aae3d6b125c5..2950477ebb0bdf 100644 --- a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts +++ b/apps/api/v2/src/modules/organizations/conferencing/controllers/organizations-teams-conferencing.controller.ts @@ -14,7 +14,6 @@ import { } from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url"; import { ConferencingAppsOutputResponseDto, - ConferencingAppOutputResponseDto, ConferencingAppsOutputDto, DisconnectConferencingAppOutputResponseDto, } from "@/modules/conferencing/outputs/get-conferencing-apps.output"; @@ -22,7 +21,6 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Controller, @@ -43,7 +41,6 @@ import { import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; import { Request } from "express"; -import { stringify } from "querystring"; import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants"; @@ -61,39 +58,12 @@ export type OAuthCallbackState = { version: API_VERSIONS_VALUES, }) @DocsTags("Orgs / Teams / Conferencing") -export class OrganizationsConferencingController { +export class OrganizationsTeamsConferencingController { constructor( private readonly conferencingService: ConferencingService, - private readonly organizationsConferencingService: OrganizationsConferencingService, - private readonly tokensRepository: TokensRepository + private readonly organizationsConferencingService: OrganizationsConferencingService ) {} - @Roles("TEAM_ADMIN") - @PlatformPlan("ESSENTIALS") - @ApiParam({ - name: "app", - description: "Conferencing application type", - enum: [GOOGLE_MEET], - required: true, - }) - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) - @Post("/teams/:teamId/conferencing/:app/connect") - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Connect your conferencing application to a team" }) - async connectTeamApp( - @GetUser() user: UserWithProfile, - @Param("teamId", ParseIntPipe) teamId: number, - @Param("orgId", ParseIntPipe) orgId: number, - @Param("app") app: string - ): Promise { - const credential = await this.organizationsConferencingService.connectTeamNonOauthApps({ - teamId, - app, - }); - - return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) }; - } - @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") @ApiParam({ @@ -150,29 +120,41 @@ export class OrganizationsConferencingController { return { status: SUCCESS_STATUS, - data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)), + data: conferencingApps.map((app) => + plainToInstance(ConferencingAppsOutputDto, app, { strategy: "excludeAll" }) + ), }; } @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) - @Post("/teams/:teamId/conferencing/:app/default") + @Post("/teams/:teamId/conferencing/:app/default/:credentialId") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Set team default conferencing application" }) + @ApiOperation({ summary: "Set team default conferencing application with specific credential ID" }) @ApiParam({ name: "app", description: "Conferencing application type", enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO], required: true, }) - async setTeamDefaultApp( + @ApiParam({ + name: "credentialId", + description: "Specific credential ID to use for the conferencing app", + type: Number, + required: true, + }) + async setTeamDefaultAppWithCredential( @Param("teamId", ParseIntPipe) teamId: number, - @Param("app") app: string + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string, + @Param("credentialId", ParseIntPipe) credentialId: number ): Promise { await this.organizationsConferencingService.setDefaultConferencingApp({ + orgId, teamId, app, + credentialId, }); return { status: SUCCESS_STATUS }; @@ -181,15 +163,35 @@ export class OrganizationsConferencingController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) - @Get("/teams/:teamId/conferencing/default") + @Post("/teams/:teamId/conferencing/:app/default") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Get team default conferencing application" }) + @ApiOperation({ summary: "Set team default conferencing application" }) @ApiParam({ name: "app", description: "Conferencing application type", enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO], required: true, }) + async setTeamDefaultApp( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string + ): Promise { + await this.organizationsConferencingService.setDefaultConferencingApp({ + orgId, + teamId, + app, + }); + + return { status: SUCCESS_STATUS }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/teams/:teamId/conferencing/default") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get team default conferencing application" }) async getTeamDefaultApp( @GetUser() user: UserWithProfile, @Param("teamId", ParseIntPipe) teamId: number @@ -233,7 +235,7 @@ export class OrganizationsConferencingController { @Get("/teams/:teamId/conferencing/:app/oauth/callback") @Redirect(undefined, 301) @ApiOperation({ summary: "Save conferencing app OAuth credentials" }) - async saveTeamOauthCredentials( + async handleTeamOauthCallback( @Query("state") state: string, @Query("code") code: string, @Query("error") error: string | undefined, diff --git a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts index 0d2b76df476cfb..99f90dd921b4f9 100644 --- a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts +++ b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts @@ -7,7 +7,8 @@ import { Office365VideoService } from "@/modules/conferencing/services/office365 import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; -import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller"; +import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/controllers/organizations-conferencing.controller"; +import { OrganizationsTeamsConferencingController } from "@/modules/organizations/conferencing/controllers/organizations-teams-conferencing.controller"; import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; @@ -41,6 +42,6 @@ import { ConfigModule } from "@nestjs/config"; OrganizationsTeamsRepository, ], exports: [OrganizationsConferencingService], - controllers: [OrganizationsConferencingController], + controllers: [OrganizationsConferencingController, OrganizationsTeamsConferencingController], }) export class OrganizationsConferencingModule {} diff --git a/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts index 80fc4e28932d40..9a164df80a0543 100644 --- a/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts +++ b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts @@ -2,14 +2,11 @@ import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferenc import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output"; import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; -import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; import { Injectable } from "@nestjs/common"; -import { GOOGLE_MEET } from "@calcom/platform-constants"; import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants"; import { teamMetadataSchema } from "@calcom/platform-libraries"; import { handleDeleteCredential } from "@calcom/platform-libraries/app-store"; @@ -21,20 +18,9 @@ export class OrganizationsConferencingService { constructor( private readonly conferencingRepository: ConferencingRepository, private teamsRepository: TeamsRepository, - private usersRepository: UsersRepository, - private readonly googleMeetService: GoogleMeetService, private readonly conferencingService: ConferencingService ) {} - async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise { - switch (app) { - case GOOGLE_MEET: - return this.googleMeetService.connectGoogleMeetToTeam(teamId); - default: - throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET."); - } - } - async connectTeamOauthApps({ decodedCallbackState, app, @@ -74,6 +60,28 @@ export class OrganizationsConferencingService { return credential; } + async validateAndCheckOrgTeamConferencingApp(teamIds: number[], app: string, credentialId?: number) { + if (!CONFERENCING_APPS.includes(app)) { + throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", ")); + } + const credentials = await this.conferencingRepository.findMultipleTeamsConferencingApp(teamIds, app); + + if (!credentials.length) { + throw new BadRequestException(`${app} not connected.`); + } + + // If credentialId is provided, verify it exists and is of the correct type + if (credentialId) { + const specificCredential = credentials.find((cred) => cred.id === credentialId); + if (!specificCredential) { + throw new BadRequestException(`Credential with ID ${credentialId} not found for app ${app}.`); + } + return specificCredential; + } + + return credentials[0]; + } + async disconnectConferencingApp({ teamId, user, @@ -92,12 +100,27 @@ export class OrganizationsConferencingService { }); } - async setDefaultConferencingApp({ teamId, app }: { teamId: number; app: string }) { + async setDefaultConferencingApp({ + orgId, + teamId, + app, + credentialId, + }: { + orgId?: number; + teamId: number; + app: string; + credentialId?: number; + }) { // cal-video is global, so we can skip this check if (app !== CAL_VIDEO) { - await this.checkAppIsValidAndConnected(teamId, app); + await this.validateAndCheckOrgTeamConferencingApp( + orgId ? [teamId, orgId] : [teamId], + app, + credentialId + ); } - const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app); + + const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app, undefined, credentialId); const metadata = team.metadata as { defaultConferencingApp?: { appSlug?: string } }; if (metadata?.defaultConferencingApp?.appSlug !== app) { throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`); diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts index 2cd9ab58d64e92..aa6851f397adf3 100644 --- a/apps/api/v2/src/modules/teams/teams/teams.repository.ts +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -85,7 +85,7 @@ export class TeamsRepository { }); } - async setDefaultConferencingApp(teamId: number, appSlug?: string, appLink?: string) { + async setDefaultConferencingApp(teamId: number, appSlug?: string, appLink?: string, credentialId?: number) { const team = await this.getById(teamId); const teamMetadata = teamMetadataSchema.parse(team?.metadata); @@ -102,6 +102,7 @@ export class TeamsRepository { defaultConferencingApp: { appSlug: appSlug, appLink: appLink, + credentialId, }, } : {}, diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 1604fd38dea54b..7b65fcbdc67c76 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -7,6 +7,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { CreationSource } from "@calcom/platform-libraries"; import type { Profile, User, Team, Prisma } from "@calcom/prisma/client"; +import { userMetadata } from "@calcom/platform-libraries"; + export type UserWithProfile = User & { movedToProfile?: (Profile & { organization: Pick }) | null; profiles?: (Profile & { organization: Pick })[]; @@ -292,22 +294,26 @@ export class UsersRepository { return profiles.map((profile: Profile & { user: User }) => profile.user); } - async setDefaultConferencingApp(userId: number, appSlug?: string, appLink?: string) { + async setDefaultConferencingApp(userId: number, appSlug?: string, appLink?: string, credentialId?: number) { const user = await this.findById(userId); if (!user) { throw new NotFoundException("user not found"); } + const metadata = userMetadata.parse(user?.metadata); + + return await this.dbWrite.prisma.user.update({ data: { metadata: - typeof user.metadata === "object" + typeof metadata === "object" ? { - ...user.metadata, + ...metadata, defaultConferencingApp: { appSlug: appSlug, appLink: appLink, + credentialId: credentialId, }, } : {}, diff --git a/docs/platform/atoms/conferencing-apps.mdx b/docs/platform/atoms/conferencing-apps.mdx index 9dcd4fad8258d4..db910020516717 100644 --- a/docs/platform/atoms/conferencing-apps.mdx +++ b/docs/platform/atoms/conferencing-apps.mdx @@ -4,10 +4,11 @@ title: "Conferencing Apps" The Conferencing Apps Atom allows users to seamlessly install applications such as Zoom, Google Meet, and Microsoft Teams, enabling them to set these as default or optional locations for their events. -Below code snippet can be used to render Conferencing Apps Atom +## User-Level Installation -```js +Below code snippet can be used to render Conferencing Apps Atom for user-level installation: +```js import { ConferencingAppsSettings } from "@calcom/atoms"; import { usePathname } from "next/navigation"; @@ -23,7 +24,81 @@ export default function ConferencingApps() { } ``` -Below is a list of props that can be passed to the Conferencing Apps Atom +## Organization & Team-Level Installation + +You can also install conferencing apps at the organization or team level, providing shared credentials for all members. + +### Benefits + +- **No individual setup required**: Users within a team or organization no longer need to connect their personal accounts to Zoom or other conferencing apps. +- **Shared credentials**: Once installed by an admin, the conferencing app credentials are available for all sub-teams and users within the organization. +- **Reduced redundancy**: Instead of every user configuring the same app, the admin installs it once and all members can use it seamlessly. +- **Flexible usage**: Users can leverage the shared team/org-installed credentials when creating personal events, as well as team events. + +### Team-Level Installation + +Install conferencing apps at the team level by passing both `orgId` (parent organization) and `teamId` (child team) as props: + +```js +import { ConferencingAppsSettings } from "@calcom/atoms"; +import { usePathname } from "next/navigation"; + +export default function TeamConferencingApps() { + const pathname = usePathname(); + const callbackUri = `${window.location.origin}${pathname}`; + const orgId = 123; // Parent organization ID + const teamId = 456; // Child team ID + + return ( + <> + + + ) +} +``` + +**Who can use team-installed apps:** +- All members of this team can use the credentials on their team events (collective, round robin) +- All team members can also use these credentials on their personal events + +### Organization-Level Installation + +Install conferencing apps at the organization level by passing only `orgId` (parent organization) as a prop: + +```js +import { ConferencingAppsSettings } from "@calcom/atoms"; +import { usePathname } from "next/navigation"; + +export default function OrgConferencingApps() { + const pathname = usePathname(); + const callbackUri = `${window.location.origin}${pathname}`; + const orgId = 123; // Parent organization ID + + return ( + <> + + + ) +} +``` + +**Who can use org-installed apps:** +- All members within the organization +- All members of all teams within the organization +- Can be used on personal events and all team events (collective, round robin) for all teams within the organization + +## Props + +Below is a list of props that can be passed to the Conferencing Apps Atom:

@@ -34,6 +109,8 @@ Below is a list of props that can be passed to the Conferencing Apps Atom | disableToasts | No | Boolean value to disable toast notifications in the atom. | | apps | No | Array of conferencing app slugs to display in the dropdown. If provided, only these apps will be shown (if not already installed). Valid values are `'google-meet'`, `'zoom'`, and `'msteams'`. | | disableBulkUpdateEventTypes | No | A Boolean flag that prevents the bulk update of event types modal from appearing when the default conferencing app is changed. Defaults to false | +| teamId | No | Team ID for team-level installation. Must be used together with `orgId`. | +| orgId | No | Organization ID for org-level or team-level installation. | ## Google Meet diff --git a/packages/app-store/_utils/getConnectedApps.ts b/packages/app-store/_utils/getConnectedApps.ts index fba006c2d1634c..4ae837b579420e 100644 --- a/packages/app-store/_utils/getConnectedApps.ts +++ b/packages/app-store/_utils/getConnectedApps.ts @@ -75,6 +75,7 @@ export async function getConnectedApps({ accepted: true, }, }, + ...(teamId ? { id: teamId } : {}), }, select: { id: true, @@ -111,6 +112,7 @@ export async function getConnectedApps({ }, }, }); + // If a team is a part of an org then include those apps // Don't want to iterate over these parent teams const filteredTeams: TeamQuery[] = []; @@ -174,6 +176,7 @@ export async function getConnectedApps({ const credentialOwner: CredentialOwner = { name: user.name, avatar: user?.avatar ?? user?.avatarUrl, + credentialId: credentials.find((c) => c.appId === app.slug && c.userId == user.id)?.id, }; // We need to know if app is payment type diff --git a/packages/app-store/delegationCredential.ts b/packages/app-store/delegationCredential.ts index d6bd5dd302a47b..0de8f53ed57829 100644 --- a/packages/app-store/delegationCredential.ts +++ b/packages/app-store/delegationCredential.ts @@ -7,11 +7,7 @@ import { CredentialRepository } from "@calcom/features/credentials/repositories/ import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import { - buildNonDelegationCredential, - buildNonDelegationCredentials, - isDelegationCredential, -} from "@calcom/lib/delegationCredential"; +import { buildNonDelegationCredential, buildNonDelegationCredentials, isDelegationCredential } from "@calcom/lib/delegationCredential"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; @@ -19,6 +15,7 @@ import type { SelectedCalendar } from "@calcom/prisma/client"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; + const GOOGLE_WORKSPACE_SLUG = "google"; const OFFICE365_WORKSPACE_SLUG = "office365"; const WORKSPACE_PLATFORM_SLUGS = [GOOGLE_WORKSPACE_SLUG, OFFICE365_WORKSPACE_SLUG] as const; @@ -675,6 +672,55 @@ export async function getUsersCredentialsIncludeServiceAccountKey(user: Pick 0 + ? [ + { + teamId: { + in: teams.map((team) => team.id), + }, + }, + ] + : []), + ], + }, + select: credentialForCalendarServiceSelect, + orderBy: { + id: "asc", + }, + }); + + const { credentials: allCredentials } = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({ + user: { + email: user.email, + id: user.id, + credentials, + }, + }); + + return allCredentials; +} + export async function getUsersCredentials(user: User) { const credentials = await getUsersCredentialsIncludeServiceAccountKey(user); return credentials.map(({ delegatedTo: _1, ...rest }) => rest); @@ -707,4 +753,4 @@ export async function getCredentialForSelectedCalendar({ }); } return undefined; -} +} \ No newline at end of file diff --git a/packages/app-store/server.ts b/packages/app-store/server.ts index a6729e9badd0be..f76280f63492e0 100644 --- a/packages/app-store/server.ts +++ b/packages/app-store/server.ts @@ -59,7 +59,27 @@ export async function getLocationGroupedOptions( where: { id: userOrTeamId.userId, }, + include: { + teams: { + select: { + teamId: true, + }, + }, + }, }); + + if (user?.teams?.length) { + idToSearchObject = { + OR: [ + { + teamId: { + in: user.teams.map((m) => m.teamId), + }, + }, + idToSearchObject, + ], + }; + } } const nonDelegationCredentials = await prisma.credential.findMany({ @@ -110,8 +130,9 @@ export async function getLocationGroupedOptions( : app.categories[0] || app.category; if (!groupByCategory) groupByCategory = AppCategories.conferencing; - for (const { teamName } of app.credentials.map((credential) => ({ + for (const { teamName, credentialId } of app.credentials.map((credential) => ({ teamName: credential.team?.name, + credentialId: credential.id, }))) { const label = `${app.locationOption.label} ${teamName ? `(${teamName})` : ""}`; const option = { @@ -119,15 +140,10 @@ export async function getLocationGroupedOptions( label, icon: app.logo, slug: app.slug, - ...(app.credential - ? { credentialId: app.credential.id, teamName: app.credential.team?.name ?? null } - : {}), + ...(app.credential ? { credentialId: credentialId, teamName: teamName ?? null } : {}), }; if (apps[groupByCategory]) { - const existingOption = apps[groupByCategory].find((o) => o.value === option.value); - if (!existingOption) { - apps[groupByCategory] = [...apps[groupByCategory], option]; - } + apps[groupByCategory] = [...apps[groupByCategory], option]; } else { apps[groupByCategory] = [option]; } diff --git a/packages/features/apps/components/AppList.tsx b/packages/features/apps/components/AppList.tsx index 10831c1d0d712d..b62237cdba6b13 100644 --- a/packages/features/apps/components/AppList.tsx +++ b/packages/features/apps/components/AppList.tsx @@ -71,8 +71,14 @@ export const AppList = ({ const ChildAppCard = ({ item }: { item: AppCardApp }) => { const appSlug = item?.slug; + + const credentialIdToCompare = item.credentialOwner?.credentialId || item.userCredentialIds[0]; + const appIsDefault = - appSlug === defaultConferencingApp?.appSlug || + (appSlug === defaultConferencingApp?.appSlug && + (defaultConferencingApp?.credentialId + ? defaultConferencingApp.credentialId === credentialIdToCompare + : true)) || (appSlug === "daily-video" && !defaultConferencingApp?.appSlug); return ( 0 : false} + invalidCredential={ + item.invalidCredentialIds.length > 0 + ? !!item.invalidCredentialIds.find((id: number) => id === credentialIdToCompare) + : false + } credentialOwner={item?.credentialOwner} actions={ !item.credentialOwner?.readOnly ? ( @@ -106,6 +116,7 @@ export const AppList = ({ } else { handleUpdateUserDefaultConferencingApp({ appSlug, + credentialId: item.credentialOwner?.credentialId, onSuccessCallback: () => setBulkUpdateModal(true), onErrorCallback: () => { return; @@ -118,7 +129,7 @@ export const AppList = ({ )} void; onErrorCallback: () => void; }; diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts index 25daaf47a1a1da..58f0493950f3da 100644 --- a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts @@ -12,14 +12,14 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito vi.mock("@calcom/features/users/repositories/UserRepository", () => { return { UserRepository: vi.fn().mockImplementation(() => ({ - enrichUserWithItsProfile: vi.fn(), + enrichUserWithItsProfileSkipPlatformCheck: vi.fn(), })), }; }); describe("getAllCredentialsIncludeServiceAccountKey", () => { test("Get an individual's credentials", async () => { - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: null, }); @@ -28,7 +28,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } @@ -74,7 +74,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { describe("If CRM is enabled on the event type", () => { describe("With _crm credentials", () => { test("For users", async () => { - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: null, }); @@ -83,7 +83,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } @@ -158,7 +158,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { expect(credentials).toContainEqual(expect.objectContaining({ userId: 1, type: "salesforce_crm" })); }); test("For teams", async () => { - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: null, }); @@ -167,7 +167,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } @@ -235,7 +235,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { expect(credentials).toContainEqual(expect.objectContaining({ teamId: 1, type: "salesforce_crm" })); }); test("For child of managed event type", async () => { - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: null, }); @@ -244,7 +244,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } @@ -340,7 +340,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials")) .getAllCredentialsIncludeServiceAccountKey; const orgId = 3; - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: { organizationId: orgId }, }); @@ -349,7 +349,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } @@ -663,7 +663,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials")) .getAllCredentialsIncludeServiceAccountKey; const orgId = 3; - const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({ + const mockEnrichUserWithItsProfileSkipPlatformCheck = vi.fn().mockReturnValue({ profile: { organizationId: orgId }, }); @@ -672,7 +672,7 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => { mockUserRepository.mockImplementation( () => ({ - enrichUserWithItsProfile: mockEnrichUserWithItsProfile, + enrichUserWithItsProfileSkipPlatformCheck: mockEnrichUserWithItsProfileSkipPlatformCheck, } as any) ); } diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts index 6ed2b690c239d1..14e1dc02b9f6e5 100644 --- a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts @@ -57,15 +57,18 @@ export const getAllCredentialsIncludeServiceAccountKey = async ( } } - const { profile } = await new UserRepository(prisma).enrichUserWithItsProfile({ + const { profile } = await new UserRepository(prisma).enrichUserWithItsProfileSkipPlatformCheck({ user: user, }); // If the user is a part of an organization, query for the organization's credentials if (profile?.organizationId) { - const org = await prisma.team.findUnique({ + const orgAndSubteamCredentials = await prisma.team.findMany({ where: { - id: profile.organizationId, + OR: [ + { id: profile.organizationId }, + ...(!eventType?.team?.id ? [{ parentId: profile.organizationId }] : []), + ], }, select: { credentials: { @@ -74,8 +77,8 @@ export const getAllCredentialsIncludeServiceAccountKey = async ( }, }); - if (org?.credentials) { - allCredentials.push(...org.credentials); + if (orgAndSubteamCredentials?.length) { + allCredentials.push(...orgAndSubteamCredentials.flatMap((team) => team.credentials)); } } diff --git a/packages/features/eventtypes/components/locations/Locations.tsx b/packages/features/eventtypes/components/locations/Locations.tsx index de615985a0476d..3ff03d91a01b36 100644 --- a/packages/features/eventtypes/components/locations/Locations.tsx +++ b/packages/features/eventtypes/components/locations/Locations.tsx @@ -48,9 +48,15 @@ type LocationsProps = { customClassNames?: LocationCustomClassNames; }; -const getLocationFromType = (type: EventLocationType["type"], locationOptions: TLocationOptions) => { +const getLocationFromType = ( + type: EventLocationType["type"], + locationOptions: TLocationOptions, + credentialId?: number +) => { for (const locationOption of locationOptions) { - const option = locationOption.options.find((option) => option.value === type); + const option = locationOption.options.find( + (option) => option.value === type && (credentialId ? option.credentialId === credentialId : true) + ); if (option) { return option; } @@ -175,7 +181,7 @@ const Locations: React.FC = ({ const isCalVideo = field.type === "integrations:daily"; - const option = getLocationFromType(field.type, locationOptions); + const option = getLocationFromType(field.type, locationOptions, field.credentialId); return (
  • @@ -186,6 +192,10 @@ const Locations: React.FC = ({ isDisabled={disableLocationProp} defaultValue={option} isSearchable={false} + isOptionSelected={(option) => + (option.credentialId ? option.credentialId === selectedNewOption?.credentialId : true) && + option.value === selectedNewOption?.value + } className={classNames( "block min-w-0 flex-1 rounded-sm text-sm", customClassNames?.locationSelect?.selectWrapper @@ -202,7 +212,10 @@ const Locations: React.FC = ({ } const canAddLocation = eventLocationType.organizerInputType || - !validLocations?.find((location) => location.type === newLocationType); + !validLocations?.find( + (location) => + location.type === newLocationType && location.credentialId === e.credentialId + ); const shouldUpdateLink = eventLocationType?.organizerInputType === "text" && @@ -329,6 +342,10 @@ const Locations: React.FC = ({ placeholder={t("select")} options={locationOptions} value={selectedNewOption} + isOptionSelected={(option) => + (option.credentialId ? option.credentialId === selectedNewOption?.credentialId : true) && + option.value === selectedNewOption?.value + } isDisabled={disableLocationProp} defaultValue={defaultValue} isSearchable={false} diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 99070eec1c5622..fe4525ede022a5 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -514,6 +514,39 @@ export class UserRepository { profile: ProfileRepository.buildPersonalProfileFromUser({ user }), }; } + /** + * Similar to enrichUserWithItsProfile but skips the platform org check. + * This function directly returns the profile without checking if it's a platform organization. + */ + async enrichUserWithItsProfileSkipPlatformCheck({ + user, + }: { + user: T; + }): Promise< + T & { + nonProfileUsername: string | null; + profile: UserProfile; + } + > { + const profiles = await ProfileRepository.findManyForUser({ id: user.id }); + if (profiles.length) { + const profile = profiles[0]; + // Directly return the profile without checking if it's a platform organization + return { + ...user, + username: profile.username, + nonProfileUsername: user.username, + profile, + }; + } + + // If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always + return { + ...user, + nonProfileUsername: user.username, + profile: ProfileRepository.buildPersonalProfileFromUser({ user }), + }; + } async enrichUsersWithTheirProfiles( users: T[] diff --git a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx index 6c1c132e0d382b..01441ab9f2f604 100644 --- a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx +++ b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx @@ -9,6 +9,7 @@ import DisconnectIntegrationModal from "@calcom/features/apps/components/Disconn import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { GOOGLE_MEET, OFFICE_365_VIDEO, ZOOM } from "@calcom/platform-constants"; +import { type RouterOutputs } from "@calcom/trpc"; import { QueryCell } from "@calcom/trpc/components/QueryCell"; import type { App } from "@calcom/types/App"; import { Button } from "@calcom/ui/components/button"; @@ -41,6 +42,7 @@ import { QUERY_KEY as defaultConferencingAppQueryKey, } from "./hooks/useGetDefaultConferencingApp"; import { useUpdateUserDefaultConferencingApp } from "./hooks/useUpdateUserDefaultConferencingApp"; +import { isAppInstalled } from "./utils/isAppInstalled"; type ConferencingAppSlug = typeof GOOGLE_MEET | typeof ZOOM | typeof OFFICE_365_VIDEO; @@ -49,6 +51,7 @@ type ConferencingAppsViewPlatformWrapperProps = { returnTo?: string; onErrorReturnTo?: string; teamId?: number; + orgId?: number; apps?: ConferencingAppSlug[]; disableBulkUpdateEventTypes?: boolean; }; @@ -77,12 +80,14 @@ export const ConferencingAppsViewPlatformWrapper = ({ returnTo, onErrorReturnTo, teamId, + orgId, apps, disableBulkUpdateEventTypes = false, }: ConferencingAppsViewPlatformWrapperProps) => { const { t } = useLocale(); const queryClient = useQueryClient(); const { toast } = useToast(); + const shouldDisableBulkUpdates = !teamId && orgId ? false : disableBulkUpdateEventTypes; const showToast = (message: string, variant: "success" | "warning" | "error") => { if (!disableToasts) { @@ -109,11 +114,11 @@ export const ConferencingAppsViewPlatformWrapper = ({ updateModal({ isOpen: true, credentialId, app }); }; - const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps(teamId); - const { data: defaultConferencingApp } = useGetDefaultConferencingApp(teamId); + const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps(teamId, orgId); + const { data: defaultConferencingApp } = useGetDefaultConferencingApp(teamId, orgId); const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes( teamId, - disableBulkUpdateEventTypes + shouldDisableBulkUpdates ); const deleteCredentialMutation = useDeleteCredential({ @@ -132,10 +137,12 @@ export const ConferencingAppsViewPlatformWrapper = ({ handleModelClose(); }, teamId, + orgId, }); const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({ teamId, + orgId, }); const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({ @@ -148,24 +155,28 @@ export const ConferencingAppsViewPlatformWrapper = ({ const handleUpdateUserDefaultConferencingApp = ({ appSlug, + credentialId, onSuccessCallback, onErrorCallback, }: UpdateUsersDefaultConferencingAppParams) => { - updateDefaultAppMutation.mutate(appSlug, { - onSuccess: () => { - showToast("Default app updated successfully", "success"); - queryClient.invalidateQueries({ queryKey: [defaultConferencingAppQueryKey] }); - !disableBulkUpdateEventTypes && onSuccessCallback(); - }, - onError: (error) => { - showToast(`Error: ${error.message}`, "error"); - onErrorCallback(); - }, - }); + updateDefaultAppMutation.mutate( + { app: appSlug, credentialId }, + { + onSuccess: () => { + showToast("Default app updated successfully", "success"); + queryClient.invalidateQueries({ queryKey: [defaultConferencingAppQueryKey] }); + !shouldDisableBulkUpdates && onSuccessCallback(); + }, + onError: (error) => { + showToast(`Error: ${error.message}`, "error"); + onErrorCallback(); + }, + } + ); }; const handleBulkUpdateDefaultLocation = ({ eventTypeIds, callback }: BulkUpdatParams) => { - if (disableBulkUpdateEventTypes) { + if (shouldDisableBulkUpdates) { callback(); return; } @@ -198,9 +209,17 @@ export const ConferencingAppsViewPlatformWrapper = ({ returnTo, onErrorReturnTo, teamId, + orgId, }); - const AddConferencingButtonPlatform = ({ installedApps }: { installedApps?: Array<{ slug: string }> }) => { + const AddConferencingButtonPlatform = ({ + installedApps, + }: { + installedApps?: RouterOutputs["viewer"]["apps"]["integrations"]["items"]; + }) => { + const baseApps = teamId || orgId ? [ZOOM, OFFICE_365_VIDEO] : [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO]; + const allowedApps = apps ? baseApps.filter((app) => apps.includes(app as ConferencingAppSlug)) : baseApps; + return ( @@ -209,9 +228,9 @@ export const ConferencingAppsViewPlatformWrapper = ({ - {/* Show Google Meet if it's not installed and either no apps filter is provided or it's in the apps filter */} {installedApps && - !installedApps.find((app) => app.slug === GOOGLE_MEET) && + allowedApps.includes(GOOGLE_MEET) && + !isAppInstalled({ appSlug: GOOGLE_MEET, installedApps, orgId, teamId }) && (!apps || apps.includes(GOOGLE_MEET)) && ( connect(GOOGLE_MEET)}> @@ -220,9 +239,9 @@ export const ConferencingAppsViewPlatformWrapper = ({ )} - {/* Show Zoom if it's not installed and either no apps filter is provided or it's in the apps filter */} {installedApps && - !installedApps.find((app) => app.slug === ZOOM) && + allowedApps.includes(ZOOM) && + !isAppInstalled({ appSlug: ZOOM, installedApps, orgId, teamId }) && (!apps || apps.includes(ZOOM)) && ( connect(ZOOM)}> @@ -231,9 +250,9 @@ export const ConferencingAppsViewPlatformWrapper = ({ )} - {/* Show Office 365 Video if it's not installed and either no apps filter is provided or it's in the apps filter */} {installedApps && - !installedApps.find((app) => app.slug === OFFICE_365_VIDEO) && + allowedApps.includes(OFFICE_365_VIDEO) && + !isAppInstalled({ appSlug: OFFICE_365_VIDEO, installedApps, orgId, teamId }) && (!apps || apps.includes(OFFICE_365_VIDEO)) && ( setIsAccountModalOpen(true)}> diff --git a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx index 6f78be2c896ee8..eb7ae23140ce78 100644 --- a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx +++ b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx @@ -16,6 +16,7 @@ import { showToast } from "@calcom/ui/components/toast"; export type UpdateUsersDefaultConferencingAppParams = { appSlug: string; appLink?: string; + credentialId?: number; onSuccessCallback: () => void; onErrorCallback: () => void; }; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts index c54aca07ea58d2..6cbc70529ca622 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts @@ -9,13 +9,15 @@ import http from "../../../lib/http"; export const QUERY_KEY = "get-installed-conferencing-apps"; -export const useAtomsGetInstalledConferencingApps = (teamId?: number) => { +export const useAtomsGetInstalledConferencingApps = (teamId?: number, orgId?: number) => { const { isInit, accessToken, organizationId } = useAtomsContext(); let pathname = "/atoms/conferencing"; if (teamId) { pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/conferencing`; + } else if (orgId) { + pathname = `/atoms/organizations/${orgId}/conferencing`; } return useQuery({ diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts index 03a990fc973920..d7fdf58c086255 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts @@ -18,14 +18,22 @@ export type UseGetOauthAuthUrlProps = { returnTo?: string; onErrorReturnTo?: string; teamId?: number; + orgId?: number; }; -export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo, teamId }: UseGetOauthAuthUrlProps) => { +export const useGetZoomOauthAuthUrl = ({ + returnTo, + onErrorReturnTo, + teamId, + orgId, +}: UseGetOauthAuthUrlProps) => { const { organizationId } = useAtomsContext(); let pathname = `conferencing/${ZOOM}/oauth/auth-url`; if (teamId) { pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${ZOOM}/oauth/auth-url`; + } else if (orgId) { + pathname = `organizations/${organizationId}/conferencing/${ZOOM}/oauth/auth-url`; } const queryParams = new URLSearchParams(); @@ -54,12 +62,15 @@ export const useOffice365GetOauthAuthUrl = ({ returnTo, onErrorReturnTo, teamId, + orgId, }: UseGetOauthAuthUrlProps) => { const { organizationId } = useAtomsContext(); let pathname = `conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`; if (teamId) { pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`; + } else if (orgId) { + pathname = `organizations/${organizationId}/conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`; } // Add query parameters @@ -91,6 +102,7 @@ export type UseConnectGoogleMeetProps = { returnTo?: string; onErrorReturnTo?: string; teamId?: number; + orgId?: number; }; export const useConnectNonOauthApp = ( diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts index b40ebb7f05bc93..a9a95166288651 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts @@ -12,8 +12,15 @@ export type UseDeleteEventTypeProps = { onError?: (err: Error) => void; onSettled?: () => void; teamId?: number; + orgId?: number; }; -export const useDeleteCredential = ({ onSuccess, onError, onSettled, teamId }: UseDeleteEventTypeProps) => { +export const useDeleteCredential = ({ + onSuccess, + onError, + onSettled, + teamId, + orgId, +}: UseDeleteEventTypeProps) => { const { organizationId } = useAtomsContext(); return useMutation({ onSuccess, @@ -26,6 +33,8 @@ export const useDeleteCredential = ({ onSuccess, onError, onSettled, teamId }: U if (teamId) { pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/disconnect`; + } else if (orgId) { + pathname = `/organizations/${orgId}/conferencing/${app}/disconnect`; } return http?.delete(pathname).then((res) => { if (res.data.status === SUCCESS_STATUS) { diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts index 5ad1dbe4a9f35e..2125cc85380fb8 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts @@ -13,15 +13,18 @@ type ResponseDataType = | { appSlug?: string; appLink?: string; + credentialId?: number; } | undefined; -export const useGetDefaultConferencingApp = (teamId?: number) => { +export const useGetDefaultConferencingApp = (teamId?: number, orgId?: number) => { const { isInit, accessToken, organizationId } = useAtomsContext(); let pathname = `/conferencing/default`; if (teamId) { pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/default`; + } else if (orgId) { + pathname = `/organizations/${orgId}/conferencing/default`; } return useQuery({ diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts index 09d996fa87f6a3..4812df2fc38455 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts @@ -12,25 +12,35 @@ export type UseUpdateUserDefaultConferencingAppProps = { onError?: (err: Error) => void; onSettled?: () => void; teamId?: number; + orgId?: number; }; export const useUpdateUserDefaultConferencingApp = ({ onSuccess, onError, onSettled, teamId, + orgId, }: UseUpdateUserDefaultConferencingAppProps) => { const { organizationId } = useAtomsContext(); return useMutation({ onSuccess, onError, onSettled, - mutationFn: (app: App["slug"]) => { + mutationFn: ({ app, credentialId }: { app: App["slug"]; credentialId?: number }) => { if (!app) throw new Error("app is required"); - let pathname = `/conferencing/${app}/default`; + let pathname = + credentialId !== undefined + ? `/conferencing/${app}/default/${credentialId}` + : `/conferencing/${app}/default`; if (teamId) { - pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/default`; + pathname = + credentialId !== undefined + ? `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/default/${credentialId}` + : `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/default`; + } else if (orgId) { + pathname = `/organizations/${orgId}/conferencing/${app}/default`; } return http?.post(pathname).then((res) => { if (res.data.status === SUCCESS_STATUS) { diff --git a/packages/platform/atoms/connect/conferencing-apps/utils/isAppInstalled.ts b/packages/platform/atoms/connect/conferencing-apps/utils/isAppInstalled.ts new file mode 100644 index 00000000000000..bb328b2737b7ff --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/utils/isAppInstalled.ts @@ -0,0 +1,31 @@ +import type { RouterOutputs } from "@calcom/trpc"; + +type InstalledApp = RouterOutputs["viewer"]["apps"]["integrations"]["items"][0]; + +interface IIsAppInstalledParams { + appSlug: string; + installedApps: InstalledApp[] | undefined; + orgId?: number; + teamId?: number; +} + +export const isAppInstalled = ({ appSlug, installedApps, orgId, teamId }: IIsAppInstalledParams): boolean => { + if (!installedApps?.length) return false; + + const app = installedApps.find((app) => app.slug === appSlug); + if (!app) return false; + + if (!orgId && !teamId) return app.userCredentialIds.length > 0; + + if (!app.teams?.length) return false; + + if (teamId) { + return app.teams.some((team) => team?.teamId === teamId); + } + + if (orgId) { + return app.teams.some((team) => team?.teamId === orgId); + } + + return false; +}; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx index fc9fa6443833ce..837c3bd8be7812 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx @@ -37,7 +37,7 @@ import EventAvailabilityTabPlatformWrapper from "./EventAvailabilityTabPlatformW import EventLimitsTabPlatformWrapper from "./EventLimitsTabPlatformWrapper"; import EventPaymentsTabPlatformWrapper from "./EventPaymentsTabPlatformWrapper"; import EventRecurringTabPlatformWrapper from "./EventRecurringTabPlatformWrapper"; -import SetupTab from "./EventSetupTabPlatformWrapper"; +import EventSetupTabPlatformWrapper from "./EventSetupTabPlatformWrapper"; import EventTeamAssignmentTabPlatformWrapper from "./EventTeamAssignmentTabPlatformWrapper"; import type { PlatformTabs } from "./types"; @@ -232,7 +232,7 @@ const EventType = forwardRef< const tabMap = { setup: tabs.includes("setup") ? ( - { - const { organizationId } = useAtomsContext(); + const { organizationId, isInit } = useAtomsContext(); const isDynamic = useMemo(() => { return getUsernameList(username ?? "").length > 1; @@ -59,6 +59,7 @@ export const useAtomGetPublicEvent = ({ username, eventSlug, isTeamEvent, teamId throw new Error(res.data.error.message); }); }, + enabled: isInit, }); return event; diff --git a/packages/platform/examples/base/src/pages/conferencing-apps.tsx b/packages/platform/examples/base/src/pages/conferencing-apps.tsx index 7bec6014a27f77..fec205eafdbaa4 100644 --- a/packages/platform/examples/base/src/pages/conferencing-apps.tsx +++ b/packages/platform/examples/base/src/pages/conferencing-apps.tsx @@ -15,7 +15,13 @@ export default function ConferencingApps(props: { calUsername: string; calEmail: className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
    - +
    ); diff --git a/packages/platform/libraries/app-store.ts b/packages/platform/libraries/app-store.ts index 2b86dcf42a70ae..c9d7632daf6ee4 100644 --- a/packages/platform/libraries/app-store.ts +++ b/packages/platform/libraries/app-store.ts @@ -32,7 +32,6 @@ export type { CredentialPayload } from "@calcom/types/Credential"; export { addDelegationCredential }; -export { enrichUserWithDelegationConferencingCredentialsWithoutOrgId } from "@calcom/app-store/delegationCredential"; export { toggleDelegationCredentialEnabled } from "@calcom/trpc/server/routers/viewer/delegationCredential/toggleEnabled.handler"; export { CalendarAppError, @@ -46,4 +45,7 @@ export { export { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository"; export { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema"; -export { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; +export { getUsersCredentialsIncludeServiceAccountKey, +getUsersAndTeamsCredentialsIncludeServiceAccountKey, +enrichUserWithDelegationConferencingCredentialsWithoutOrgId + } from "@calcom/app-store/delegationCredential"; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 56025f6e0da3dc..a1644a50829676 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -328,6 +328,7 @@ export const createdEventSchema = z const schemaDefaultConferencingApp = z.object({ appSlug: z.string().default("daily-video").optional(), appLink: z.string().optional(), + credentialId: z.number().optional(), }); export const userMetadata = z