Merge branch 'main' into main

This commit is contained in:
Peer_Rich 2021-08-15 12:48:48 +02:00 committed by GitHub
commit 610ea6c9ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 979 additions and 222 deletions

View file

@ -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=

View file

@ -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>

View file

@ -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;
}; };

View file

@ -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">

View file

@ -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
View 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;
};

View file

@ -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";
} }

View 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;
}
}

View 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;

View file

@ -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"
}, },

View file

@ -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&apos;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&apos;t set up any event types yet.</p>
</div>
</div>
)}
</main>
</div>
)}
</>
); );
} }

View file

@ -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) => {

View file

@ -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);
} }

View 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({});
}
}

View file

@ -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>
)} )}

View file

@ -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">
&#8203;
</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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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",

View file

@ -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"