diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 57a99e301e7602..a779e9d0140811 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/apps/api/v2/src/ee/calendars/calendars.interface.ts b/apps/api/v2/src/ee/calendars/calendars.interface.ts index 4ea4ec3b69218e..9767e7ee814216 100644 --- a/apps/api/v2/src/ee/calendars/calendars.interface.ts +++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts @@ -7,6 +7,11 @@ export interface CalendarApp { check(userId: number): Promise; } +export interface CredentialSyncCalendarApp { + save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>; + check(userId: number): Promise; +} + export interface OAuthCalendarApp extends CalendarApp { connect(authorization: string, req: Request): Promise>; } diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts index 17e39fc9b2e381..93b37b6f2bd854 100644 --- a/apps/api/v2/src/ee/calendars/calendars.module.ts +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -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, ], diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts index b6dfe978446a79..79dde7b9f80ab9 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -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: ", diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts new file mode 100644 index 00000000000000..e95c64a7af0861 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -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, + }; + } +} diff --git a/packages/platform/atoms/cal-provider/CalProvider.tsx b/packages/platform/atoms/cal-provider/CalProvider.tsx index 6ec7e7b8879e8b..581bfef67bbf71 100644 --- a/packages/platform/atoms/cal-provider/CalProvider.tsx +++ b/packages/platform/atoms/cal-provider/CalProvider.tsx @@ -46,6 +46,12 @@ export function CalProvider({ http.setVersionHeader(version); }, [version]); + useEffect(() => { + if (accessToken) { + queryClient.resetQueries(); + } + }, [accessToken]); + return ( >> = ({ + 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 ( + + + + + + + + Connect to Apple Server + + Generate an app specific password to use with Cal.com at{" "} + https://appleid.apple.com/account/manage. Your credentials + will be stored and encrypted. + + +
{ + const { username, password } = values; + + await saveCredentials({ calendar: "apple", username, password }); + }}> +
+ + +
+
+ + +
+
+
+
+
+ ); +}; diff --git a/packages/platform/atoms/connect/index.ts b/packages/platform/atoms/connect/index.ts index c8d90797249e8a..586c664f2937e0 100644 --- a/packages/platform/atoms/connect/index.ts +++ b/packages/platform/atoms/connect/index.ts @@ -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"; diff --git a/packages/platform/atoms/hooks/connect/useCheck.ts b/packages/platform/atoms/hooks/connect/useCheck.ts index 73333b6ad1e548..7d5163a86360d8 100644 --- a/packages/platform/atoms/hooks/connect/useCheck.ts +++ b/packages/platform/atoms/hooks/connect/useCheck.ts @@ -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>(`/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(); + }, + }; }; diff --git a/packages/platform/atoms/hooks/connect/useConnect.ts b/packages/platform/atoms/hooks/connect/useConnect.ts index 0c8bea3e40a405..4b52b82f07ac85 100644 --- a/packages/platform/atoms/hooks/connect/useConnect.ts +++ b/packages/platform/atoms/hooks/connect/useConnect.ts @@ -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; +}; diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts index 41675b29b4a162..8872c142c9bf92 100644 --- a/packages/platform/constants/apps.ts +++ b/packages/platform/constants/apps.ts @@ -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; diff --git a/packages/platform/examples/base/src/pages/index.tsx b/packages/platform/examples/base/src/pages/index.tsx index 6be5d5266cacb7..1fc93a34c00653 100644 --- a/packages/platform/examples/base/src/pages/index.tsx +++ b/packages/platform/examples/base/src/pages/index.tsx @@ -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" /> +