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
This commit is contained in:
parent
21c709ee46
commit
65366b7c5b
13 changed files with 706 additions and 30 deletions
|
@ -34,3 +34,7 @@ EMAIL_SERVER_USER='<office365_emailAddress>'
|
|||
EMAIL_SERVER_PASSWORD='<office365_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=
|
|
@ -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 (
|
||||
|
|
42
lib/crypto.ts
Normal file
42
lib/crypto.ts
Normal file
|
@ -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;
|
||||
};
|
|
@ -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";
|
||||
}
|
||||
|
|
313
lib/integrations/CalDav/CalDavCalendarAdapter.ts
Normal file
313
lib/integrations/CalDav/CalDavCalendarAdapter.ts
Normal file
|
@ -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<string, string>;
|
||||
private headers: Record<string, string>;
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<void> {
|
||||
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<EventBusyDate[]> {
|
||||
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<IntegrationCalendar[]> {
|
||||
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<unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
63
lib/integrations/CalDav/components/AddCalDavIntegration.tsx
Normal file
63
lib/integrations/CalDav/components/AddCalDavIntegration.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
props.onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<form id="addCalDav" ref={ref} onSubmit={onSubmit}>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Calendar URL
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm flex">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="url"
|
||||
id="url"
|
||||
placeholder="https://example.com/calendar"
|
||||
className="focus:ring-black focus:border-black flex-grow block w-full min-w-0 rounded-none rounded-r-sm sm:text-sm border-gray-300 lowercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
placeholder="rickroll"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="•••••••••••••"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
AddCalDavIntegration.displayName = "AddCalDavIntegrationForm";
|
||||
export default AddCalDavIntegration;
|
|
@ -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"
|
||||
},
|
||||
|
|
79
pages/api/integrations/caldav/add.ts
Normal file
79
pages/api/integrations/caldav/add.ts
Normal file
|
@ -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({});
|
||||
}
|
||||
}
|
|
@ -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<HTMLFormElement>(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 <Loader />;
|
||||
}
|
||||
|
||||
const ConnectNewAppDialog = () => (
|
||||
<Dialog>
|
||||
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
|
@ -87,7 +122,8 @@ export default function IntegrationHome({ integrations }) {
|
|||
<ul className="divide-y divide-gray-200">
|
||||
{integrations
|
||||
.filter((integration) => integration.installed)
|
||||
.map((integration) => (
|
||||
.map((integration) => {
|
||||
return (
|
||||
<li key={integration.type} className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
||||
|
@ -97,14 +133,24 @@ export default function IntegrationHome({ integrations }) {
|
|||
<p className="text-gray-400 text-sm">{integration.description}</p>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-2">
|
||||
{integration.type === "caldav_calendar" ? (
|
||||
<button
|
||||
onClick={() => integrationHandler(integration.type)}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
Add
|
||||
</button>
|
||||
) : (
|
||||
// <ConnectCalDavServerDialog isOpen={isOpen}/>
|
||||
<button
|
||||
onClick={() => integrationHandler(integration.type)}
|
||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
|
@ -160,6 +206,85 @@ export default function IntegrationHome({ integrations }) {
|
|||
</Dialog>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<PlusIcon className="h-6 w-6 text-neutral-900" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Connect to CalDav Server
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Your credentials will be stored and encrypted.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<AddCalDavIntegration
|
||||
ref={addCalDavIntegrationRef}
|
||||
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
|
||||
Save
|
||||
</button>
|
||||
<DialogClose as="button" className="btn btn-white mx-2">
|
||||
Cancel
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
|
@ -262,6 +387,7 @@ export default function IntegrationHome({ integrations }) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectCalDavServerDialog isOpen={isAddCalDavIntegrationDialogOpen} />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
@ -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 {
|
||||
|
|
BIN
public/integrations/generic-calendar.png
Normal file
BIN
public/integrations/generic-calendar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 544 B |
BIN
public/integrations/generic-calendar@2x.png
Normal file
BIN
public/integrations/generic-calendar@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
public/integrations/generic-calendar@3x.png
Normal file
BIN
public/integrations/generic-calendar@3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
36
yarn.lock
36
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"
|
||||
|
@ -5887,7 +5904,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==
|
||||
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue