Skip to content

Commit

Permalink
feat: outlook 365 connect atom (#15318)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
4 people committed Jun 6, 2024
1 parent 50eeb71 commit 7d4e8e2
Show file tree
Hide file tree
Showing 17 changed files with 285 additions and 195 deletions.
23 changes: 14 additions & 9 deletions apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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: ",
Expand All @@ -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: ",
Expand Down
24 changes: 15 additions & 9 deletions apps/api/v2/src/ee/calendars/services/gcal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -151,6 +157,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
);
}

return { url: origin };
return { url: redir || origin };
}
}
35 changes: 22 additions & 13 deletions apps/api/v2/src/ee/calendars/services/outlook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"];
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -174,7 +183,7 @@ export class OutlookService implements OAuthCalendarApp {
}

return {
url: origin,
url: redir || origin,
};
}
}
54 changes: 4 additions & 50 deletions apps/api/v2/src/ee/gcal/gcal.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions packages/platform/atoms/connect/OAuthConnect.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
27 changes: 27 additions & 0 deletions packages/platform/atoms/connect/google/GcalConnect.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/platform/atoms/connect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
27 changes: 27 additions & 0 deletions packages/platform/atoms/connect/outlook/OutlookConnect.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
);
};

0 comments on commit 7d4e8e2

Please sign in to comment.