Monorepo/app store MS Teams Integration (#2080)

* Create teamsvideo package

* Remove zoom specific refrences

* Add teams video files

* Rename to office365_video

* Add call back to add crednetial type office365_teams

* Rename to office_video to match type

* Add MS Teams as a location option

* Rename files

* Add teams reponse interface and create meeting

* Comment out Daily imports

* Add check for Teams integration

* Add token checking functions

* Change template to create event rather than meeting

* Add comment to test between create link and event

* Add teams URL to booking

* Ask for just onlineMeeting permission

* Add MS Teams logo

* Add message to have an enterprise account

* Remove comments

* Comment back hasDailyIntegration

* Comment back daily credentials

* Update link to MS Graph section of README

* Move API calls to package

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Joe Au-Yeung 2022-03-08 12:22:08 -05:00 committed by GitHub
parent 26e5904d00
commit 26db39f98b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 507 additions and 62 deletions

View File

@ -141,6 +141,7 @@ const BookingPage = (props: BookingPageProps) => {
[LocationType.Daily]: "Daily.co Video",
[LocationType.Huddle01]: "Huddle01 Video",
[LocationType.Tandem]: "Tandem Video",
[LocationType.Teams]: "MS Teams",
};
const defaultValues = () => {

View File

@ -54,13 +54,17 @@ export const isTandem = (location: string): boolean => {
return location === "integrations:tandem";
};
export const isTeams = (location: string): boolean => {
return location === "integrations:office365_video";
};
export const isJitsi = (location: string): boolean => {
return location === "integrations:jitsi";
};
export const isDedicatedIntegration = (location: string): boolean => {
return (
isZoom(location) || isDaily(location) || isHuddle01(location) || isTandem(location) || isJitsi(location)
isZoom(location) || isDaily(location) || isHuddle01(location) || isTandem(location) || isJitsi(location) || isTeams(location)
);
};
@ -71,7 +75,8 @@ export const getLocationRequestFromIntegration = (location: string) => {
location === LocationType.Daily.valueOf() ||
location === LocationType.Jitsi.valueOf() ||
location === LocationType.Huddle01.valueOf() ||
location === LocationType.Tandem.valueOf()
location === LocationType.Tandem.valueOf() ||
location === LocationType.Teams.valueOf()
) {
const requestId = uuidv5(location, uuidv5.URL);

View File

@ -287,6 +287,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return <p className="text-sm">{t("cal_provide_huddle01_meeting_url")}</p>;
case LocationType.Tandem:
return <p className="text-sm">{t("cal_provide_tandem_meeting_url")}</p>;
case LocationType.Teams:
return <p className="text-sm">{t("cal_provide_teams_meeting_url")}</p>;
default:
return null;
}
@ -410,7 +412,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
{formMethods.getValues("locations").map((location) => (
<li
key={location.type}
className="mb-2 rounded-sm border border-neutral-300 py-1.5 px-2 shadow-sm">
className="border-neutral-300 mb-2 rounded-sm border py-1.5 px-2 shadow-sm">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex flex-grow items-center">
@ -610,6 +612,78 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<span className="ml-2 text-sm">Jitsi Meet</span>
</div>
)}
{location.type === LocationType.Teams && (
<div className="flex flex-grow items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
viewBox="0 0 2228.833 2073.333">
<path
fill="#5059C9"
d="M1554.637,777.5h575.713c54.391,0,98.483,44.092,98.483,98.483c0,0,0,0,0,0v524.398 c0,199.901-162.051,361.952-361.952,361.952h0h-1.711c-199.901,0.028-361.975-162-362.004-361.901c0-0.017,0-0.034,0-0.052V828.971 C1503.167,800.544,1526.211,777.5,1554.637,777.5L1554.637,777.5z"
/>
<circle fill="#5059C9" cx="1943.75" cy="440.583" r="233.25" />
<circle fill="#7B83EB" cx="1218.083" cy="336.917" r="336.917" />
<path
fill="#7B83EB"
d="M1667.323,777.5H717.01c-53.743,1.33-96.257,45.931-95.01,99.676v598.105 c-7.505,322.519,247.657,590.16,570.167,598.053c322.51-7.893,577.671-275.534,570.167-598.053V877.176 C1763.579,823.431,1721.066,778.83,1667.323,777.5z"
/>
<path
opacity=".1"
d="M1244,777.5v838.145c-0.258,38.435-23.549,72.964-59.09,87.598 c-11.316,4.787-23.478,7.254-35.765,7.257H667.613c-6.738-17.105-12.958-34.21-18.142-51.833 c-18.144-59.477-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1244z"
/>
<path
opacity=".2"
d="M1192.167,777.5v889.978c-0.002,12.287-2.47,24.449-7.257,35.765 c-14.634,35.541-49.163,58.833-87.598,59.09H691.975c-8.812-17.105-17.105-34.21-24.362-51.833 c-7.257-17.623-12.958-34.21-18.142-51.833c-18.144-59.476-27.402-121.307-27.472-183.49V877.02 c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z"
/>
<path
opacity=".2"
d="M1192.167,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855h-447.84 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z"
/>
<path
opacity=".2"
d="M1140.333,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855H649.472 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1140.333z"
/>
<path
opacity=".1"
d="M1244,509.522v163.275c-8.812,0.518-17.105,1.037-25.917,1.037 c-8.812,0-17.105-0.518-25.917-1.037c-17.496-1.161-34.848-3.937-51.833-8.293c-104.963-24.857-191.679-98.469-233.25-198.003 c-7.153-16.715-12.706-34.071-16.587-51.833h258.648C1201.449,414.866,1243.801,457.217,1244,509.522z"
/>
<path
opacity=".2"
d="M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z"
/>
<path
opacity=".2"
d="M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z"
/>
<path
opacity=".2"
d="M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z"
/>
<linearGradient
id="a"
gradientUnits="userSpaceOnUse"
x1="198.099"
y1="1683.0726"
x2="942.2344"
y2="394.2607"
gradientTransform="matrix(1 0 0 -1 0 2075.3333)">
<stop offset="0" stopColor="#5a62c3" />
<stop offset=".5" stopColor="#4d55bd" />
<stop offset="1" stopColor="#3940ab" />
</linearGradient>
<path
fill="url(#a)"
d="M95.01,466.5h950.312c52.473,0,95.01,42.538,95.01,95.01v950.312c0,52.473-42.538,95.01-95.01,95.01 H95.01c-52.473,0-95.01-42.538-95.01-95.01V561.51C0,509.038,42.538,466.5,95.01,466.5z"
/>
<path
fill="#FFF"
d="M820.211,828.193H630.241v517.297H509.211V828.193H320.123V727.844h500.088V828.193z"
/>
</svg>
<span className="ml-2 text-sm">MS Teams</span>
</div>
)}
<div className="flex">
<button
type="button"
@ -631,8 +705,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
type="button"
className="flex rounded-sm py-2 hover:bg-gray-100"
onClick={() => setShowLocationModal(true)}>
<PlusIcon className="mt-0.5 h-4 w-4 text-neutral-900" />
<span className="ml-1 text-sm font-medium text-neutral-700">{t("add_location")}</span>
<PlusIcon className="text-neutral-900 mt-0.5 h-4 w-4" />
<span className="text-neutral-700 ml-1 text-sm font-medium">{t("add_location")}</span>
</button>
</li>
)}
@ -668,7 +742,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
autoFocus
style={{ top: -6, fontSize: 22 }}
required
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
className="focus:outline-none relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:ring-0"
placeholder={t("quick_chat")}
{...formMethods.register("title")}
defaultValue={eventType.title}
@ -681,7 +755,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<ClientSuspense fallback={<Loader />}>
<div className="mx-auto block sm:flex md:max-w-5xl">
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<div className="border-neutral-200 -mx-4 rounded-sm border bg-white p-4 py-6 sm:mx-0 sm:px-8">
<Form
form={formMethods}
handleSubmit={async (values) => {
@ -713,8 +787,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="space-y-3">
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="slug" className="flex text-sm font-medium text-neutral-700">
<LinkIcon className="mt-0.5 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
<label htmlFor="slug" className="text-neutral-700 flex text-sm font-medium">
<LinkIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("url")}
</label>
</div>
@ -744,7 +818,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<MinutesField
label={
<>
<ClockIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
<ClockIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" />{" "}
{t("duration")}
</>
}
@ -766,8 +840,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 sm:mb-0">
<label
htmlFor="location"
className="mt-2.5 flex text-sm font-medium text-neutral-700">
<LocationMarkerIcon className="mt-0.5 mb-4 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
className="text-neutral-700 mt-2.5 flex text-sm font-medium">
<LocationMarkerIcon className="text-neutral-500 mt-0.5 mb-4 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("location")}
</label>
</div>
@ -785,8 +859,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 mt-2.5 sm:mb-0">
<label
htmlFor="description"
className="mt-0 flex text-sm font-medium text-neutral-700">
<DocumentIcon className="mt-0.5 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
className="text-neutral-700 mt-0 flex text-sm font-medium">
<DocumentIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("description")}
</label>
</div>
@ -807,8 +881,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="schedulingType"
className="mt-2 flex text-sm font-medium text-neutral-700">
<UsersIcon className="h-5 w-5 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
className="text-neutral-700 mt-2 flex text-sm font-medium">
<UsersIcon className="text-neutral-500 h-5 w-5 ltr:mr-2 rtl:ml-2" />{" "}
{t("scheduling_type")}
</label>
</div>
@ -831,8 +905,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="users" className="flex text-sm font-medium text-neutral-700">
<UserAddIcon className="h-5 w-5 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
<label htmlFor="users" className="text-neutral-700 flex text-sm font-medium">
<UserAddIcon className="text-neutral-500 h-5 w-5 ltr:mr-2 rtl:ml-2" />{" "}
{t("attendees")}
</label>
</div>
@ -868,9 +942,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<ChevronRightIcon
className={`${
advancedSettingsVisible ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
} text-neutral-500 ml-auto h-5 w-5`}
/>
<span className="text-sm font-medium text-neutral-700">
<span className="text-neutral-700 text-sm font-medium">
{t("show_advanced_settings")}
</span>
</CollapsibleTrigger>
@ -885,7 +959,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="createEventsOn"
className="flex text-sm font-medium text-neutral-700">
className="text-neutral-700 flex text-sm font-medium">
{t("create_events_on")}
</label>
</div>
@ -909,7 +983,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)}
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
<label htmlFor="eventName" className="text-neutral-700 flex text-sm font-medium">
{t("event_name")} <InfoBadge content={t("event_name_tooltip")} />
</label>
</div>
@ -930,7 +1004,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="smartContractAddress"
className="flex text-sm font-medium text-neutral-700">
className="text-neutral-700 flex text-sm font-medium">
{t("Smart Contract Address")}
</label>
</div>
@ -953,7 +1027,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="additionalFields"
className="flexflex mt-2 text-sm font-medium text-neutral-700">
className="flexflex text-neutral-700 mt-2 text-sm font-medium">
{t("additional_inputs")}
</label>
</div>
@ -1059,7 +1133,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)}
/>
<hr className="my-2 border-neutral-200" />
<hr className="border-neutral-200 my-2" />
<Controller
name="minimumBookingNotice"
control={formMethods.control}
@ -1080,7 +1154,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
<label htmlFor="eventName" className="text-neutral-700 flex text-sm font-medium">
{t("slot_interval")}
</label>
</div>
@ -1129,7 +1203,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="inviteesCanSchedule"
className="mt-2.5 flex text-sm font-medium text-neutral-700">
className="text-neutral-700 mt-2.5 flex text-sm font-medium">
{t("invitees_can_schedule")}
</label>
</div>
@ -1149,7 +1223,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<RadioGroup.Item
id={period.type}
value={period.type}
className="flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
className="focus:outline-none flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
@ -1164,7 +1238,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
<select
id=""
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
className="focus:border-primary-500 focus:ring-primary-500 focus:outline-none block w-full rounded-sm border-gray-300 py-2 pl-3 pr-10 text-base sm:text-sm"
{...formMethods.register("periodCountCalendarDays")}
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
<option value="1">{t("calendar_days")}</option>
@ -1205,7 +1279,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="bufferTime"
className="mt-2.5 flex text-sm font-medium text-neutral-700">
className="text-neutral-700 mt-2.5 flex text-sm font-medium">
{t("buffer_time")}
</label>
</div>
@ -1214,7 +1288,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="w-full">
<label
htmlFor="beforeBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
className="text-neutral-700 mb-2 flex text-sm font-medium">
{t("before_event")}
</label>
<Controller
@ -1253,7 +1327,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="w-full">
<label
htmlFor="afterBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
className="text-neutral-700 mb-2 flex text-sm font-medium">
{t("after_event")}
</label>
<Controller
@ -1298,7 +1372,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="availability"
className="flex text-sm font-medium text-neutral-700">
className="text-neutral-700 flex text-sm font-medium">
{t("availability")}
</label>
</div>
@ -1341,7 +1415,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="payment"
className="mt-2 flex text-sm font-medium text-neutral-700">
className="text-neutral-700 mt-2 flex text-sm font-medium">
{t("payment")}
</label>
</div>
@ -1466,9 +1540,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
href={permalink}
target="_blank"
rel="noreferrer"
className="text-md inline-flex items-center rounded-sm px-2 py-1 text-sm font-medium text-neutral-700 hover:bg-gray-200 hover:text-gray-900">
className="text-md text-neutral-700 inline-flex items-center rounded-sm px-2 py-1 text-sm font-medium hover:bg-gray-200 hover:text-gray-900">
<ExternalLinkIcon
className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2"
className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2"
aria-hidden="true"
/>
{t("preview")}
@ -1480,12 +1554,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}}
type="button"
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
<LinkIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("copy_link")}
</button>
<Dialog>
<DialogTrigger className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-neutral-700 hover:bg-gray-200 hover:text-gray-900">
<TrashIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
<DialogTrigger className="text-md text-neutral-700 flex items-center rounded-sm px-2 py-1 text-sm font-medium hover:bg-gray-200 hover:text-gray-900">
<TrashIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("delete")}
</DialogTrigger>
<ConfirmationDialogContent

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2228.833 2073.333">
<path fill="#5059C9" d="M1554.637,777.5h575.713c54.391,0,98.483,44.092,98.483,98.483c0,0,0,0,0,0v524.398 c0,199.901-162.051,361.952-361.952,361.952h0h-1.711c-199.901,0.028-361.975-162-362.004-361.901c0-0.017,0-0.034,0-0.052V828.971 C1503.167,800.544,1526.211,777.5,1554.637,777.5L1554.637,777.5z"/>
<circle fill="#5059C9" cx="1943.75" cy="440.583" r="233.25"/>
<circle fill="#7B83EB" cx="1218.083" cy="336.917" r="336.917"/>
<path fill="#7B83EB" d="M1667.323,777.5H717.01c-53.743,1.33-96.257,45.931-95.01,99.676v598.105 c-7.505,322.519,247.657,590.16,570.167,598.053c322.51-7.893,577.671-275.534,570.167-598.053V877.176 C1763.579,823.431,1721.066,778.83,1667.323,777.5z"/>
<path opacity=".1" d="M1244,777.5v838.145c-0.258,38.435-23.549,72.964-59.09,87.598 c-11.316,4.787-23.478,7.254-35.765,7.257H667.613c-6.738-17.105-12.958-34.21-18.142-51.833 c-18.144-59.477-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1244z"/>
<path opacity=".2" d="M1192.167,777.5v889.978c-0.002,12.287-2.47,24.449-7.257,35.765 c-14.634,35.541-49.163,58.833-87.598,59.09H691.975c-8.812-17.105-17.105-34.21-24.362-51.833 c-7.257-17.623-12.958-34.21-18.142-51.833c-18.144-59.476-27.402-121.307-27.472-183.49V877.02 c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z"/>
<path opacity=".2" d="M1192.167,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855h-447.84 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1192.167z"/>
<path opacity=".2" d="M1140.333,777.5v786.312c-0.395,52.223-42.632,94.46-94.855,94.855H649.472 c-18.144-59.476-27.402-121.307-27.472-183.49V877.02c-1.246-53.659,41.198-98.19,94.855-99.52H1140.333z"/>
<path opacity=".1" d="M1244,509.522v163.275c-8.812,0.518-17.105,1.037-25.917,1.037 c-8.812,0-17.105-0.518-25.917-1.037c-17.496-1.161-34.848-3.937-51.833-8.293c-104.963-24.857-191.679-98.469-233.25-198.003 c-7.153-16.715-12.706-34.071-16.587-51.833h258.648C1201.449,414.866,1243.801,457.217,1244,509.522z"/>
<path opacity=".2" d="M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z"/>
<path opacity=".2" d="M1192.167,561.355v111.442c-17.496-1.161-34.848-3.937-51.833-8.293 c-104.963-24.857-191.679-98.469-233.25-198.003h190.228C1149.616,466.699,1191.968,509.051,1192.167,561.355z"/>
<path opacity=".2" d="M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z"/>
<linearGradient id="a" gradientUnits="userSpaceOnUse" x1="198.099" y1="1683.0726" x2="942.2344" y2="394.2607" gradientTransform="matrix(1 0 0 -1 0 2075.3333)">
<stop offset="0" stop-color="#5a62c3"/>
<stop offset=".5" stop-color="#4d55bd"/>
<stop offset="1" stop-color="#3940ab"/>
</linearGradient>
<path fill="url(#a)" d="M95.01,466.5h950.312c52.473,0,95.01,42.538,95.01,95.01v950.312c0,52.473-42.538,95.01-95.01,95.01 H95.01c-52.473,0-95.01-42.538-95.01-95.01V561.51C0,509.038,42.538,466.5,95.01,466.5z"/>
<path fill="#FFF" d="M820.211,828.193H630.241v517.297H509.211V828.193H320.123V727.844h500.088V828.193z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -581,6 +581,7 @@
"cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.",
"cal_provide_jitsi_meeting_url": "We will generate a Jitsi Meet URL for you.",
"cal_provide_huddle01_meeting_url": "Cal will provide a Huddle01 web3 video meeting URL.",
"cal_provide_teams_meeting_url": "Cal will provide a MS Teams meeting URL. NOTE: MUST HAVE A WORK OR SCHOOL ACCOUNT",
"require_payment": "Require Payment",
"commission_per_transaction": "commission per transaction",
"event_type_updated_successfully_description": "Your event type has been updated successfully.",

View File

@ -2,6 +2,7 @@ import * as example from "./_example";
import * as dailyvideo from "./dailyvideo";
import * as huddle01video from "./huddle01video";
import * as jitsivideo from "./jitsivideo";
import * as office365video from "./office365video";
import * as zoomvideo from "./zoomvideo";
const appStore = {
@ -10,6 +11,7 @@ const appStore = {
huddle01video,
jitsivideo,
zoomvideo,
office365video,
};
export default appStore;

View File

@ -0,0 +1,4 @@
# Place these on `apps/web/.env`
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
MS_GRAPH_CLIENT_CLIENT_ID=
MS_GRAPH_CLIENT_CLIENT_SECRET=

View File

@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { getSession } from "@calcom/lib/auth";
import { BASE_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { encodeOAuthState } from "../utils";
const scopes = ["OnlineMeetings.ReadWrite"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Check that user is authenticated
const session = await getSession({ req });
if (!session?.user) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const state = encodeOAuthState(req);
const params = {
response_type: "code",
scope: scopes.join(" "),
client_id: process.env.MS_GRAPH_CLIENT_ID,
redirect_uri: BASE_URL + "/api/integrations/office365video/callback",
state,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
res.status(200).json({ url });
}
}

View File

@ -0,0 +1,74 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@calcom/lib/auth";
import { BASE_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../utils";
const scopes = ["OnlineMeetings.ReadWrite"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
console.log("🚀 ~ file: callback.ts ~ line 14 ~ handler ~ code", req.query);
// Check that user is authenticated
const session = await getSession({ req: req });
if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
if (typeof code !== "string") {
res.status(400).json({ message: "No code returned" });
return;
}
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => key + "=" + encodeURIComponent(payload[key]))
.join("&");
const body = toUrlEncoded({
client_id: process.env.MS_GRAPH_CLIENT_ID!,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: BASE_URL + "/api/integrations/office365video/callback",
client_secret: process.env.MS_GRAPH_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) {
return res.redirect("/integrations?error=" + JSON.stringify(responseBody));
}
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;
await prisma.credential.create({
data: {
type: "office365_video",
key: responseBody,
userId: session.user.id,
},
});
const state = decodeOAuthState(req);
return res.redirect(state?.returnTo ?? "/integrations");
}

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";

View File

@ -0,0 +1,26 @@
import type { App } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "Microsoft 365/Teams",
description: _package.description,
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_video",
imageSrc: "apps/msteams.svg",
variant: "conferencing",
logo: "/apps/msteams.svg",
publisher: "Cal.com",
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
verified: true,
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
label: "MS Teams",
slug: "msteams",
title: "MS Teams",
trending: true,
} as App;
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,142 @@
import { Credential } from "@prisma/client";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/CalendarEvent";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
const MS_GRAPH_CLIENT_ID = process.env.MS_GRAPH_CLIENT_ID || "";
const MS_GRAPH_CLIENT_SECRET = process.env.MS_GRAPH_CLIENT_SECRET || "";
/** @link https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings?view=graph-rest-1.0&tabs=http#response */
export interface TeamsEventResult {
creationDateTime: string;
startDateTime: string;
endDateTime: string;
id: string;
joinWebUrl: string;
subject: string;
}
interface O365AuthCredentials {
email: string;
scope: string;
token_type: string;
expiry_date: number;
access_token: string;
refresh_token: string;
ext_expires_in: number;
}
// Checks to see if our O365 user token is valid or if we need to refresh
const o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as unknown as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: MS_GRAPH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: MS_GRAPH_CLIENT_SECRET,
}),
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// set expiry date as offset from current time.
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: responseBody,
},
});
o365AuthCredentials.expiry_date = responseBody.expiry_date;
o365AuthCredentials.access_token = responseBody.access_token;
return o365AuthCredentials.access_token;
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
const TeamsVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
return {
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
};
};
// Since the meeting link is not tied to an event we only need the create and update functions
return {
getAvailability: () => {
return Promise.resolve([]);
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent) => {
const accessToken = await auth.getToken();
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw);
const resultObject = JSON.parse(resultString);
return Promise.resolve({
type: "office365_video",
id: resultObject.id,
password: "",
url: resultObject.joinUrl,
});
},
deleteMeeting: () => {
return Promise.resolve([]);
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
const accessToken = await auth.getToken();
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw);
const resultObject = JSON.parse(resultString);
return Promise.resolve({
type: "office365_video",
id: resultObject.id,
password: "",
url: resultObject.joinUrl,
});
},
};
};
export default TeamsVideoApiAdapter;

View File

@ -0,0 +1,2 @@
export { default as locationOption } from "./locationOption";
export { default as VideoApiAdapter } from "./VideoApiAdapter";

View File

@ -0,0 +1,9 @@
import { LocationType } from "@calcom/lib/location";
const locationOption = {
value: LocationType.Teams,
label: "MS Teams",
disabled: false,
};
export default locationOption;

View File

@ -0,0 +1,14 @@
{
"private": true,
"name": "@calcom/office365video",
"version": "0.0.0",
"main": "./index.ts",
"description": "Use your Office 365 account to book video calls through MS Teams NOTE: MUST HAVE A WORK / SCHOOL ACCOUNT",
"dependencies": {
"@calcom/prisma": "*",
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}

View File

@ -0,0 +1,21 @@
import { NextApiRequest } from "next";
import { IntegrationOAuthCallbackState } from "./types";
export function encodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return JSON.stringify(state);
}
export function decodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return state;
}

View File

@ -4,6 +4,7 @@
"main": "./index.ts",
"dependencies": {
"@calcom/zoomvideo": "*",
"@calcom/dailyvideo": "*"
"@calcom/dailyvideo": "*",
"@calcom/office365video": "*"
}
}

View File

@ -1,4 +1,6 @@
import { compare, hash } from "bcryptjs";
import { Session } from "next-auth";
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
@ -9,3 +11,10 @@ export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}
export async function getSession(options: GetSessionParams): Promise<Session | null> {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}

View File

@ -8,4 +8,5 @@ export enum LocationType {
Jitsi = "integrations:jitsi",
Huddle01 = "integrations:huddle01",
Tandem = "integrations:tandem",
Teams = "integrations:office365_video",
}

View File

@ -2020,12 +2020,12 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@prisma/client@3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.10.0.tgz#4782fe6f1b0e43c2a11a75ad4bb1098599d1dfb1"
integrity sha512-6P4sV7WFuODSfSoSEzCH1qfmWMrCUBk1LIIuTbQf6m1LI/IOpLN4lnqGDmgiBGprEzuWobnGLfe9YsXLn0inrg==
"@prisma/client@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.9.2.tgz#ad17dcfb702842573fe6ec3b7dc4615eff8d8fc6"
integrity sha512-VlEIYVMyfFZHbVBOlunPl47gmP/Z0zzPjPj8I7uKEIaABqrUy50ru3XS0aZd8GFvevVwt7p91xxkUjNjrWhKAQ==
dependencies:
"@prisma/engines-version" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
"@prisma/engines-version" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
"@prisma/debug@3.8.1":
version "3.8.1"
@ -2036,15 +2036,15 @@
ms "2.1.3"
strip-ansi "6.0.1"
"@prisma/engines-version@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#82750856fa637dd89b8f095d2dcc6ac0631231c6"
integrity sha512-cVYs5gyQH/qyut24hUvDznCfPrWiNMKNfPb9WmEoiU6ihlkscIbCfkmuKTtspVLWRdl0LqjYEC7vfnPv17HWhw==
"@prisma/engines-version@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#ea03ffa723382a526dc6625ce6eae9b6ad984400"
integrity sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==
"@prisma/engines@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#2964113729a78b8b21e186b5592affd1fde73c16"
integrity sha512-LjRssaWu9w2SrXitofnutRIyURI7l0veQYIALz7uY4shygM9nMcK3omXcObRm7TAcw3Z+9ytfK1B+ySOsOesxQ==
"@prisma/engines@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#e5c345cdedb7be83d11c1e0c5ab61d866b411256"
integrity sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==
"@prisma/generator-helper@~3.8.1":
version "3.8.1"
@ -11676,12 +11676,12 @@ prism-react-renderer@^1.1.1:
resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz#88fc9d0df6bed06ca2b9097421349f8c2f24e30d"
integrity sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==
prisma@3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.10.0.tgz#872d87afbeb1cbcaa77c3d6a63c125e0d704b04d"
integrity sha512-dAld12vtwdz9Rz01nOjmnXe+vHana5PSog8t0XGgLemKsUVsaupYpr74AHaS3s78SaTS5s2HOghnJF+jn91ZrA==
prisma@3.9.2:
version "3.9.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.9.2.tgz#cc2da4e8db91231dea7465adf9db6e19f11032a9"
integrity sha512-i9eK6cexV74OgeWaH3+e6S07kvC9jEZTl6BqtBH398nlCU0tck7mE9dicY6YQd+euvMjjCtY89q4NgmaPnUsSg==
dependencies:
"@prisma/engines" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
"@prisma/engines" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
process-es6@^0.11.2:
version "0.11.6"
@ -13929,10 +13929,10 @@ ts-morph@^13.0.2:
"@ts-morph/common" "~0.12.3"
code-block-writer "^11.0.0"
ts-node@^10.6.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.6.0.tgz#c3f4195d5173ce3affdc8f2fd2e9a7ac8de5376a"
integrity sha512-CJen6+dfOXolxudBQXnVjRVvYTmTWbyz7cn+xq2XTsvnaXbHqr4gXSCNbS2Jj8yTZMuGwUoBESLaOkLascVVvg==
ts-node@^10.2.1:
version "10.5.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.5.0.tgz#618bef5854c1fbbedf5e31465cbb224a1d524ef9"
integrity sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==
dependencies:
"@cspotcode/source-map-support" "0.7.0"
"@tsconfig/node10" "^1.0.7"