feat: outlook 365 connect atom (#15318)

* custom for office 365 connect atom

* office 365 connect atom

* add office 365 atom to package exports

* fixup

* fixup

* fixup fixup

* refactors

* chore: refactor connect atoms

* fixup

* chore: refactor connect, enable custom redir

* fixup! Merge branch 'outlook-365-calendar-frontend' of github.com:calcom/cal.com into outlook-365-calendar-frontend

* fixup! fixup! Merge branch 'outlook-365-calendar-frontend' of github.com:calcom/cal.com into outlook-365-calendar-frontend

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2024-06-06 15:08:38 +00:00
committed by GitHub
co-authored by Rajiv Sahal Morgan Vernay Morgan
parent 50eeb712e3
commit 7d4e8e2210
17 changed files with 285 additions and 195 deletions
@@ -71,6 +71,7 @@ export class CalendarsController {
}
@Get("/")
@UseGuards(AccessTokenGuard)
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
const calendars = await this.calendarsService.getCalendars(userId);
@@ -86,13 +87,14 @@ export class CalendarsController {
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string
@Param("calendar") calendar: string,
@Query("redir") redir?: string | null
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.connect(authorization, req);
return await this.outlookService.connect(authorization, req, redir ?? "");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.connect(authorization, req);
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -111,15 +113,18 @@ export class CalendarsController {
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
const { accessToken, origin, redir } = z
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
});
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin);
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(code, accessToken, origin);
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -37,31 +37,32 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request
req: Request,
redir?: string
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
});
return authUrl;
@@ -106,11 +107,16 @@ export class GoogleCalendarService implements OAuthCalendarApp {
return { status: SUCCESS_STATUS };
}
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
async saveCalendarCredentialsAndRedirect(
code: string,
accessToken: string,
origin: string,
redir?: string
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
@@ -151,6 +157,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
);
}
return { url: origin };
return { url: redir || origin };
}
}
@@ -11,13 +11,16 @@ import { Request } from "express";
import { stringify } from "querystring";
import { z } from "zod";
import { OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
OFFICE_365_CALENDAR,
OFFICE_365_CALENDAR_ID,
OFFICE_365_CALENDAR_TYPE,
} from "@calcom/platform-constants";
@Injectable()
export class OutlookService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/calendars/office365/save`;
private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
constructor(
private readonly config: ConfigService,
@@ -29,24 +32,25 @@ export class OutlookService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request
req: Request,
redir?: string
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
@@ -56,7 +60,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
};
const query = stringify(params);
@@ -140,10 +144,15 @@ export class OutlookService implements OAuthCalendarApp {
return responseBody as OfficeCalendar;
}
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
async saveCalendarCredentialsAndRedirect(
code: string,
accessToken: string,
origin: string,
redir?: string
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code) {
return { url: origin };
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
@@ -174,7 +183,7 @@ export class OutlookService implements OAuthCalendarApp {
}
return {
url: origin,
url: redir || origin,
};
}
}
+4 -50
View File
@@ -82,56 +82,10 @@ export class GcalController {
@Redirect(undefined, 301)
@HttpCode(HttpStatus.OK)
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
auth: oAuth2Client,
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_TYPE
);
}
return { url: origin };
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
url.searchParams.append("code", code);
url.searchParams.append("state", state);
return { url: url.href };
}
@Get("/check")
@@ -0,0 +1,69 @@
import type { FC } from "react";
import type { CALENDARS } from "@calcom/platform-constants";
import { Button } from "@calcom/ui";
import type { OnCheckErrorType } from "../hooks/connect/useCheck";
import { useCheck } from "../hooks/connect/useCheck";
import { useConnect } from "../hooks/connect/useConnect";
import { useAtomsContext } from "../hooks/useAtomsContext";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
import { cn } from "../src/lib/utils";
export type OAuthConnectProps = {
className?: string;
label: string;
alreadyConnectedLabel: string;
loadingLabel: string;
onCheckError?: OnCheckErrorType;
redir?: string;
};
export const OAuthConnect: FC<OAuthConnectProps & { calendar: (typeof CALENDARS)[number] }> = ({
label,
alreadyConnectedLabel,
loadingLabel,
className,
onCheckError,
calendar,
redir,
}) => {
const { isAuth } = useAtomsContext();
const { connect } = useConnect(calendar, redir);
const { allowConnect, checked } = useCheck({
isAuth,
onCheckError,
calendar: calendar,
});
const isChecking = !isAuth || !checked;
const isDisabled = isChecking || !allowConnect;
let displayedLabel = label;
if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}
return (
<AtomsWrapper>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn(
"",
className,
isChecking && "animate-pulse",
isDisabled && "cursor-not-allowed",
!isDisabled && "cursor-pointer"
)}
onClick={() => connect()}>
{displayedLabel}
</Button>
</AtomsWrapper>
);
};
@@ -0,0 +1,27 @@
import type { FC } from "react";
import { GOOGLE_CALENDAR } from "@calcom/platform-constants";
import type { OAuthConnectProps } from "../OAuthConnect";
import { OAuthConnect } from "../OAuthConnect";
export const GcalConnect: FC<Partial<OAuthConnectProps>> = ({
label = "Connect Google Calendar",
alreadyConnectedLabel = "Connected Google Calendar",
loadingLabel = "Checking Google Calendar",
className,
onCheckError,
redir,
}) => {
return (
<OAuthConnect
label={label}
alreadyConnectedLabel={alreadyConnectedLabel}
loadingLabel={loadingLabel}
calendar={GOOGLE_CALENDAR}
className={className}
onCheckError={onCheckError}
redir={redir}
/>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
@@ -0,0 +1,27 @@
import type { FC } from "react";
import { OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import type { OAuthConnectProps } from "../OAuthConnect";
import { OAuthConnect } from "../OAuthConnect";
export const OutlookConnect: FC<Partial<OAuthConnectProps>> = ({
label = "Connect Outlook Calendar",
alreadyConnectedLabel = "Connected Outlook Calendar",
loadingLabel = "Checking Outlook Calendar",
className,
onCheckError,
redir,
}) => {
return (
<OAuthConnect
label={label}
alreadyConnectedLabel={alreadyConnectedLabel}
loadingLabel={loadingLabel}
calendar={OFFICE_365_CALENDAR}
className={className}
onCheckError={onCheckError}
redir={redir}
/>
);
};
@@ -1,65 +0,0 @@
import type { FC } from "react";
import { Button } from "@calcom/ui";
import { useAtomsContext } from "../hooks/useAtomsContext";
import type { OnCheckErrorType } from "../hooks/useGcal";
import { useGcal } from "../hooks/useGcal";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
import { cn } from "../src/lib/utils";
interface GcalConnectProps {
className?: string;
label?: string;
alreadyConnectedLabel?: string;
onCheckError?: OnCheckErrorType;
}
/**
* Renders a button to connect or disconnect the Google Calendar of a user.
* @requires AccessToken - The user must be authenticated with an access token passed to CalProvider.
* @component
* @example
* ```tsx
* <GcalConnect
* label="Connect Google Calendar"
* alreadyConnectedLabel="Connected Google Calendar"
* className="my-button"
* />
* ```
*
*
* @param {string} [label="Connect Google Calendar"] - The label for the connect button. Optional.
* @param {string} [alreadyConnectedLabel="Connected Google Calendar"] - The label for the already connected button. Optional.
* @param {string} [className] - Additional CSS class name for the button. Optional.
* @param {OnCheckErrorType} [onCheckError] - A callback function to handle errors when checking the connection status. Optional.
* @returns {JSX.Element} The rendered component.
*/
export const GcalConnect: FC<GcalConnectProps> = ({
label = "Connect Google Calendar",
alreadyConnectedLabel = "Connected Google Calendar",
className,
onCheckError,
}) => {
const { isAuth } = useAtomsContext();
const { allowConnect, checked, redirectToGcalOAuth } = useGcal({
isAuth,
onCheckError,
});
if (!isAuth || !checked) return <></>;
return (
<AtomsWrapper>
<Button
StartIcon="calendar-days"
color="primary"
disabled={!allowConnect}
className={cn("", className)}
onClick={() => redirectToGcalOAuth()}>
{allowConnect ? label : alreadyConnectedLabel}
</Button>
</AtomsWrapper>
);
};
@@ -1 +0,0 @@
export { GcalConnect } from "./GcalConnect";
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export interface UseCheckProps {
isAuth: boolean;
onCheckError?: OnCheckErrorType;
calendar: (typeof CALENDARS)[number];
}
export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
export const useCheck = ({ isAuth, onCheckError, calendar }: UseCheckProps) => {
const { data: check } = useQuery({
queryKey: getQueryKey(calendar),
staleTime: 6000,
enabled: isAuth,
queryFn: () => {
return http
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/calendars/${calendar}/check`)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } };
}
onCheckError?.(responseBody);
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
})
.catch((err) => {
onCheckError?.(err);
return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
});
},
});
return { allowConnect: check?.data?.allowConnect ?? false, checked: check?.data?.checked ?? false };
};
@@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-redirect-uri`];
export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const authUrl = useQuery({
queryKey: getQueryKey(calendar),
staleTime: Infinity,
enabled: false,
queryFn: () => {
return http
?.get<ApiResponse<{ authUrl: string }>>(
`/calendars/${calendar}/connect${redir ? `?redir=${redir}` : ""}`
)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return responseBody.data.authUrl;
}
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
return "";
});
},
});
return authUrl;
};
export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const { refetch } = useGetRedirectUrl(calendar, redir);
const connect = async () => {
const redirectUri = await refetch();
if (redirectUri.data) {
window.location.href = redirectUri.data;
}
};
return { connect };
};
-42
View File
@@ -1,42 +0,0 @@
import { useState, useEffect } from "react";
import type { ApiErrorResponse } from "@calcom/platform-types";
import http from "../lib/http";
export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export interface useGcalProps {
isAuth: boolean;
onCheckError?: OnCheckErrorType;
}
export const useGcal = ({ isAuth, onCheckError }: useGcalProps) => {
const [allowConnect, setAllowConnect] = useState<boolean>(false);
const [checked, setChecked] = useState<boolean>(false);
const redirectToGcalOAuth = () => {
http
?.get("/gcal/oauth/auth-url")
.then(({ data: responseBody }) => {
if (responseBody.data?.authUrl) {
window.location.href = responseBody.data.authUrl;
}
})
.catch(console.error);
};
useEffect(() => {
if (isAuth) {
http
?.get("/gcal/check")
.then(() => setAllowConnect(false))
.catch((err) => {
setAllowConnect(true);
onCheckError?.(err as ApiErrorResponse);
})
.finally(() => setChecked(true));
}
}, [isAuth]);
return { allowConnect, checked, redirectToGcalOAuth };
};
+3 -1
View File
@@ -1,5 +1,5 @@
export { CalProvider } from "./cal-provider";
export { GcalConnect } from "./gcal-connect";
export { GcalConnect } from "./connect/google/GcalConnect";
export { AvailabilitySettingsPlatformWrapper as AvailabilitySettings } from "./availability";
export { BookerPlatformWrapper as Booker } from "./booker";
export { useIsPlatform } from "./hooks/useIsPlatform";
@@ -12,3 +12,5 @@ export { useCancelBooking } from "./hooks/useCancelBooking";
export { useGetBooking } from "./hooks/useGetBooking";
export { useGetBookings } from "./hooks/useGetBookings";
export { useMe } from "./hooks/useMe";
export { OutlookConnect } from "./connect/outlook/OutlookConnect";
export * as Connect from "./connect";
+3 -1
View File
@@ -17,6 +17,7 @@
"@types/react": "18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^2.2.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"postcss-import": "^16.1.0",
@@ -25,7 +26,8 @@
"rollup-plugin-node-builtins": "^2.1.2",
"typescript": "^4.9.4",
"vite": "^5.0.10",
"vite-plugin-dts": "^3.7.3"
"vite-plugin-dts": "^3.7.3",
"vite-plugin-inspect": "^0.8.4"
},
"files": [
"dist",
+3 -2
View File
@@ -1,8 +1,9 @@
import react from "@vitejs/plugin-react";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect";
export default defineConfig({
optimizeDeps: {
@@ -16,7 +17,7 @@ export default defineConfig({
"@calcom/platform-utils",
],
},
plugins: [react(), dts({ insertTypesEntry: true })],
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
@@ -1,7 +1,7 @@
import { Navbar } from "@/components/Navbar";
import { Inter, Poppins } from "next/font/google";
import { GcalConnect } from "@calcom/atoms";
import { GcalConnect, Connect } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
@@ -19,7 +19,16 @@ export default function Home(props: { calUsername: string; calEmail: string }) {
<p className={`w-[70%] font-normal ${inter.className} pb-3 text-2xl`}>
To get started, connect your google calendar.
</p>
<GcalConnect className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700" />
<div className="flex flex-row gap-4">
<GcalConnect
redir="http://localhost:4321/calendars"
className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700"
/>
<Connect.OutlookCalendar
redir="http://localhost:4321/calendars"
className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700"
/>
</div>
</div>
<div className="hidden lg:block">
<img