Merge branch 'main' into main
This commit is contained in:
commit
610ea6c9ef
21 changed files with 979 additions and 222 deletions
|
@ -34,3 +34,7 @@ EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||||
# ApiKey for cronjobs
|
# ApiKey for cronjobs
|
||||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||||
|
|
||||||
|
# Application Key for symmetric encryption and decryption
|
||||||
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
|
CALENDSO_ENCRYPTION_KEY=
|
|
@ -28,8 +28,11 @@ const AvailableTimes = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||||
<div className="text-gray-600 font-light text-xl mb-4 text-left">
|
<div className="text-gray-600 font-light text-lg mb-4 text-left">
|
||||||
<span className="w-1/2 dark:text-white text-gray-600">{date.format("dddd DD MMMM YYYY")}</span>
|
<span className="w-1/2 dark:text-white text-gray-600">
|
||||||
|
<strong>{date.format("dddd")}</strong>
|
||||||
|
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{slots.length > 0 &&
|
{slots.length > 0 &&
|
||||||
slots.map((slot) => (
|
slots.map((slot) => (
|
||||||
|
@ -39,7 +42,7 @@ const AvailableTimes = ({
|
||||||
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
|
`/${user.username}/book?date=${slot.utc().format()}&type=${eventTypeId}` +
|
||||||
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
|
(rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")
|
||||||
}>
|
}>
|
||||||
<a className="block font-medium mb-4 bg-white dark:bg-neutral-900 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-black rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
|
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
|
||||||
{slot.format(timeFormat)}
|
{slot.format(timeFormat)}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -142,23 +142,29 @@ const DatePicker = ({
|
||||||
setCalendar([
|
setCalendar([
|
||||||
...emptyDays,
|
...emptyDays,
|
||||||
...days.map((day) => (
|
...days.map((day) => (
|
||||||
<button
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
style={{
|
||||||
disabled={isDisabled(day)}
|
paddingTop: "100%",
|
||||||
className={
|
}}
|
||||||
"text-center w-10 h-10 mx-auto hover:border hover:border-black dark:hover:border-white" +
|
className="w-full relative">
|
||||||
(isDisabled(day)
|
<button
|
||||||
? " text-gray-400 font-light hover:border-0 cursor-default"
|
onClick={() => setSelectedDate(inviteeDate.date(day))}
|
||||||
: " dark:text-white text-primary-500 font-medium") +
|
disabled={isDisabled(day)}
|
||||||
(selectedDate && selectedDate.isSame(inviteeDate.date(day), "day")
|
className={
|
||||||
? " bg-black text-white-important"
|
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto hover:border hover:border-black dark:hover:border-white" +
|
||||||
: !isDisabled(day)
|
(isDisabled(day)
|
||||||
? " bg-gray-100 dark:bg-black dark:bg-opacity-30"
|
? " 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")
|
||||||
{day}
|
? " bg-black text-white-important"
|
||||||
</button>
|
: !isDisabled(day)
|
||||||
|
? " bg-gray-100 dark:bg-gray-600"
|
||||||
|
: "")
|
||||||
|
}>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)),
|
)),
|
||||||
]);
|
]);
|
||||||
}, [selectedMonth, inviteeTimeZone, selectedDate]);
|
}, [selectedMonth, inviteeTimeZone, selectedDate]);
|
||||||
|
@ -166,12 +172,12 @@ const DatePicker = ({
|
||||||
return selectedMonth ? (
|
return selectedMonth ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"mt-8 sm:mt-0 min-w-[350px] " +
|
"mt-8 sm:mt-0 sm:min-w-[455px] " +
|
||||||
(selectedDate
|
(selectedDate
|
||||||
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-black sm:pl-4 sm:pr-6"
|
? "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")
|
||||||
}>
|
}>
|
||||||
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
|
<div className="flex text-gray-600 font-light text-xl mb-4">
|
||||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||||
<strong className="text-gray-900 dark:text-white">
|
<strong className="text-gray-900 dark:text-white">
|
||||||
{dayjs().month(selectedMonth).format("MMMM")}
|
{dayjs().month(selectedMonth).format("MMMM")}
|
||||||
|
@ -193,16 +199,16 @@ const DatePicker = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-7 gap-4 text-center">
|
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
|
||||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||||
.map((weekDay) => (
|
.map((weekDay) => (
|
||||||
<div key={weekDay} className="uppercase text-gray-400 text-xs tracking-widest">
|
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
|
||||||
{weekDay}
|
{weekDay}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{calendar}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2 text-center">{calendar}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,7 +25,7 @@ const TimeOptions = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectedTimeZone !== "" && (
|
selectedTimeZone !== "" && (
|
||||||
<div className="absolute w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||||
<div className="flex mb-4">
|
<div className="flex mb-4">
|
||||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||||
|
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { google } = require("googleapis");
|
const { google } = require("googleapis");
|
||||||
|
@ -516,6 +517,8 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||||
return GoogleCalendar(cred);
|
return GoogleCalendar(cred);
|
||||||
case "office365_calendar":
|
case "office365_calendar":
|
||||||
return MicrosoftOffice365Calendar(cred);
|
return MicrosoftOffice365Calendar(cred);
|
||||||
|
case "caldav_calendar":
|
||||||
|
return new CalDavCalendar(cred);
|
||||||
default:
|
default:
|
||||||
return; // unknown credential, could be legacy? In any case, ignore
|
return; // unknown credential, could be legacy? In any case, ignore
|
||||||
}
|
}
|
||||||
|
@ -531,7 +534,7 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
|
||||||
|
|
||||||
const listCalendars = (withCredentials) =>
|
const listCalendars = (withCredentials) =>
|
||||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
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 (
|
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";
|
return "Office 365 Calendar";
|
||||||
case "zoom_video":
|
case "zoom_video":
|
||||||
return "Zoom";
|
return "Zoom";
|
||||||
|
case "caldav_calendar":
|
||||||
|
return "CalDav Server";
|
||||||
default:
|
default:
|
||||||
return "Unknown";
|
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",
|
"dayjs-business-days": "^1.0.4",
|
||||||
"googleapis": "^67.1.1",
|
"googleapis": "^67.1.1",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
|
"ical.js": "^1.4.0",
|
||||||
"ics": "^2.27.0",
|
"ics": "^2.27.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
|
@ -47,6 +48,7 @@
|
||||||
"react-select": "^4.3.0",
|
"react-select": "^4.3.0",
|
||||||
"react-timezone-select": "^1.0.2",
|
"react-timezone-select": "^1.0.2",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
|
"tsdav": "^1.0.2",
|
||||||
"tslog": "^3.2.0",
|
"tslog": "^3.2.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
|
|
100
pages/[user].tsx
100
pages/[user].tsx
|
@ -47,33 +47,83 @@ export default function User(props): User {
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
return (
|
return (
|
||||||
isReady && (
|
<>
|
||||||
<div className="bg-neutral-50 dark:bg-black h-screen">
|
<Head>
|
||||||
<Head>
|
<title>{props.user.name || props.user.username} | Calendso</title>
|
||||||
<title>{props.user.name || props.user.username} | Calendso</title>
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto py-24 px-4">
|
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||||
<div className="mb-8 text-center">
|
<meta name="description" content={"Book a time with " + (props.user.name || props.user.username)} />
|
||||||
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
|
||||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-1">
|
<meta property="og:type" content="website" />
|
||||||
{props.user.name || props.user.username}
|
<meta property="og:url" content="https://calendso/" />
|
||||||
</h1>
|
<meta
|
||||||
<p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
|
property="og:title"
|
||||||
</div>
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
<div className="space-y-6">{eventTypes}</div>
|
/>
|
||||||
{eventTypes.length == 0 && (
|
<meta
|
||||||
<div className="shadow overflow-hidden rounded-sm">
|
property="og:description"
|
||||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
content={"Book a time with " + (props.user.name || props.user.username)}
|
||||||
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2>
|
/>
|
||||||
<p className="max-w-md mx-auto">This user hasn't set up any event types yet.</p>
|
<meta
|
||||||
</div>
|
property="og:image"
|
||||||
|
content={
|
||||||
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
|
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
|
||||||
|
/'/g,
|
||||||
|
"%27"
|
||||||
|
) +
|
||||||
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
|
encodeURIComponent(props.user.avatar)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://calendso/" />
|
||||||
|
<meta
|
||||||
|
property="twitter:title"
|
||||||
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="twitter:description"
|
||||||
|
content={"Book a time with " + (props.user.name || props.user.username)}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="twitter:image"
|
||||||
|
content={
|
||||||
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
|
encodeURIComponent("Meet **" + (props.user.name || props.user.username) + "** <br>").replace(
|
||||||
|
/'/g,
|
||||||
|
"%27"
|
||||||
|
) +
|
||||||
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
|
encodeURIComponent(props.user.avatar)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
{isReady && (
|
||||||
|
<div className="bg-neutral-50 dark:bg-black h-screen">
|
||||||
|
<main className="max-w-3xl mx-auto py-24 px-4">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
|
||||||
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-1">
|
||||||
|
{props.user.name || props.user.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="space-y-6">{eventTypes}</div>
|
||||||
</main>
|
{eventTypes.length == 0 && (
|
||||||
</div>
|
<div className="shadow overflow-hidden rounded-sm">
|
||||||
)
|
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||||
|
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2>
|
||||||
|
<p className="max-w-md mx-auto">This user hasn't set up any event types yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,129 +78,159 @@ export default function Type(props): Type {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isReady && (
|
<>
|
||||||
<div>
|
<Head>
|
||||||
<Head>
|
<title>
|
||||||
<title>
|
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
|
||||||
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "}
|
Calendso
|
||||||
| Calendso
|
</title>
|
||||||
</title>
|
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
||||||
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
|
<meta name="description" content={props.eventType.description} />
|
||||||
<meta name="description" content={props.eventType.description} />
|
|
||||||
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://calendso/" />
|
<meta property="og:url" content="https://calendso/" />
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
/>
|
/>
|
||||||
<meta property="og:description" content={props.eventType.description} />
|
<meta property="og:description" content={props.eventType.description} />
|
||||||
<meta
|
<meta
|
||||||
property="og:image"
|
property="og:image"
|
||||||
content={
|
content={
|
||||||
"https://og-image-one-pi.vercel.app/" +
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
encodeURIComponent(
|
encodeURIComponent(
|
||||||
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
||||||
).replace(/'/g, "%27") +
|
).replace(/'/g, "%27") +
|
||||||
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
encodeURIComponent(props.user.avatar)
|
encodeURIComponent(props.user.avatar)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content="https://calendso/" />
|
<meta property="twitter:url" content="https://calendso/" />
|
||||||
<meta
|
<meta
|
||||||
property="twitter:title"
|
property="twitter:title"
|
||||||
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
content={"Meet " + (props.user.name || props.user.username) + " via Calendso"}
|
||||||
/>
|
/>
|
||||||
<meta property="twitter:description" content={props.eventType.description} />
|
<meta property="twitter:description" content={props.eventType.description} />
|
||||||
<meta
|
<meta
|
||||||
property="twitter:image"
|
property="twitter:image"
|
||||||
content={
|
content={
|
||||||
"https://og-image-one-pi.vercel.app/" +
|
"https://og-image-one-pi.vercel.app/" +
|
||||||
encodeURIComponent(
|
encodeURIComponent(
|
||||||
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
"Meet **" + (props.user.name || props.user.username) + "** <br>" + props.eventType.description
|
||||||
).replace(/'/g, "%27") +
|
).replace(/'/g, "%27") +
|
||||||
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" +
|
||||||
encodeURIComponent(props.user.avatar)
|
encodeURIComponent(props.user.avatar)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
<main
|
|
||||||
className={
|
|
||||||
"mx-auto my-0 sm:my-24 transition-max-width ease-in-out duration-500 " +
|
|
||||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
|
||||||
}>
|
|
||||||
<div className="dark:bg-neutral-900 bg-white border border-gray-200 rounded-sm dark:border-0">
|
|
||||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"pr-8 sm:border-r sm:dark:border-black " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
|
||||||
}>
|
|
||||||
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
|
||||||
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
|
|
||||||
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
|
|
||||||
{props.eventType.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
|
||||||
{props.eventType.length} minutes
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
{isReady && (
|
||||||
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2 text-left min-w-32 ">
|
<div>
|
||||||
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<main
|
||||||
{timeZone()}
|
className={
|
||||||
{isTimeOptionsOpen ? (
|
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
|
||||||
<ChevronUpIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||||
) : (
|
}>
|
||||||
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
<div className="sm:dark:border-gray-600 dark:bg-gray-900 bg-white md:border border-gray-200 rounded-sm">
|
||||||
)}
|
{/* mobile: details */}
|
||||||
</Collapsible.Trigger>
|
<div className="p-4 sm:p-8 block md:hidden">
|
||||||
<Collapsible.Content>
|
<div className="flex items-center">
|
||||||
<TimeOptions
|
<Avatar user={props.user} className="inline-block h-9 w-9 rounded-full" />
|
||||||
onSelectTimeZone={handleSelectTimeZone}
|
<div className="ml-3">
|
||||||
onToggle24hClock={handleToggle24hClock}
|
<p className="text-sm font-medium dark:text-gray-300 text-black">{props.user.name}</p>
|
||||||
/>
|
<div className="flex gap-2 text-xs font-medium text-gray-600">
|
||||||
</Collapsible.Content>
|
{props.eventType.title}
|
||||||
</Collapsible.Root>
|
<div>
|
||||||
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
<p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
{props.eventType.length} minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="dark:text-gray-200 text-gray-600 mt-3">{props.eventType.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<DatePicker
|
|
||||||
date={selectedDate}
|
<div className="sm:flex px-4 sm:py-5 sm:p-4">
|
||||||
periodType={props.eventType?.periodType}
|
<div
|
||||||
periodStartDate={props.eventType?.periodStartDate}
|
className={
|
||||||
periodEndDate={props.eventType?.periodEndDate}
|
"hidden md:block pr-8 sm:border-r sm:dark:border-gray-800 " +
|
||||||
periodDays={props.eventType?.periodDays}
|
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||||
periodCountCalendarDays={props.eventType?.periodCountCalendarDays}
|
}>
|
||||||
weekStart={props.user.weekStart}
|
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
|
||||||
onDatePicked={changeDate}
|
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.user.name}</h2>
|
||||||
workingHours={props.workingHours}
|
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
|
||||||
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
{props.eventType.title}
|
||||||
inviteeTimeZone={timeZone()}
|
</h1>
|
||||||
eventLength={props.eventType.length}
|
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||||
minimumBookingNotice={props.eventType.minimumBookingNotice}
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
/>
|
{props.eventType.length} minutes
|
||||||
{selectedDate && (
|
</p>
|
||||||
<AvailableTimes
|
|
||||||
workingHours={props.workingHours}
|
<TimezoneDropdown />
|
||||||
timeFormat={timeFormat}
|
|
||||||
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
<p className="dark:text-gray-200 text-gray-600 mt-3 mb-8">{props.eventType.description}</p>
|
||||||
minimumBookingNotice={props.eventType.minimumBookingNotice}
|
</div>
|
||||||
eventTypeId={props.eventType.id}
|
<DatePicker
|
||||||
eventLength={props.eventType.length}
|
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
user={props.user}
|
periodType={props.eventType?.periodType}
|
||||||
|
periodStartDate={props.eventType?.periodStartDate}
|
||||||
|
periodEndDate={props.eventType?.periodEndDate}
|
||||||
|
periodDays={props.eventType?.periodDays}
|
||||||
|
periodCountCalendarDays={props.eventType?.periodCountCalendarDays}
|
||||||
|
weekStart={props.user.weekStart}
|
||||||
|
onDatePicked={changeDate}
|
||||||
|
workingHours={props.workingHours}
|
||||||
|
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
||||||
|
inviteeTimeZone={timeZone()}
|
||||||
|
eventLength={props.eventType.length}
|
||||||
|
minimumBookingNotice={props.eventType.minimumBookingNotice}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
<div className="ml-1 mt-4 block sm:hidden">
|
||||||
|
<TimezoneDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<AvailableTimes
|
||||||
|
workingHours={props.workingHours}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
organizerTimeZone={props.eventType.timeZone || props.user.timeZone}
|
||||||
|
minimumBookingNotice={props.eventType.minimumBookingNotice}
|
||||||
|
eventTypeId={props.eventType.id}
|
||||||
|
eventLength={props.eventType.length}
|
||||||
|
date={selectedDate}
|
||||||
|
user={props.user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{!props.user.hideBranding && <PoweredByCalendso />}
|
||||||
{!props.user.hideBranding && <PoweredByCalendso />}
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function TimezoneDropdown() {
|
||||||
|
return (
|
||||||
|
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||||
|
<Collapsible.Trigger className="text-gray-500 mb-1 px-2 py-1 -ml-2 text-left min-w-32">
|
||||||
|
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
{timeZone()}
|
||||||
|
{isTimeOptionsOpen ? (
|
||||||
|
<ChevronUpIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
|
||||||
|
)}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { getBusyCalendarTimes } from "../../../lib/calendarClient";
|
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import { getBusyVideoTimes } from "../../../lib/videoClient";
|
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
@ -25,39 +25,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasCalendarIntegrations =
|
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||||
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(
|
|
||||||
currentUser.credentials,
|
currentUser.credentials,
|
||||||
req.query.dateFrom,
|
req.query.dateFrom,
|
||||||
req.query.dateTo,
|
req.query.dateTo,
|
||||||
selectedCalendars
|
selectedCalendars
|
||||||
);
|
);
|
||||||
const videoAvailability = await getBusyVideoTimes(
|
const videoBusyTimes = await getBusyVideoTimes(
|
||||||
currentUser.credentials,
|
currentUser.credentials,
|
||||||
req.query.dateFrom,
|
req.query.dateFrom,
|
||||||
req.query.dateTo
|
req.query.dateTo
|
||||||
);
|
);
|
||||||
|
calendarBusyTimes.push(...videoBusyTimes);
|
||||||
|
|
||||||
let commonAvailability = [];
|
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
||||||
|
|
||||||
if (hasCalendarIntegrations && hasVideoIntegrations) {
|
|
||||||
commonAvailability = calendarAvailability.filter((availability) =>
|
|
||||||
videoAvailability.includes(availability)
|
|
||||||
);
|
|
||||||
} else if (hasVideoIntegrations) {
|
|
||||||
commonAvailability = videoAvailability;
|
|
||||||
} else if (hasCalendarIntegrations) {
|
|
||||||
commonAvailability = calendarAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
commonAvailability = commonAvailability.map((a) => ({
|
|
||||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.status(200).json(commonAvailability);
|
res.status(200).json(bufferedBusyTimes);
|
||||||
}
|
}
|
||||||
|
|
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({});
|
||||||
|
}
|
||||||
|
}
|
|
@ -54,7 +54,7 @@ export default function Bookings({ bookings }) {
|
||||||
<tr key={booking.id}>
|
<tr key={booking.id}>
|
||||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||||
{!booking.confirmed && !booking.rejected && (
|
{!booking.confirmed && !booking.rejected && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
Unconfirmed
|
Unconfirmed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -2,18 +2,36 @@ import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import prisma from "../../lib/prisma";
|
import prisma from "../../lib/prisma";
|
||||||
import Shell from "../../components/Shell";
|
import Shell from "../../components/Shell";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { getSession, useSession } from "next-auth/client";
|
import { getSession, useSession } from "next-auth/client";
|
||||||
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
|
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
|
||||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
|
||||||
import Switch from "@components/ui/Switch";
|
import Switch from "@components/ui/Switch";
|
||||||
import Loader from "@components/Loader";
|
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 [selectableCalendars, setSelectableCalendars] = useState([]);
|
||||||
|
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(loadCalendars, [integrations]);
|
||||||
|
|
||||||
function loadCalendars() {
|
function loadCalendars() {
|
||||||
fetch("api/availability/calendar")
|
fetch("api/availability/calendar")
|
||||||
|
@ -24,11 +42,32 @@ export default function IntegrationHome({ integrations }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function integrationHandler(type) {
|
function integrationHandler(type) {
|
||||||
|
if (type === "caldav_calendar") {
|
||||||
|
setIsAddCalDavIntegrationDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => (window.location.href = data.url));
|
.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) {
|
function calendarSelectionHandler(calendar) {
|
||||||
return (selected) => {
|
return (selected) => {
|
||||||
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
||||||
|
@ -59,6 +98,8 @@ export default function IntegrationHome({ integrations }) {
|
||||||
return "integrations/google-calendar.svg";
|
return "integrations/google-calendar.svg";
|
||||||
case "office365_calendar":
|
case "office365_calendar":
|
||||||
return "integrations/outlook.svg";
|
return "integrations/outlook.svg";
|
||||||
|
case "caldav_calendar":
|
||||||
|
return "integrations/generic-calendar.png";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -68,12 +109,6 @@ export default function IntegrationHome({ integrations }) {
|
||||||
setSelectableCalendars([...selectableCalendars]);
|
setSelectableCalendars([...selectableCalendars]);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(loadCalendars, [integrations]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConnectNewAppDialog = () => (
|
const ConnectNewAppDialog = () => (
|
||||||
<Dialog>
|
<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">
|
<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,24 +122,35 @@ export default function IntegrationHome({ integrations }) {
|
||||||
<ul className="divide-y divide-gray-200">
|
<ul className="divide-y divide-gray-200">
|
||||||
{integrations
|
{integrations
|
||||||
.filter((integration) => integration.installed)
|
.filter((integration) => integration.installed)
|
||||||
.map((integration) => (
|
.map((integration) => {
|
||||||
<li key={integration.type} className="flex py-4">
|
return (
|
||||||
<div className="w-1/12 mr-4 pt-2">
|
<li key={integration.type} className="flex py-4">
|
||||||
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
<div className="w-1/12 mr-4 pt-2">
|
||||||
</div>
|
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
|
||||||
<div className="w-10/12">
|
</div>
|
||||||
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
|
<div className="w-10/12">
|
||||||
<p className="text-gray-400 text-sm">{integration.description}</p>
|
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
|
||||||
</div>
|
<p className="text-gray-400 text-sm">{integration.description}</p>
|
||||||
<div className="w-2/12 text-right pt-2">
|
</div>
|
||||||
<button
|
<div className="w-2/12 text-right pt-2">
|
||||||
onClick={() => integrationHandler(integration.type)}
|
{integration.type === "caldav_calendar" ? (
|
||||||
className="font-medium text-neutral-900 hover:text-neutral-500">
|
<button
|
||||||
Add
|
onClick={() => integrationHandler(integration.type)}
|
||||||
</button>
|
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||||
</div>
|
Add
|
||||||
</li>
|
</button>
|
||||||
))}
|
) : (
|
||||||
|
// <ConnectCalDavServerDialog isOpen={isOpen}/>
|
||||||
|
<button
|
||||||
|
onClick={() => integrationHandler(integration.type)}
|
||||||
|
className="font-medium text-neutral-900 hover:text-neutral-500">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
@ -160,6 +206,85 @@ export default function IntegrationHome({ integrations }) {
|
||||||
</Dialog>
|
</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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -262,6 +387,7 @@ export default function IntegrationHome({ integrations }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConnectCalDavServerDialog isOpen={isAddCalDavIntegrationDialogOpen} />
|
||||||
</Shell>
|
</Shell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -329,6 +455,14 @@ export async function getServerSideProps(context) {
|
||||||
imageSrc: "integrations/zoom.svg",
|
imageSrc: "integrations/zoom.svg",
|
||||||
description: "Video Conferencing",
|
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 {
|
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 |
|
@ -5,6 +5,19 @@ module.exports = {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
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: {
|
neutral: {
|
||||||
50: "#F7F8F9",
|
50: "#F7F8F9",
|
||||||
100: "#F4F5F6",
|
100: "#F4F5F6",
|
||||||
|
|
31
yarn.lock
31
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"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
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:
|
base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1:
|
||||||
version "1.5.1"
|
version "1.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
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"
|
safe-buffer "^5.0.1"
|
||||||
sha.js "^2.4.8"
|
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:
|
cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||||
version "7.0.3"
|
version "7.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
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"
|
resolved "https://registry.yarnpkg.com/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e"
|
||||||
integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ==
|
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:
|
iconv-lite@0.4.24:
|
||||||
version "0.4.24"
|
version "0.4.24"
|
||||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
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"
|
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
|
|
||||||
sax@>=0.6.0:
|
sax@>=0.6.0, sax@^1.2.4:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||||
|
@ -6504,6 +6521,11 @@ tslib@2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
|
||||||
integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==
|
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:
|
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
|
@ -6903,6 +6925,13 @@ ws@^7.4.5:
|
||||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.1.tgz#44fc000d87edb1d9c53e51fbc69a0ac1f6871d66"
|
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.1.tgz#44fc000d87edb1d9c53e51fbc69a0ac1f6871d66"
|
||||||
integrity sha512-2c6faOUH/nhoQN6abwMloF7Iyl0ZS2E9HGtsiLrWn0zOOMWlhtDmdf/uihDt6jnuCxgtwGBNy6Onsoy2s2O2Ow==
|
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:
|
xml-name-validator@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||||
|
|
Loading…
Reference in a new issue