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/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 4f7a5fd4..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) => ( @@ -39,7 +42,7 @@ const AvailableTimes = ({ `/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") }> - + {slot.format(timeFormat)} diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 41a4ab94..e4bd374a 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]); @@ -166,12 +172,12 @@ const DatePicker = ({ return selectedMonth ? (
-
+
{dayjs().month(selectedMonth).format("MMMM")} @@ -193,16 +199,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
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 d8ea43c7..36ce1b9a 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", @@ -47,6 +48,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/[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 5c750fc0..677cabfb 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -78,129 +78,159 @@ 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) - } - /> - -
-
-
-
- -

{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) + } + /> + - - - - {timeZone()} - {isTimeOptionsOpen ? ( - - ) : ( - - )} - - - - - - -

{props.eventType.description}

+ {isReady && ( +
+
+
+ {/* mobile: details */} +
+
+ +
+

{props.user.name}

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

{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() { + return ( + + + + {timeZone()} + {isTimeOptionsOpen ? ( + + ) : ( + + )} + + + + + + ); + } } export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => { 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); } 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/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 )} 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 00000000..7fb22d84 Binary files /dev/null and b/public/integrations/generic-calendar.png differ diff --git a/public/integrations/generic-calendar@2x.png b/public/integrations/generic-calendar@2x.png new file mode 100644 index 00000000..e96b7616 Binary files /dev/null and b/public/integrations/generic-calendar@2x.png differ diff --git a/public/integrations/generic-calendar@3x.png b/public/integrations/generic-calendar@3x.png new file mode 100644 index 00000000..fadf5603 Binary files /dev/null and b/public/integrations/generic-calendar@3x.png differ 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", diff --git a/yarn.lock b/yarn.lock index bf2dc38b..b0a7d820 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1738,6 +1738,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base-64@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-1.0.0.tgz#09d0f2084e32a3fd08c2475b973788eee6ae8f4a" + integrity sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg== + base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -2268,6 +2273,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +cross-fetch@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" + integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -3414,6 +3426,11 @@ husky@^6.0.0: resolved "https://registry.yarnpkg.com/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e" integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ== +ical.js@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ical.js/-/ical.js-1.4.0.tgz#fc5619dc55fe03d909bf04362aa0677f4541b9d7" + integrity sha512-ltHZuOFNNjcyEYbzDgjemS7LWIFh2vydJeznxQHUh3dnarbxqOYsWONYteBVAq1MEOHnwXFGN2eskZReHclnrA== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -5900,7 +5917,7 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@>=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== @@ -6504,6 +6521,11 @@ tslib@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== + 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" @@ -6903,6 +6925,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"