From f8be2b3833fa1681aa490043fff03b06bf1ae40d Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Mon, 16 Dec 2024 19:50:59 +0530 Subject: [PATCH] feat: added office 365 video to conferencing atoms (#18067) * feat: added office 365 video to conferencing atoms * added documentation for conferencing atoms * added props table to the documentation --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- .../conferencing/conferencing.module.ts | 2 + .../controllers/conferencing.controller.ts | 26 +++- .../services/office365-video.service.ts | 121 ++++++++++++++++++ .../services/zoom-video.service.ts | 2 +- apps/api/v2/swagger/documentation.json | 2 +- apps/web/public/static/locales/en/common.json | 1 + docs/mint.json | 3 +- docs/platform/atoms/conferencing-apps.mdx | 93 ++++++++++++++ .../components/AccountDialog.tsx | 28 +--- .../components/InstallAppButton.tsx | 23 +++- .../ConferencingAppsViewPlatformWrapper.tsx | 21 ++- .../conferencing-apps/hooks/useConnect.ts | 48 ++++++- packages/platform/constants/apps.ts | 8 +- 13 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 apps/api/v2/src/modules/conferencing/services/office365-video.service.ts create mode 100644 docs/platform/atoms/conferencing-apps.mdx diff --git a/apps/api/v2/src/modules/conferencing/conferencing.module.ts b/apps/api/v2/src/modules/conferencing/conferencing.module.ts index 0bc314c754..1070727911 100644 --- a/apps/api/v2/src/modules/conferencing/conferencing.module.ts +++ b/apps/api/v2/src/modules/conferencing/conferencing.module.ts @@ -3,6 +3,7 @@ import { ConferencingController } from "@/modules/conferencing/controllers/confe import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; @@ -21,6 +22,7 @@ import { ConfigModule } from "@nestjs/config"; UsersRepository, TokensRepository, ZoomVideoService, + Office365VideoService, AppsRepository, ], exports: [], 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 bf612dac2b..4ad3517bab 100644 --- a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -15,6 +15,7 @@ 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 { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; @@ -39,7 +40,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; import { Request } from "express"; -import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants"; export type OAuthCallbackState = { accessToken: string; @@ -61,7 +62,8 @@ export class ConferencingController { private readonly tokensRepository: TokensRepository, private readonly conferencingService: ConferencingService, private readonly googleMeetService: GoogleMeetService, - private readonly zoomVideoService: ZoomVideoService + private readonly zoomVideoService: ZoomVideoService, + private readonly office365VideoService: Office365VideoService ) {} @Post("/:app/connect") @@ -116,8 +118,18 @@ export class ConferencingController { data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), }; + case OFFICE_365_VIDEO: + credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state)); + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), + }; + default: - throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", ")); + throw new BadRequestException( + "Invalid conferencing app, available apps are: ", + [ZOOM, OFFICE_365_VIDEO].join(", ") + ); } } @@ -147,8 +159,14 @@ export class ConferencingController { case ZOOM: return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId); + case OFFICE_365_VIDEO: + return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId); + default: - throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", ")); + throw new BadRequestException( + "Invalid conferencing app, available apps are: ", + [ZOOM, OFFICE_365_VIDEO].join(", ") + ); } } catch (error) { return { diff --git a/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts new file mode 100644 index 0000000000..a6edd50d28 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts @@ -0,0 +1,121 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller"; +import { BadRequestException, Logger, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants"; + +import stringify = require("qs-stringify"); + +const zoomAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +@Injectable() +export class Office365VideoService { + private logger = new Logger("Office365VideoService"); + private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`; + private scopes = ["OnlineMeetings.ReadWrite", "offline_access"]; + + constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {} + + async getOffice365AppKeys() { + const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO); + + const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Office365 app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Office365 app not found"); + } + + return { client_id, client_secret }; + } + + async generateOffice365AuthUrl(state: string) { + const { client_id } = await this.getOffice365AppKeys(); + + const params = { + response_type: "code", + client_id, + scope: this.scopes.join(" "), + redirect_uri: this.redirectUri, + state: state, + }; + + const query = stringify(params); + + const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`; + return { url }; + } + + async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) { + const { client_id, client_secret } = await this.getOffice365AppKeys(); + + const toUrlEncoded = (payload: Record) => + Object.keys(payload) + .map((key) => `${key}=${encodeURIComponent(payload[key])}`) + .join("&"); + + const body = toUrlEncoded({ + client_id, + grant_type: "authorization_code", + code, + scope: this.scopes.join(" "), + redirect_uri: this.redirectUri, + client_secret, + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body, + }); + + const responseBody = await response.json(); + + if (!response.ok) { + throw new BadRequestException(responseBody.error); + } + + const whoami = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${responseBody.access_token}` }, + }); + + const graphUser = await whoami.json(); + + // In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address. + responseBody.email = graphUser.mail ?? graphUser.userPrincipalName; + responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds + delete responseBody.expires_in; + + const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({ + type: OFFICE_365_VIDEO_TYPE, + userId, + appId: OFFICE_365_VIDEO, + }); + + const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId); + } + + await this.appsRepository.createAppCredential( + OFFICE_365_VIDEO_TYPE, + responseBody as unknown as Prisma.InputJsonObject, + userId, + OFFICE_365_VIDEO + ); + + return { url: state.returnTo ?? "" }; + } +} diff --git a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts index e03b6f6dae..1058e58736 100644 --- a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts @@ -18,7 +18,7 @@ const zoomAppKeysSchema = z.object({ @Injectable() export class ZoomVideoService { private logger = new Logger("ZoomVideoService"); - private redirectUri = `${this.config.get("api.url")}/conferencing/zoom/oauth/callback`; + private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`; constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 717da59c00..6a1978bb37 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -15367,4 +15367,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5c542e3852..d86ae5b650 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2878,6 +2878,7 @@ "salesforce_ignore_guests": "Do not create new records for guests added to the booking", "google_meet": "Google Meet", "zoom": "Zoom", + "office_365_video": "MS Teams Video", "lock_attribute_for_assignment": "Lock for assignment", "lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync", "attribute_edited_successfully": "Attribute edited successfully", diff --git a/docs/mint.json b/docs/mint.json index d3001e76aa..1bc9931357 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -197,7 +197,8 @@ "platform/atoms/booker", "platform/atoms/event-type", "platform/atoms/calendar-settings", - "platform/atoms/payment-form" + "platform/atoms/payment-form", + "platform/atoms/conferencing-apps" ] }, { diff --git a/docs/platform/atoms/conferencing-apps.mdx b/docs/platform/atoms/conferencing-apps.mdx new file mode 100644 index 0000000000..c294c7a920 --- /dev/null +++ b/docs/platform/atoms/conferencing-apps.mdx @@ -0,0 +1,93 @@ +--- +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 + +```js + +import { ConferencingAppsSettings } from "@calcom/atoms"; +import { usePathname } from "next/navigation"; + +export default function ConferencingApps() { + const pathname = usePathname(); + const callbackUri = `${window.location.origin}${pathname}`; + + return ( + <> + + + ) +} +``` + +Below is a list of props that can be passed to the Conferencing Apps Atom + +

+ +| Name | Required | Description | +| :------------------ | :------- | :-------------------------------------------------------------------------- | +| returnTo | No | The URL of the page to redirect to after a successful installation. | +| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. | +| disableToasts | No | boolean value to disable toast notifications in the atom. | + + +## Google Meet + +For a demonstration of installing Google Meet, setting it as the default conferencing app for all event types, and removing the app, please watch the video below. + +

+ + + +

+ +Google meet requires Google Calendar to be installed first + +## Zoom + +For a demonstration of installing Zoom, setting it as the default conferencing app for all event types, and removing the app, please watch the video below. + +

+ + + +

+ +## MS Teams Video + +For a demonstration of installing MS Teams Video, setting it as the default conferencing app for all event types, and removing the app, please watch the video below. + +

+ + + +

+ + + Connecting with MS Teams requires a work/school Microsoft account. + If you continue with a personal account you will receive an error + \ No newline at end of file diff --git a/packages/app-store/office365video/components/AccountDialog.tsx b/packages/app-store/office365video/components/AccountDialog.tsx index b23513065a..ddc2a6a266 100644 --- a/packages/app-store/office365video/components/AccountDialog.tsx +++ b/packages/app-store/office365video/components/AccountDialog.tsx @@ -1,14 +1,12 @@ -import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; -import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; -import { WEBAPP_URL } from "@calcom/lib/constants"; import type { DialogProps } from "@calcom/ui"; import { Button } from "@calcom/ui"; import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; -import useAddAppMutation from "../../_utils/useAddAppMutation"; - -export function AccountDialog(props: DialogProps) { - const mutation = useAddAppMutation(null); +export function AccountDialog( + props: DialogProps & { + handleSubmit: () => void; + } +) { return ( - diff --git a/packages/app-store/office365video/components/InstallAppButton.tsx b/packages/app-store/office365video/components/InstallAppButton.tsx index a5cbdafd43..a2c790664e 100644 --- a/packages/app-store/office365video/components/InstallAppButton.tsx +++ b/packages/app-store/office365video/components/InstallAppButton.tsx @@ -1,10 +1,29 @@ import { useState } from "react"; +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import useAddAppMutation from "../../_utils/useAddAppMutation"; import type { InstallAppButtonProps } from "../../types"; -import AddIntegration from "./AccountDialog"; +import AccountDialog from "./AccountDialog"; export default function InstallAppButton(props: InstallAppButtonProps) { const [isModalOpen, setIsModalOpen] = useState(false); + const mutation = useAddAppMutation(null); + const handleSubmit = () => { + mutation.mutate({ + type: "office365_video", + variant: "conferencing", + slug: "msteams", + returnTo: + WEBAPP_URL + + getAppOnboardingUrl({ + slug: "msteams", + step: AppOnboardingSteps.EVENT_TYPES_STEP, + }), + }); + }; return ( <> @@ -14,7 +33,7 @@ export default function InstallAppButton(props: InstallAppButtonProps) { }, disabled: isModalOpen, })} - + ); } diff --git a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx index 769bfa4aa7..22b9dac744 100644 --- a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx +++ b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx @@ -1,13 +1,14 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { useReducer } from "react"; +import { useReducer, useState } from "react"; +import AccountDialog from "@calcom/app-store/office365video/components/AccountDialog"; import { AppList } from "@calcom/features/apps/components/AppList"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { GOOGLE_MEET, ZOOM } from "@calcom/platform-constants"; +import { GOOGLE_MEET, OFFICE_365_VIDEO, ZOOM } from "@calcom/platform-constants"; import { QueryCell } from "@calcom/trpc/components/QueryCell"; import type { App } from "@calcom/types/App"; import { @@ -91,6 +92,8 @@ export const ConferencingAppsViewPlatformWrapper = ({ } ); + const [isAccountModalOpen, setIsAccountModalOpen] = useState(false); + const handleModelClose = () => { updateModal({ isOpen: false, credentialId: null, app: null }); }; @@ -199,6 +202,14 @@ export const ConferencingAppsViewPlatformWrapper = ({ )} + + {installedApps && !installedApps?.find((app) => app.slug == OFFICE_365_VIDEO) && ( + + setIsAccountModalOpen(true)}> + {t("office_365_video")} + + + )} ); @@ -257,6 +268,12 @@ export const ConferencingAppsViewPlatformWrapper = ({ app={modal.app} handleRemoveApp={handleRemoveApp} /> + + connect(OFFICE_365_VIDEO)} + /> diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts index efb4b034e9..ab8b98d5d9 100644 --- a/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts @@ -1,7 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query"; -import { SUCCESS_STATUS, ERROR_STATUS, ZOOM, GOOGLE_MEET } from "@calcom/platform-constants"; +import { + SUCCESS_STATUS, + ERROR_STATUS, + ZOOM, + GOOGLE_MEET, + OFFICE_365_VIDEO, +} from "@calcom/platform-constants"; import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; import type { App } from "@calcom/types/App"; @@ -12,7 +18,7 @@ export type UseGetOauthAuthUrlProps = { onErrorReturnTo?: string; }; -export const useGetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => { +export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => { return useQuery({ queryKey: ["get-zoom-auth-url"], staleTime: Infinity, @@ -35,6 +41,29 @@ export const useGetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAut }); }; +export const useOffice365GetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => { + return useQuery({ + queryKey: ["get-office365-auth-url"], + staleTime: Infinity, + enabled: false, + queryFn: () => { + return http + ?.get>( + `conferencing/${OFFICE_365_VIDEO}/oauth/auth-url${ + returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : "" + }${onErrorReturnTo ? `&onErrorReturnTo=${encodeURIComponent(onErrorReturnTo)}` : ""}` + ) + .then(({ data: responseBody }) => { + if (responseBody.status === SUCCESS_STATUS) { + return responseBody.data.url; + } + if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message); + return ""; + }); + }, + }); +}; + export type UseConnectGoogleMeetProps = { onSuccess?: (res: ApiResponse) => void; onError?: (err: ApiErrorResponse) => void; @@ -69,18 +98,25 @@ export const useConnectNonOauthApp = ( }; export const useConnect = ({ returnTo, onErrorReturnTo, ...props }: UseConnectGoogleMeetProps) => { - const { refetch } = useGetOauthAuthUrl({ returnTo, onErrorReturnTo }); + const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl({ returnTo, onErrorReturnTo }); + const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl({ returnTo, onErrorReturnTo }); const connectNonOauthApp = useConnectNonOauthApp(props); const connect = async (app: App["slug"]) => { switch (app) { case ZOOM: - const redirectUri = await refetch(); - if (redirectUri.data) { - window.location.href = redirectUri.data; + const zoomRedirectUri = await refetchZoomAuthUrl(); + if (zoomRedirectUri.data) { + window.location.href = zoomRedirectUri.data; } break; + case OFFICE_365_VIDEO: + const office365RedirectUri = await refetchOffice365AuthUrl(); + if (office365RedirectUri.data) { + window.location.href = office365RedirectUri.data; + } + break; case GOOGLE_MEET: connectNonOauthApp.mutate(app); diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts index e26aff7820..f1ad75555e 100644 --- a/packages/platform/constants/apps.ts +++ b/packages/platform/constants/apps.ts @@ -20,9 +20,13 @@ export const GOOGLE_MEET_ID = "google-meet"; export const ZOOM = "zoom"; export const ZOOM_TYPE = "zoom_video"; -export const CAL_VIDEO = "daily-video"; +export const OFFICE_365_VIDEO = "msteams"; +export const OFFICE_365_VIDEO_TYPE = "office365_video"; -export const CONFERENCING_APPS = [GOOGLE_MEET, ZOOM]; +export const CAL_VIDEO = "daily-video"; +export const CAL_VIDEO_TYPE = "daily_video"; + +export const CONFERENCING_APPS = [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO]; export const APPS_TYPE_ID_MAPPING = { [GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID, [OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID,