From 8bc209f6d02ac3bc264a9007e6d3ebbfd5fe3b0e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 4 Aug 2021 20:28:35 +0000 Subject: [PATCH 01/11] Attempts to fix conflict with zoom --- pages/api/availability/[user].ts | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index f0c64068..2d1c87ca 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import prisma from "../../../lib/prisma"; -import { getBusyCalendarTimes } from "../../../lib/calendarClient"; -import { getBusyVideoTimes } from "../../../lib/videoClient"; +import prisma from "@lib/prisma"; +import { getBusyCalendarTimes } from "@lib/calendarClient"; +import { getBusyVideoTimes } from "@lib/videoClient"; import dayjs from "dayjs"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -25,39 +25,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - const hasCalendarIntegrations = - currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0; - const hasVideoIntegrations = - currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0; - - const calendarAvailability = await getBusyCalendarTimes( + const calendarBusyTimes = await getBusyCalendarTimes( currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars ); - const videoAvailability = await getBusyVideoTimes( + const videoBusyTimes = await getBusyVideoTimes( currentUser.credentials, req.query.dateFrom, req.query.dateTo ); + calendarBusyTimes.push(...videoBusyTimes); - let commonAvailability = []; - - if (hasCalendarIntegrations && hasVideoIntegrations) { - commonAvailability = calendarAvailability.filter((availability) => - videoAvailability.includes(availability) - ); - } else if (hasVideoIntegrations) { - commonAvailability = videoAvailability; - } else if (hasCalendarIntegrations) { - commonAvailability = calendarAvailability; - } - - commonAvailability = commonAvailability.map((a) => ({ + const bufferedBusyTimes = calendarBusyTimes.map((a) => ({ start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(), })); - res.status(200).json(commonAvailability); + res.status(200).json(bufferedBusyTimes); } From 655b2b18e8272ecbfaaefab6aa8d1d7a8d92c0d4 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 15:51:40 +0200 Subject: [PATCH 02/11] another booking page design revision --- components/booking/DatePicker.tsx | 14 +++---- pages/[user]/[type].tsx | 69 +++++++++++++++++++++---------- tailwind.config.js | 13 ++++++ 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 41a4ab94..39175a94 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -147,14 +147,14 @@ const DatePicker = ({ onClick={() => setSelectedDate(inviteeDate.date(day))} disabled={isDisabled(day)} className={ - "text-center w-10 h-10 mx-auto hover:border hover:border-black dark:hover:border-white" + + "text-center w-14 h-14 mx-auto hover:border hover:border-black dark:hover:border-white" + (isDisabled(day) ? " text-gray-400 font-light hover:border-0 cursor-default" : " dark:text-white text-primary-500 font-medium") + (selectedDate && selectedDate.isSame(inviteeDate.date(day), "day") ? " bg-black text-white-important" : !isDisabled(day) - ? " bg-gray-100 dark:bg-black dark:bg-opacity-30" + ? " bg-gray-100 dark:bg-gray-600" : "") }> {day} @@ -166,12 +166,12 @@ const DatePicker = ({ return selectedMonth ? (
-
+
{dayjs().month(selectedMonth).format("MMMM")} @@ -193,11 +193,11 @@ const DatePicker = ({
-
+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) .map((weekDay) => ( -
+
{weekDay}
))} diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 5c750fc0..21185826 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -128,14 +128,33 @@ export default function Type(props): Type {
-
-
+
+ {/* mobile: details */} +
+
+ +
+

{props.user.name}

+
+ {props.eventType.title} +
+ + {props.eventType.length} minutes +
+
+
+
+

{props.eventType.description}

+
+ +

{props.user.name}

@@ -147,23 +166,7 @@ export default function Type(props): Type { {props.eventType.length} minutes

- - - - {timeZone()} - {isTimeOptionsOpen ? ( - - ) : ( - - )} - - - - - +

{props.eventType.description}

@@ -182,6 +185,11 @@ export default function Type(props): Type { eventLength={props.eventType.length} minimumBookingNotice={props.eventType.minimumBookingNotice} /> + +
+ +
+ {selectedDate && ( ) ); + + function TimezoneDropdown() { + return ( + + + + {timeZone()} + {isTimeOptionsOpen ? ( + + ) : ( + + )} + + + + + + ); + } } export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => { diff --git a/tailwind.config.js b/tailwind.config.js index 69f75f3a..73d36b92 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,6 +5,19 @@ module.exports = { theme: { extend: { colors: { + black: "#111111", + gray: { + 50: "#F8F8F8", + 100: "#F5F5F5", + 200: "#E1E1E1", + 300: "#CFCFCF", + 400: "#ACACAC", + 500: "#888888", + 600: "#494949", + 700: "#3E3E3E", + 800: "#313131", + 900: "#292929", + }, neutral: { 50: "#F7F8F9", 100: "#F4F5F6", From c322690cf535e1f82f17688caceca3289c0009d1 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 15:54:05 +0200 Subject: [PATCH 03/11] applied new styles to available times --- components/booking/AvailableTimes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 4f7a5fd4..6df37b4a 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -39,7 +39,7 @@ const AvailableTimes = ({ `/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") }> - + {slot.format(timeFormat)} From 7b2b75c25c27e614d9da094f075da4fbdf2f5d82 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 15:55:09 +0200 Subject: [PATCH 04/11] wip --- pages/bookings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx index c48d14ad..b835dfb5 100644 --- a/pages/bookings/index.tsx +++ b/pages/bookings/index.tsx @@ -54,7 +54,7 @@ export default function Bookings({ bookings }) { {!booking.confirmed && !booking.rejected && ( - + Unconfirmed )} From 1dc6ae1d26cf59ba1756181ea1eb8d0c483d6741 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 16:29:48 +0200 Subject: [PATCH 05/11] added border to date picker days on mobile --- components/booking/DatePicker.tsx | 10 +++++----- components/booking/TimeOptions.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 39175a94..a9b7bf3e 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -166,9 +166,9 @@ const DatePicker = ({ return selectedMonth ? (
@@ -193,16 +193,16 @@ const DatePicker = ({
-
+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) .map((weekDay) => ( -
+
{weekDay}
))} - {calendar}
+
{calendar}
) : null; }; diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx index 9e51b6ae..4e29bbf1 100644 --- a/components/booking/TimeOptions.tsx +++ b/components/booking/TimeOptions.tsx @@ -25,7 +25,7 @@ const TimeOptions = (props) => { return ( selectedTimeZone !== "" && ( -
+
Time Options
From d682804c5f4f889a4ce668b8a14b3eeba5acdf46 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 16:35:08 +0200 Subject: [PATCH 06/11] wip --- components/booking/DatePicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index a9b7bf3e..f136fd44 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -166,9 +166,9 @@ const DatePicker = ({ return selectedMonth ? (
From 99e003153ecb0141bf75b4e291aa56effc3c7cd0 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 19:05:46 +0200 Subject: [PATCH 07/11] moved og:image outside of isReady scope so it renders on the server --- components/booking/DatePicker.tsx | 4 +- pages/[user].tsx | 100 +++++++++--- pages/[user]/[type].tsx | 243 +++++++++++++++--------------- 3 files changed, 200 insertions(+), 147 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index f136fd44..7c39b591 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -166,9 +166,9 @@ const DatePicker = ({ return selectedMonth ? (
diff --git a/pages/[user].tsx b/pages/[user].tsx index 209b213b..a27c0cd1 100644 --- a/pages/[user].tsx +++ b/pages/[user].tsx @@ -47,33 +47,83 @@ export default function User(props): User {
)); return ( - isReady && ( -
- - {props.user.name || props.user.username} | Calendso - - + <> + + {props.user.name || props.user.username} | Calendso + -
-
- -

- {props.user.name || props.user.username} -

-

{props.user.bio}

-
-
{eventTypes}
- {eventTypes.length == 0 && ( -
-
-

Uh oh!

-

This user hasn't set up any event types yet.

-
+ + + + + + + + ").replace( + /'/g, + "%27" + ) + + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + + encodeURIComponent(props.user.avatar) + } + /> + + + + + + ").replace( + /'/g, + "%27" + ) + + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + + encodeURIComponent(props.user.avatar) + } + /> + + {isReady && ( +
+
+
+ +

+ {props.user.name || props.user.username} +

+

{props.user.bio}

- )} -
-
- ) +
{eventTypes}
+ {eventTypes.length == 0 && ( +
+
+

Uh oh!

+

This user hasn't set up any event types yet.

+
+
+ )} +
+
+ )} + ); } diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 21185826..677cabfb 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -78,136 +78,139 @@ export default function Type(props): Type { }; return ( - isReady && ( -
- - - {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "} - | Calendso - - - + <> + + + {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | + Calendso + + + - - - - - " + props.eventType.description - ).replace(/'/g, "%27") + - ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + - encodeURIComponent(props.user.avatar) - } - /> + + + + + " + props.eventType.description + ).replace(/'/g, "%27") + + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + + encodeURIComponent(props.user.avatar) + } + /> - - - - - " + props.eventType.description - ).replace(/'/g, "%27") + - ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + - encodeURIComponent(props.user.avatar) - } - /> - -
-
- {/* mobile: details */} -
-
- -
-

{props.user.name}

-
- {props.eventType.title} -
- - {props.eventType.length} minutes + + + + + " + props.eventType.description + ).replace(/'/g, "%27") + + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + + encodeURIComponent(props.user.avatar) + } + /> + + + {isReady && ( +
+
+
+ {/* mobile: details */} +
+
+ +
+

{props.user.name}

+
+ {props.eventType.title} +
+ + {props.eventType.length} minutes +
-
-

{props.eventType.description}

-
- -
-
- -

{props.user.name}

-

- {props.eventType.title} -

-

- - {props.eventType.length} minutes -

- - - -

{props.eventType.description}

-
- - -
- +

{props.eventType.description}

- {selectedDate && ( - +
+ +

{props.user.name}

+

+ {props.eventType.title} +

+

+ + {props.eventType.length} minutes +

+ + + +

{props.eventType.description}

+
+ - )} + +
+ +
+ + {selectedDate && ( + + )} +
-
- {!props.user.hideBranding && } -
-
- ) + {!props.user.hideBranding && } +
+
+ )} + ); function TimezoneDropdown() { From 7ff55b29e0de6e76e2f78b870cc350caef24e43f Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 12 Aug 2021 19:10:57 +0200 Subject: [PATCH 08/11] minor changes to availability headline in booking page --- components/booking/AvailableTimes.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 6df37b4a..7eadb552 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -28,8 +28,11 @@ const AvailableTimes = ({ return (
-
- {date.format("dddd DD MMMM YYYY")} +
+ + {date.format("dddd")} + {date.format(", DD MMMM")} +
{slots.length > 0 && slots.map((slot) => ( From 8e2e798572007a6479b562e7f61de2bc435c595f Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Sat, 14 Aug 2021 14:19:57 +0200 Subject: [PATCH 09/11] wip --- components/booking/DatePicker.tsx | 42 ++++++++++++++++++------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 7c39b591..65ae700b 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -142,23 +142,29 @@ const DatePicker = ({ setCalendar([ ...emptyDays, ...days.map((day) => ( - + style={{ + paddingTop: "100%", + }} + className="w-full relative"> + +
)), ]); }, [selectedMonth, inviteeTimeZone, selectedDate]); @@ -193,7 +199,7 @@ const DatePicker = ({
-
+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) .map((weekDay) => ( @@ -202,7 +208,7 @@ const DatePicker = ({
))}
-
{calendar}
+
{calendar}
) : null; }; From 21c709ee46afc061aada994a52ed839a34bd9564 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Sat, 14 Aug 2021 14:26:33 +0200 Subject: [PATCH 10/11] fixed booking layout for tablet --- components/booking/DatePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 65ae700b..e4bd374a 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -175,7 +175,7 @@ const DatePicker = ({ "mt-8 sm:mt-0 sm:min-w-[455px] " + (selectedDate ? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 " - : "sm:w-1/2 sm:pl-4") + : "w-full sm:pl-4") }>
From 65366b7c5bf4e49d57019497f351a59df111f100 Mon Sep 17 00:00:00 2001 From: Femi Odugbesan Date: Sat, 14 Aug 2021 20:53:59 -0500 Subject: [PATCH 11/11] 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"