feat: apple calendar connect atom (#15510)

* init apple calendar service

* update apple calendar service logic

* update typing for CalendarApp interface

* add id and type for apple calendar

* fixup: logic for apple calendar service

* update logic for checkIfCalendarConnected function

* add apple calendar service to calendars module

* fixup

* update calendars to include cases for apple calendar

* fix imports

* init frontend for apple calendar connect

* add apple connect atom to connect atom

* fixup

* fixup

* add alias for latest version of platform library

* fix import paths

* custom hook for saving apple calendar credentials

* add apple calendar to examples app

* add interface for credentials syncing calendars

* bring calendars controller to orginal state

* refactor

* update custom hook to sync credentials

* update atom

* resolve merge conflicts

* add endpoint for syncing calendar credentials

* fixup

* update atom

* update useCheck to invalidate queries onSubmit

* fix typo

* fixup

* remove unused tokens respository

* rename controller

* cleanup

* fix: reset react queryClient on access token change

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
Rajiv Sahal
2024-06-27 15:51:27 +00:00
committed by GitHub
co-authored by Rajiv Sahal Morgan Morgan Vernay
parent 0e0dfcdd88
commit 5e382ffd21
12 changed files with 353 additions and 11 deletions
+1
View File
@@ -28,6 +28,7 @@
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -7,6 +7,11 @@ export interface CalendarApp {
check(userId: number): Promise<ApiResponse>;
}
export interface CredentialSyncCalendarApp {
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,4 +1,5 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
CalendarsService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
@@ -1,5 +1,6 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -21,13 +22,22 @@ import {
Headers,
Redirect,
BadRequestException,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { z } from "zod";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
CALENDARS,
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
@@ -39,7 +49,8 @@ export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService
) {}
@UseGuards(ApiAuthGuard)
@@ -133,6 +144,26 @@ export class CalendarsController {
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
): Promise<{ status: string }> {
const { username, password } = body;
switch (calendar) {
case APPLE_CALENDAR:
return await this.appleCalendarService.save(user.id, user.email, username, password);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@@ -143,6 +174,8 @@ export class CalendarsController {
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
case APPLE_CALENDAR:
return await this.appleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -0,0 +1,92 @@
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.14";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}
async save(
userId: number,
userEmail: string,
username: string,
password: string
): Promise<{ status: string }> {
return await this.saveCalendarCredentials(userId, userEmail, username, password);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
if (!appleCalendarCredentials) {
throw new BadRequestException("Credentials for apple calendar not found.");
}
if (appleCalendarCredentials.invalid) {
throw new BadRequestException("Invalid apple calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const appleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
);
if (!appleCalendar) {
throw new UnauthorizedException("Apple calendar not connected.");
}
if (appleCalendar.error?.message) {
throw new UnauthorizedException(appleCalendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -46,6 +46,12 @@ export function CalProvider({
http.setVersionHeader(version);
}, [version]);
useEffect(() => {
if (accessToken) {
queryClient.resetQueries();
}
}, [accessToken]);
return (
<QueryClientProvider client={queryClient}>
<BaseCalProvider
@@ -0,0 +1,141 @@
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
} from "@/components/ui/dialog";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button, Form, PasswordField, TextField } from "@calcom/ui";
import { SUCCESS_STATUS } from "../../../constants/api";
import { useCheck } from "../../hooks/connect/useCheck";
import { useSaveCalendarCredentials } from "../../hooks/connect/useConnect";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import { cn } from "../../src/lib/utils";
import type { OAuthConnectProps } from "../OAuthConnect";
export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
label = "Connect Apple Calendar",
alreadyConnectedLabel = "Connected Apple Calendar",
loadingLabel = "Checking Apple Calendar",
className,
}) => {
const form = useForm({
defaultValues: {
username: "",
password: "",
},
});
const { toast } = useToast();
const { allowConnect, checked, refetch } = useCheck({
calendar: "apple",
});
const [isDialogOpen, setIsDialogOpen] = useState(false);
let displayedLabel = label;
const { mutate: saveCredentials, isPending: isSaving } = useSaveCalendarCredentials({
onSuccess: (res) => {
if (res.status === SUCCESS_STATUS) {
form.reset();
setIsDialogOpen(false);
refetch();
toast({
description: "Calendar credentials added successfully",
});
}
},
onError: (err) => {
toast({
description: `Error: ${err}`,
});
},
});
const isChecking = !checked;
const isDisabled = isChecking || !allowConnect;
if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}
return (
<AtomsWrapper>
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
onClick={() => setIsDialogOpen(true)}>
{displayedLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect to Apple Server</DialogTitle>
<DialogDescription>
Generate an app specific password to use with Cal.com at{" "}
<span className="font-bold">https://appleid.apple.com/account/manage</span>. Your credentials
will be stored and encrypted.
</DialogDescription>
</DialogHeader>
<Form
form={form}
handleSubmit={async (values) => {
const { username, password } = values;
await saveCredentials({ calendar: "apple", username, password });
}}>
<fieldset
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
required
type="text"
{...form.register("username")}
label="Apple ID"
placeholder="appleid@domain.com"
data-testid="apple-calendar-email"
/>
<PasswordField
required
{...form.register("password")}
label="Password"
placeholder="•••••••••••••"
autoComplete="password"
data-testid="apple-calendar-password"
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button
disabled={isSaving}
type="button"
color="secondary"
onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
disabled={isSaving}
type="submit"
loading={form.formState.isSubmitting}
data-testid="apple-calendar-login-button">
Save
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</AtomsWrapper>
);
};
+1
View File
@@ -1,2 +1,3 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
export { AppleConnect as AppleCalendar } from "./apple/AppleConnect";
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { CALENDARS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -15,11 +15,13 @@ export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
export const useCheck = ({ onCheckError, calendar }: UseCheckProps) => {
const { isInit } = useAtomsContext();
const { data: check } = useQuery({
const { isInit, accessToken } = useAtomsContext();
const queryClient = useQueryClient();
const { data: check, refetch } = useQuery({
queryKey: getQueryKey(calendar),
staleTime: 6000,
enabled: isInit,
enabled: isInit && !!accessToken,
queryFn: () => {
return http
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/calendars/${calendar}/check`)
@@ -36,5 +38,15 @@ export const useCheck = ({ onCheckError, calendar }: UseCheckProps) => {
});
},
});
return { allowConnect: check?.data?.allowConnect ?? false, checked: check?.data?.checked ?? false };
return {
allowConnect: check?.data?.allowConnect ?? false,
checked: check?.data?.checked ?? false,
refetch: () => {
queryClient.setQueryData(getQueryKey(calendar), {
status: SUCCESS_STATUS,
data: { allowConnect: false, checked: false },
});
refetch();
},
};
};
@@ -1,13 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } 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 type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-redirect-uri`];
interface IPUpdateOAuthCredentials {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse) => void;
}
export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const authUrl = useQuery({
queryKey: getQueryKey(calendar),
@@ -44,3 +49,44 @@ export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string)
return { connect };
};
export const useSaveCalendarCredentials = (
{ onSuccess, onError }: IPUpdateOAuthCredentials = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<
ApiResponse<{ status: string }>,
unknown,
{ username: string; password: string; calendar: (typeof CALENDARS)[number] }
>({
mutationFn: (data) => {
const { calendar, username, password } = data;
const body = {
username,
password,
};
return http.post(`/calendars/${calendar}/credentials`, body).then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return mutation;
};
+4 -2
View File
@@ -5,10 +5,12 @@ export const OFFICE_365_CALENDAR_ID = "office365-calendar";
export const GOOGLE_CALENDAR = "google";
export const OFFICE_365_CALENDAR = "office365";
export const APPLE_CALENDAR = "apple";
// APPLE_CALENDAR is not implemented yet
export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR] as const;
export const APPLE_CALENDAR_TYPE = "apple_calendar";
export const APPLE_CALENDAR_ID = "apple-calendar";
export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR, APPLE_CALENDAR] as const;
export const APPS_TYPE_ID_MAPPING = {
[GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID,
[OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID,
[APPLE_CALENDAR_TYPE]: APPLE_CALENDAR_ID,
} as const;
@@ -28,6 +28,7 @@ export default function Home(props: { calUsername: string; calEmail: string }) {
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.AppleCalendar 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">