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:
co-authored by
Rajiv Sahal
Morgan
Morgan Vernay
parent
0e0dfcdd88
commit
5e382ffd21
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user