From 65366b7c5bf4e49d57019497f351a59df111f100 Mon Sep 17 00:00:00 2001 From: Femi Odugbesan Date: Sat, 14 Aug 2021 20:53:59 -0500 Subject: [PATCH] cal-101-caldav-integration (#419) * add generic calendar icon for caldav * module for symmetric encrypt/decrypt * caldav integration * use Radix dialog * Move caldav components to /caldav * remove duplicate cancel button, unused function * ensure app can connect to caldav server before adding * fix calendar clients can possibly return null * fix: add caldav dialog does not close when submitted * safely attempt all caldav operations * clarify variable name, fix typo * use common helper for stripping html * remove usage of request lib until "completed" * add types and usage comments to crypto lib * add encryption key to example env file --- .env.example | 4 + lib/calendarClient.ts | 5 +- lib/crypto.ts | 42 +++ lib/integrations.ts | 2 + .../CalDav/CalDavCalendarAdapter.ts | 313 ++++++++++++++++++ .../components/AddCalDavIntegration.tsx | 63 ++++ package.json | 2 + pages/api/integrations/caldav/add.ts | 79 +++++ pages/integrations/index.tsx | 190 +++++++++-- public/integrations/generic-calendar.png | Bin 0 -> 544 bytes public/integrations/generic-calendar@2x.png | Bin 0 -> 1079 bytes public/integrations/generic-calendar@3x.png | Bin 0 -> 1364 bytes yarn.lock | 36 +- 13 files changed, 706 insertions(+), 30 deletions(-) create mode 100644 lib/crypto.ts create mode 100644 lib/integrations/CalDav/CalDavCalendarAdapter.ts create mode 100644 lib/integrations/CalDav/components/AddCalDavIntegration.tsx create mode 100644 pages/api/integrations/caldav/add.ts create mode 100644 public/integrations/generic-calendar.png create mode 100644 public/integrations/generic-calendar@2x.png create mode 100644 public/integrations/generic-calendar@3x.png diff --git a/.env.example b/.env.example index a0006420..d39a44d0 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,7 @@ EMAIL_SERVER_USER='' EMAIL_SERVER_PASSWORD='' # ApiKey for cronjobs CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' + +# Application Key for symmetric encryption and decryption +# must be 32 bytes for AES256 encryption algorithm +CALENDSO_ENCRYPTION_KEY= \ No newline at end of file diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 36aab6f0..8bded410 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -9,6 +9,7 @@ import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); +import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { google } = require("googleapis"); @@ -516,6 +517,8 @@ const calendars = (withCredentials): CalendarApiAdapter[] => return GoogleCalendar(cred); case "office365_calendar": return MicrosoftOffice365Calendar(cred); + case "caldav_calendar": + return new CalDavCalendar(cred); default: return; // unknown credential, could be legacy? In any case, ignore } @@ -531,7 +534,7 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda const listCalendars = (withCredentials) => Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => - results.reduce((acc, calendars) => acc.concat(calendars), []) + results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) ); const createEvent = async ( diff --git a/lib/crypto.ts b/lib/crypto.ts new file mode 100644 index 00000000..dde42b62 --- /dev/null +++ b/lib/crypto.ts @@ -0,0 +1,42 @@ +import crypto from "crypto"; + +const ALGORITHM = "aes256"; +const INPUT_ENCODING = "utf8"; +const OUTPUT_ENCODING = "hex"; +const IV_LENGTH = 16; // AES blocksize + +/** + * + * @param text Value to be encrypted + * @param key Key used to encrypt value must be 32 bytes for AES256 encryption algorithm + * + * @returns Encrypted value using key + */ +export const symmetricEncrypt = function (text: string, key: string) { + const _key = Buffer.from(key, "latin1"); + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, _key, iv); + let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING); + ciphered += cipher.final(OUTPUT_ENCODING); + const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered; + + return ciphertext; +}; + +/** + * + * @param text Value to decrypt + * @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm + */ +export const symmetricDecrypt = function (text: string, key: string) { + const _key = Buffer.from(key, "latin1"); + + const components = text.split(":"); + const iv_from_ciphertext = Buffer.from(components.shift(), OUTPUT_ENCODING); + const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext); + let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING); + deciphered += decipher.final(INPUT_ENCODING); + + return deciphered; +}; diff --git a/lib/integrations.ts b/lib/integrations.ts index 3f71dd2f..c134bf94 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -6,6 +6,8 @@ export function getIntegrationName(name: String) { return "Office 365 Calendar"; case "zoom_video": return "Zoom"; + case "caldav_calendar": + return "CalDav Server"; default: return "Unknown"; } diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts new file mode 100644 index 00000000..9adc675e --- /dev/null +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -0,0 +1,313 @@ +import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient"; +import { symmetricDecrypt } from "@lib/crypto"; +import { + createAccount, + fetchCalendars, + fetchCalendarObjects, + getBasicAuthHeaders, + createCalendarObject, + updateCalendarObject, + deleteCalendarObject, +} from "tsdav"; +import { Credential } from "@prisma/client"; +import ICAL from "ical.js"; +import { createEvent, DurationObject, Attendee, Person } from "ics"; +import dayjs from "dayjs"; +import { v4 as uuidv4 } from "uuid"; +import { stripHtml } from "../../emails/helpers"; + +type EventBusyDate = Record<"start" | "end", Date>; + +export class CalDavCalendar implements CalendarApiAdapter { + private url: string; + private credentials: Record; + private headers: Record; + private readonly integrationName: string = "caldav_calendar"; + + constructor(credential: Credential) { + const decryptedCredential = JSON.parse( + symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY) + ); + const username = decryptedCredential.username; + const url = decryptedCredential.url; + const password = decryptedCredential.password; + + this.url = url; + + this.credentials = { + username, + password, + }; + + this.headers = getBasicAuthHeaders({ + username, + password, + }); + } + + convertDate(date: string): number[] { + return dayjs(date) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)); + } + + getDuration(start: string, end: string): DurationObject { + return { + minutes: dayjs(end).diff(dayjs(start), "minute"), + }; + } + + getAttendees(attendees: Person[]): Attendee[] { + return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); + } + + async createEvent(event: CalendarEvent): Promise> { + try { + const calendars = await this.listCalendars(); + const uid = uuidv4(); + + const { error, value: iCalString } = await createEvent({ + uid, + startInputType: "utc", + start: this.convertDate(event.startTime), + duration: this.getDuration(event.startTime, event.endTime), + title: event.title, + description: stripHtml(event.description), + location: event.location, + organizer: { email: event.organizer.email, name: event.organizer.name }, + attendees: this.getAttendees(event.attendees), + }); + + if (error) { + return null; + } + + await Promise.all( + calendars.map((calendar) => { + return createCalendarObject({ + calendar: { + url: calendar.externalId, + }, + filename: `${uid}.ics`, + iCalString: iCalString, + headers: this.headers, + }); + }) + ); + + return { + uid, + id: uid, + }; + } catch (reason) { + console.error(reason); + } + } + + async updateEvent(uid: string, event: CalendarEvent): Promise> { + try { + const calendars = await this.listCalendars(); + const events = []; + + for (const cal of calendars) { + const calEvents = await this.getEvents(cal.externalId, null, null); + + for (const ev of calEvents) { + events.push(ev); + } + } + + const { error, value: iCalString } = await createEvent({ + uid, + startInputType: "utc", + start: this.convertDate(event.startTime), + duration: this.getDuration(event.startTime, event.endTime), + title: event.title, + description: stripHtml(event.description), + location: event.location, + organizer: { email: event.organizer.email, name: event.organizer.name }, + attendees: this.getAttendees(event.attendees), + }); + + if (error) { + return null; + } + + const eventsToUpdate = events.filter((event) => event.uid === uid); + + await Promise.all( + eventsToUpdate.map((event) => { + return updateCalendarObject({ + calendarObject: { + url: event.url, + data: iCalString, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + + return null; + } catch (reason) { + console.error(reason); + } + } + + async deleteEvent(uid: string): Promise { + try { + const calendars = await this.listCalendars(); + const events = []; + + for (const cal of calendars) { + const calEvents = await this.getEvents(cal.externalId, null, null); + + for (const ev of calEvents) { + events.push(ev); + } + } + + const eventsToUpdate = events.filter((event) => event.uid === uid); + + await Promise.all( + eventsToUpdate.map((event) => { + return deleteCalendarObject({ + calendarObject: { + url: event.url, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + + return null; + } catch (reason) { + console.error(reason); + } + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + try { + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === this.integrationName) + .map((e) => e.externalId); + + const events = []; + + for (const calId of selectedCalendarIds) { + const calEvents = await this.getEvents(calId, dateFrom, dateTo); + + for (const ev of calEvents) { + events.push({ start: ev.startDate, end: ev.endDate }); + } + } + + return events; + } catch (reason) { + console.error(reason); + } + } + + async listCalendars(): Promise { + try { + const account = await this.getAccount(); + const calendars = await fetchCalendars({ + account, + headers: this.headers, + }); + + return calendars + .filter((calendar) => { + return calendar.components.includes("VEVENT"); + }) + .map((calendar) => ({ + externalId: calendar.url, + name: calendar.displayName, + primary: false, + integration: this.integrationName, + })); + } catch (reason) { + console.error(reason); + } + } + + async getEvents(calId: string, dateFrom: string, dateTo: string): Promise { + try { + //TODO: Figure out Time range and filters + console.log(dateFrom, dateTo); + const objects = await fetchCalendarObjects({ + calendar: { + url: calId, + }, + headers: this.headers, + }); + + const events = + objects && + objects?.length > 0 && + objects + .map((object) => { + if (object?.data) { + const jcalData = ICAL.parse(object.data); + const vcalendar = new ICAL.Component(jcalData); + const vevent = vcalendar.getFirstSubcomponent("vevent"); + const event = new ICAL.Event(vevent); + + const startDate = new Date(event.startDate.toUnixTime() * 1000); + const endDate = new Date(event.endDate.toUnixTime() * 1000); + + return { + uid: event.uid, + etag: object.etag, + url: object.url, + summary: event.summary, + description: event.description, + location: event.location, + sequence: event.sequence, + startDate, + endDate, + duration: { + weeks: event.duration.weeks, + days: event.duration.days, + hours: event.duration.hours, + minutes: event.duration.minutes, + seconds: event.duration.seconds, + isNegative: event.duration.isNegative, + }, + organizer: event.organizer, + attendees: event.attendees.map((a) => a.getValues()), + recurrenceId: event.recurrenceId, + timezone: vcalendar.getFirstSubcomponent("vtimezone") + ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid") + : "", + }; + } + }) + .filter((e) => e != null); + + return events; + } catch (reason) { + console.error(reason); + } + } + + private async getAccount() { + const account = await createAccount({ + account: { + serverUrl: `${this.url}`, + accountType: "caldav", + credentials: this.credentials, + }, + headers: this.headers, + }); + + return account; + } +} diff --git a/lib/integrations/CalDav/components/AddCalDavIntegration.tsx b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx new file mode 100644 index 00000000..3510c09e --- /dev/null +++ b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +type Props = { + onSubmit: () => void; +}; + +const AddCalDavIntegration = React.forwardRef((props, ref) => { + const onSubmit = (event) => { + event.preventDefault(); + event.stopPropagation(); + + props.onSubmit(); + }; + + return ( +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ ); +}); + +AddCalDavIntegration.displayName = "AddCalDavIntegrationForm"; +export default AddCalDavIntegration; diff --git a/package.json b/package.json index a25a020f..80d14dbc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dayjs-business-days": "^1.0.4", "googleapis": "^67.1.1", "handlebars": "^4.7.7", + "ical.js": "^1.4.0", "ics": "^2.27.0", "lodash.debounce": "^4.0.8", "lodash.merge": "^4.6.2", @@ -46,6 +47,7 @@ "react-select": "^4.3.0", "react-timezone-select": "^1.0.2", "short-uuid": "^4.2.0", + "tsdav": "^1.0.2", "tslog": "^3.2.0", "uuid": "^8.3.2" }, diff --git a/pages/api/integrations/caldav/add.ts b/pages/api/integrations/caldav/add.ts new file mode 100644 index 00000000..530726f4 --- /dev/null +++ b/pages/api/integrations/caldav/add.ts @@ -0,0 +1,79 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getSession } from "next-auth/client"; +import prisma from "../../../../lib/prisma"; +import { symmetricEncrypt } from "@lib/crypto"; +import logger from "@lib/logger"; +import { davRequest, getBasicAuthHeaders } from "tsdav"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + // Check that user is authenticated + const session = await getSession({ req: req }); + + if (!session) { + res.status(401).json({ message: "You must be logged in to do this" }); + return; + } + + const { username, password, url } = req.body; + // Get user + await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); + + const header = getBasicAuthHeaders({ + username, + password, + }); + + try { + const [response] = await davRequest({ + url: url, + init: { + method: "PROPFIND", + namespace: "d", + body: { + propfind: { + _attributes: { + "xmlns:d": "DAV:", + }, + prop: { "d:current-user-principal": {} }, + }, + }, + headers: header, + }, + }); + + if (!response.ok) { + logger.error("Could not add this caldav account", response?.statusText); + logger.error(response.error); + return res.status(200).json({ message: "Could not add this caldav account" }); + } + + if (response.ok) { + await prisma.credential.create({ + data: { + type: "caldav_calendar", + key: symmetricEncrypt( + JSON.stringify({ username, password, url }), + process.env.CALENDSO_ENCRYPTION_KEY + ), + userId: session.user.id, + }, + }); + } + } catch (reason) { + logger.error("Could not add this caldav account", reason); + return res.status(200).json({ message: "Could not add this caldav account" }); + } + // TODO VALIDATE URL + // TODO VALIDATE CONNECTION IS POSSIBLE + + return res.status(200).json({}); + } +} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 322d8dfc..9b6f6345 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -2,18 +2,36 @@ import Head from "next/head"; import Link from "next/link"; import prisma from "../../lib/prisma"; import Shell from "../../components/Shell"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { getSession, useSession } from "next-auth/client"; import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid"; import { InformationCircleIcon } from "@heroicons/react/outline"; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog"; import Switch from "@components/ui/Switch"; import Loader from "@components/Loader"; +import AddCalDavIntegration from "@lib/integrations/CalDav/components/AddCalDavIntegration"; + +type Integration = { + installed: boolean; + credential: unknown; + type: string; + title: string; + imageSrc: string; + description: string; +}; + +type Props = { + integrations: Integration[]; +}; + +export default function Home({ integrations }: Props) { + const [, loading] = useSession(); -export default function IntegrationHome({ integrations }) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [session, loading] = useSession(); const [selectableCalendars, setSelectableCalendars] = useState([]); + const addCalDavIntegrationRef = useRef(null); + const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); + + useEffect(loadCalendars, [integrations]); function loadCalendars() { fetch("api/availability/calendar") @@ -24,11 +42,32 @@ export default function IntegrationHome({ integrations }) { } function integrationHandler(type) { + if (type === "caldav_calendar") { + setIsAddCalDavIntegrationDialogOpen(true); + return; + } + fetch("/api/integrations/" + type.replace("_", "") + "/add") .then((response) => response.json()) .then((data) => (window.location.href = data.url)); } + const handleAddCalDavIntegration = async ({ url, username, password }) => { + const requestBody = JSON.stringify({ + url, + username, + password, + }); + + await fetch("/api/integrations/caldav/add", { + method: "POST", + body: requestBody, + headers: { + "Content-Type": "application/json", + }, + }); + }; + function calendarSelectionHandler(calendar) { return (selected) => { const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId); @@ -59,6 +98,8 @@ export default function IntegrationHome({ integrations }) { return "integrations/google-calendar.svg"; case "office365_calendar": return "integrations/outlook.svg"; + case "caldav_calendar": + return "integrations/generic-calendar.png"; default: return ""; } @@ -68,12 +109,6 @@ export default function IntegrationHome({ integrations }) { setSelectableCalendars([...selectableCalendars]); } - useEffect(loadCalendars, [integrations]); - - if (loading) { - return ; - } - const ConnectNewAppDialog = () => ( @@ -87,24 +122,35 @@ export default function IntegrationHome({ integrations }) {
    {integrations .filter((integration) => integration.installed) - .map((integration) => ( -
  • -
    - {integration.title} -
    -
    -

    {integration.title}

    -

    {integration.description}

    -
    -
    - -
    -
  • - ))} + .map((integration) => { + return ( +
  • +
    + {integration.title} +
    +
    +

    {integration.title}

    +

    {integration.description}

    +
    +
    + {integration.type === "caldav_calendar" ? ( + + ) : ( + // + + )} +
    +
  • + ); + })}
@@ -160,6 +206,85 @@ export default function IntegrationHome({ integrations }) {
); + function handleAddCalDavIntegrationSaveButtonPress() { + const form = addCalDavIntegrationRef.current.elements; + const url = form.url.value; + const password = form.password.value; + const username = form.username.value; + try { + handleAddCalDavIntegration({ username, password, url }); + } catch (reason) { + console.error(reason); + } + } + + const onSubmit = () => { + const form = addCalDavIntegrationRef.current; + + if (form) { + if (typeof form.requestSubmit === "function") { + form.requestSubmit(); + } else { + form.dispatchEvent(new Event("submit", { cancelable: true })); + } + + setIsAddCalDavIntegrationDialogOpen(false); + } + }; + + const ConnectCalDavServerDialog = ({ isOpen }) => { + return ( + + +
+ + +
+
+
+ +
+
+ +
+

Your credentials will be stored and encrypted.

+
+
+
+
+ +
+ +
+ + + Cancel + +
+
+
+
+
+ ); + }; + + if (loading) { + return ; + } + return (
@@ -262,6 +387,7 @@ export default function IntegrationHome({ integrations }) {
+ ); @@ -329,6 +455,14 @@ export async function getServerSideProps(context) { imageSrc: "integrations/zoom.svg", description: "Video Conferencing", }, + { + installed: true, + type: "caldav_calendar", + credential: credentials.find((integration) => integration.type === "caldav_calendar") || null, + title: "CalDav Server", + imageSrc: "integrations/generic-calendar.png", + description: "For personal and business calendars", + }, ]; return { diff --git a/public/integrations/generic-calendar.png b/public/integrations/generic-calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..7fb22d8426178a31a47fda4ec85fd055435ca0ba GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBxvicqjv*C{Z)Y_I9yZ`{wU1#>XjyZBC6&eO zfDeQ86`v-z2Hrz#ISytGyj{#^A9_2zIuew8y{dfIvcG-nu0?jO=P$L0gWJ@f4i&a9d|jWbfe<>`v=P>e%Q|_zjbr= zpRnvshATqdmI!4O~} z#jwWt_T87?rFa(0otee>qP${TPnE%)^u_?4HtRF$Znjc~{?s|FnzKfJf~t4$f@18NYzh;o%EAg@iMgj{9Ge;F*7T=j?+%I_I93{&WY1 eG!T9H!8RxIhSqzw^=7~bW$<+Mb6Mw<&;$St59k~K literal 0 HcmV?d00001 diff --git a/public/integrations/generic-calendar@2x.png b/public/integrations/generic-calendar@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e96b7616bf74cd02763e52090f940d18f021cb3d GIT binary patch literal 1079 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSoCO|{#S9E$svykh8Km+7D9BhG z<`6I0GDgR5?W_@a1SRXffzo2z#h(nSC|Y|J?60a~ePXV6ptQ=hjwLMMj|6 zKycvJt>DP3r@nn#zwG6!3s=tvAKgFyWS`r%?e(SdCBIMaUOD%T!REjIq09G`pZ30) znD#rHmqE;l;e*1$)!ujQ&jr4|@-%b4&LgKw{;N*y3RsqYYV+GydjnSchWqc@<&|5p zsH8BhTHy7~Tea_BU43~z&~oyD8G2=Pe;?W1T4MKe=Ot6-{{2_K+CDQ{L#)7;q&j&KknH6F$UO%{X9)Cm09R7xO zph`i8=Nt%C5CcAoHEdh0FST!vRovd$oII+{&BjA5{zoq?RHfi4b z4iBRqzu&j@_xo4X!pDkDzRil5@K^AGVUkb>)G60?*f)IjjL4mQ;3&&ff2QqH@oc#- z&j*=so)HWYYv?@||Hn7?g@2exgX(hzOZ`;~_uDz9Dj%46X+KXUP}qd&35OQ9L&>`O z|N3)xi|*F~>P^yOm@#jrC4-g=IADO@o%T<|La+5+X2ne5{1ty352v56cpo1c@KftS zz4wCIRu|?UJ!7=t>W}XIE2sTSab-;4Ye)fF;!q;c-Z1H(ovZSJOOp>=-?g{BZvOgT z#+-}xJ2^g8W>|JyhJhav_8=?x9JFs=f4=_i{#6S$g*qsM+4e`yXerDS$^7~2&(`1R z8&eqiB+u_%2J%6BL&oxtiUscV(-t`&kowO4;F7^R{szvOGg%fnLE`T_d&Iu`YZiR+ zd=U2d^n$m_@8&ySR55T5h-6q3KR^DKy}0pehS%4hPyaSsJj9G?PUq|2&jVngp||e+ z{8=j*5~|XkF{}_^EPy8xq^Q3m@|>CF;F>uMGVF5u1+t2LYLm_>D$INNDRxEd9ru&z zs!v%I{ype-di%(3*4N%$;lE}YUfHzux|37*^iBV{t942>)gD7~9hiLZMBH2F=D{LW QXJruI)78&qol`;+09!Bb>Hq)$ literal 0 HcmV?d00001 diff --git a/public/integrations/generic-calendar@3x.png b/public/integrations/generic-calendar@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..fadf56031c677e64d53ea2e568b12330c4bac77a GIT binary patch literal 1364 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaloCO|{#S9FJ79h;%I?XTvD9BhG z(bb}svz(jEZ`?^p{(PfRa>nx56x*A>>h?>T9;h*^`_-=-y-68p zIvjjksq;1~bG!HM=v%Y->yLSI9#4-_wfnbW{E7I!`i%X0Z`I0P z-Mm!S|G8Y$WbM|qwK;FIXWw|mv*F!^-AirxU*y|L?}@opfANK9&g1PCvEL4;{@JkQ z{j_i8Cbu5(1Wfyuf8gob^v3dw3EjIL)-xZ=*!Sv&62rRRl21c{3WQ$hCCpi7_s8Uy zwC(gIVgbdC=WhRgfBnehgdkxC5IQZ;@N9YOWbfJ(5cH+hF zma5-sYrfsv|80MB;V;GO;k9@EZwO!bxqj7=x_+|03~@D=ZD@ zNN=NDfuY=6&?@W&ROb%JZq5?@(Kl!;oE=`ZvcjexZ$94gM zd~yFT?5aDM%%m!JuW;WpVORI{@tc4D+xlBp=1s26zR!2IuV(q0z3ZF)e2-V_pSnlc zC;qBE6T2ViXd|{4>z`WhIWPAizb~#{gYm_Ct9|#J_g$|3@~b_ary!2A`GMH(_wl>$ zAC%Sj_|bX~|4WPTk3VitabH*Ocl}?gz?plJEY4UQ@6PGK`T1q~y84W*%@8I(P=;Gs$(| z`DTGUFzc4ttO))E`@S+*r-OXmzDdsE@5QhD>%U7ZXmNZ3QT$HGI^s~xubHps2j7kp ze$9Ae_qxBQ;b{|+LLua4Ifq)Q(%K;LWkNmc?4#cQySfH!(YAjXTmL%)wO+}eyZ(MO zFj{OIeys$$%&xbppV3QVN#7c<>NgOZCVq8)b!-0`_JsGB_?s3=Gb)%8hqf-ict5N= zDWN!e4r3nEv5ZLmzxTu#H*A;{IE#71(W0Z`g+1arhV$hYf8=`Nxn+0f<( zpY|=?`?#z$jeiUpDs;!pIa(ja(CkNLNC=XTfS`+-{|-}{%b|5 yXXLHdz)YXcvaL{f`E1?S-3|AF=0.6.0: +sax@>=0.6.0, sax@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -6487,6 +6504,16 @@ ts-pnp@^1.1.6: resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== +tsdav@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tsdav/-/tsdav-1.0.2.tgz#bc30b7c6278054771aabd3d3a13c4c1af013bd88" + integrity sha512-a6HgwzduoZWG3UbSeTeS3d/CQQBzrp9KrDdJ0gTng0whlgaPgV5AlxnCY5gah/GbpprsxBlB8QD41NxVnNTLyQ== + dependencies: + base-64 "^1.0.0" + cross-fetch "^3.1.4" + debug "^4.3.1" + xml-js "^1.6.11" + tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -6885,6 +6912,13 @@ ws@^7.4.5: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.1.tgz#44fc000d87edb1d9c53e51fbc69a0ac1f6871d66" integrity sha512-2c6faOUH/nhoQN6abwMloF7Iyl0ZS2E9HGtsiLrWn0zOOMWlhtDmdf/uihDt6jnuCxgtwGBNy6Onsoy2s2O2Ow== +xml-js@^1.6.11: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"