integrate apple icalendar (#623)
This commit is contained in:
parent
5a9401bd28
commit
bc79f24fd4
7 changed files with 623 additions and 0 deletions
|
@ -8,6 +8,7 @@ import logger from "@lib/logger";
|
|||
|
||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
|
||||
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { google } = require("googleapis");
|
||||
|
@ -521,6 +522,8 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
|||
return MicrosoftOffice365Calendar(cred);
|
||||
case "caldav_calendar":
|
||||
return new CalDavCalendar(cred);
|
||||
case "apple_calendar":
|
||||
return new AppleCalendar(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ export function getIntegrationName(name: string) {
|
|||
return "Zoom";
|
||||
case "caldav_calendar":
|
||||
return "CalDav Server";
|
||||
case "apple_calendar":
|
||||
return "Apple Calendar";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
|
|
355
lib/integrations/Apple/AppleCalendarAdapter.ts
Normal file
355
lib/integrations/Apple/AppleCalendarAdapter.ts
Normal file
|
@ -0,0 +1,355 @@
|
|||
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";
|
||||
import logger from "@lib/logger";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
|
||||
|
||||
type EventBusyDate = Record<"start" | "end", Date>;
|
||||
|
||||
export class AppleCalendar implements CalendarApiAdapter {
|
||||
private url: string;
|
||||
private credentials: Record<string, string>;
|
||||
private headers: Record<string, string>;
|
||||
private readonly integrationName: string = "apple_calendar";
|
||||
|
||||
constructor(credential: Credential) {
|
||||
const decryptedCredential = JSON.parse(
|
||||
symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
);
|
||||
const username = decryptedCredential.username;
|
||||
const password = decryptedCredential.password;
|
||||
|
||||
this.url = "https://caldav.icloud.com";
|
||||
|
||||
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) {
|
||||
log.debug("Error creating iCalString");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!iCalString) {
|
||||
log.debug("Error creating iCalString");
|
||||
return {};
|
||||
}
|
||||
|
||||
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);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEvent(uid: string, event: CalendarEvent): Promise<unknown> {
|
||||
try {
|
||||
const calendars = await this.listCalendars();
|
||||
const events = [];
|
||||
|
||||
for (const cal of calendars) {
|
||||
const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
|
||||
|
||||
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) {
|
||||
log.debug("Error creating iCalString");
|
||||
return {};
|
||||
}
|
||||
|
||||
const eventsToUpdate = events.filter((event) => event.uid === uid);
|
||||
|
||||
return await Promise.all(
|
||||
eventsToUpdate.map((event) => {
|
||||
return updateCalendarObject({
|
||||
calendarObject: {
|
||||
url: event.url,
|
||||
data: iCalString,
|
||||
etag: event?.etag,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
throw 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, [`${cal.externalId}${uid}.ics`]);
|
||||
|
||||
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,
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
throw 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);
|
||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
selectedCalendarIds.length === 0
|
||||
? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId))
|
||||
: Promise.resolve(selectedCalendarIds)
|
||||
).then(async (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
ids.map(async (calId) => {
|
||||
return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
|
||||
return {
|
||||
start: event.startDate,
|
||||
end: event.endDate,
|
||||
};
|
||||
});
|
||||
})
|
||||
)
|
||||
).flatMap((event) => event);
|
||||
});
|
||||
} catch (reason) {
|
||||
log.error(reason);
|
||||
throw 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);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
async getEvents(
|
||||
calId: string,
|
||||
dateFrom: string | null,
|
||||
dateTo: string | null,
|
||||
objectUrls: string[] | null
|
||||
): Promise<unknown[]> {
|
||||
try {
|
||||
const objects = await fetchCalendarObjects({
|
||||
calendar: {
|
||||
url: calId,
|
||||
},
|
||||
objectUrls: objectUrls ? objectUrls : undefined,
|
||||
timeRange:
|
||||
dateFrom && dateTo
|
||||
? {
|
||||
start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
|
||||
end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
|
||||
}
|
||||
: undefined,
|
||||
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 calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone")
|
||||
? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
|
||||
: "";
|
||||
|
||||
const startDate = calendarTimezone
|
||||
? dayjs(event.startDate).tz(calendarTimezone)
|
||||
: new Date(event.startDate.toUnixTime() * 1000);
|
||||
const endDate = calendarTimezone
|
||||
? dayjs(event.endDate).tz(calendarTimezone)
|
||||
: 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: calendarTimezone,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((e) => e != null);
|
||||
|
||||
return events;
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccount() {
|
||||
const account = await createAccount({
|
||||
account: {
|
||||
serverUrl: this.url,
|
||||
accountType: "caldav",
|
||||
credentials: this.credentials,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
50
lib/integrations/Apple/components/AddAppleIntegration.tsx
Normal file
50
lib/integrations/Apple/components/AddAppleIntegration.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration";
|
||||
|
||||
const AddAppleIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
props.onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<form id={ADD_APPLE_INTEGRATION_FORM_TITLE} ref={ref} onSubmit={onSubmit}>
|
||||
<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="email@icloud.com"
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
AddAppleIntegration.displayName = "AddAppleIntegrationForm";
|
||||
export default AddAppleIntegration;
|
52
pages/api/integrations/apple/add.ts
Normal file
52
pages/api/integrations/apple/add.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
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 { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
|
||||
|
||||
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 } = req.body;
|
||||
// Get user
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const dav = new AppleCalendar({
|
||||
id: 0,
|
||||
type: "apple_calendar",
|
||||
key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await dav.listCalendars();
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "apple_calendar",
|
||||
key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
} catch (reason) {
|
||||
logger.error("Could not add this caldav account", reason);
|
||||
return res.status(500).json({ message: "Could not add this caldav account" });
|
||||
}
|
||||
|
||||
return res.status(200).json({});
|
||||
}
|
||||
}
|
|
@ -12,6 +12,9 @@ import AddCalDavIntegration, {
|
|||
ADD_CALDAV_INTEGRATION_FORM_TITLE,
|
||||
} from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||
import { getSession } from "@lib/auth";
|
||||
import AddAppleIntegration, {
|
||||
ADD_APPLE_INTEGRATION_FORM_TITLE,
|
||||
} from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||
|
||||
export type Integration = {
|
||||
installed: boolean;
|
||||
|
@ -34,6 +37,10 @@ export default function Home({ integrations }: Props) {
|
|||
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
|
||||
const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null);
|
||||
|
||||
const addAppleIntegrationRef = useRef<HTMLFormElement>(null);
|
||||
const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false);
|
||||
const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null);
|
||||
|
||||
useEffect(loadCalendars, [integrations]);
|
||||
|
||||
function loadCalendars() {
|
||||
|
@ -51,6 +58,12 @@ export default function Home({ integrations }: Props) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (type === "apple_calendar") {
|
||||
setAddAppleError(null);
|
||||
setIsAddAppleIntegrationDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/integrations/" + type.replace("_", "") + "/add")
|
||||
.then((response) => response.json())
|
||||
.then((data) => (window.location.href = data.url));
|
||||
|
@ -72,6 +85,21 @@ export default function Home({ integrations }: Props) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAddAppleIntegration = async ({ username, password }) => {
|
||||
const requestBody = JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
return await fetch("/api/integrations/apple/add", {
|
||||
method: "POST",
|
||||
body: requestBody,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function calendarSelectionHandler(calendar) {
|
||||
return (selected) => {
|
||||
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
|
||||
|
@ -104,6 +132,8 @@ export default function Home({ integrations }: Props) {
|
|||
return "integrations/outlook.svg";
|
||||
case "caldav_calendar":
|
||||
return "integrations/caldav.svg";
|
||||
case "apple_calendar":
|
||||
return "integrations/apple-calendar.svg";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
@ -221,6 +251,25 @@ export default function Home({ integrations }: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAddAppleIntegrationSaveButtonPress = async () => {
|
||||
const form = addAppleIntegrationRef.current.elements;
|
||||
const password = form.password.value;
|
||||
const username = form.username.value;
|
||||
|
||||
try {
|
||||
setAddAppleError(null);
|
||||
const addAppleIntegrationResponse = await handleAddAppleIntegration({ username, password });
|
||||
if (addAppleIntegrationResponse.ok) {
|
||||
setIsAddAppleIntegrationDialogOpen(false);
|
||||
} else {
|
||||
const j = await addAppleIntegrationResponse.json();
|
||||
setAddAppleError({ message: j.message });
|
||||
}
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const ConnectCalDavServerDialog = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -264,6 +313,49 @@ export default function Home({ integrations }: Props) {
|
|||
);
|
||||
}, [isAddCalDavIntegrationDialogOpen, addCalDavError]);
|
||||
|
||||
const ConnectAppleServerDialog = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isAddAppleIntegrationDialogOpen}
|
||||
onOpenChange={(isOpen) => setIsAddAppleIntegrationDialogOpen(isOpen)}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Connect to Apple Server"
|
||||
subtitle="Your credentials will be stored and encrypted. Generate an app specific password."
|
||||
/>
|
||||
<div className="my-4">
|
||||
{addAppleError && (
|
||||
<p className="text-red-700 text-sm">
|
||||
<span className="font-bold">Error: </span>
|
||||
{addAppleError.message}
|
||||
</p>
|
||||
)}
|
||||
<AddAppleIntegration
|
||||
ref={addAppleIntegrationRef}
|
||||
onSubmit={handleAddAppleIntegrationSaveButtonPress}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
form={ADD_APPLE_INTEGRATION_FORM_TITLE}
|
||||
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
|
||||
onClick={() => {
|
||||
setIsAddAppleIntegrationDialogOpen(false);
|
||||
}}
|
||||
as="button"
|
||||
className="btn btn-white mx-2">
|
||||
Cancel
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}, [isAddAppleIntegrationDialogOpen, addAppleError]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
@ -366,6 +458,7 @@ export default function Home({ integrations }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
<ConnectCalDavServerDialog />
|
||||
<ConnectAppleServerDialog />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
@ -441,6 +534,14 @@ export async function getServerSideProps(context) {
|
|||
imageSrc: "integrations/caldav.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
type: "apple_calendar",
|
||||
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
|
||||
title: "Apple Calendar",
|
||||
imageSrc: "integrations/apple-calendar.svg",
|
||||
description: "For personal and business calendars",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
|
|
60
public/integrations/apple-calendar.svg
Normal file
60
public/integrations/apple-calendar.svg
Normal file
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
|
||||
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
|
||||
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
|
||||
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
|
||||
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
|
||||
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
|
||||
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||
]>
|
||||
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2160 2160"
|
||||
style="enable-background:new 0 0 2160 2160;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;enable-background:new ;}
|
||||
.st1{fill:#FF0000;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="&ns_sfw;">
|
||||
<slices></slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="2160" width="2160" x="840" y="0"></sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<path class="st0" d="M2160,675.7c0-25.8,0-51.6-0.1-77.4c-0.1-21.7-0.4-43.5-1-65.2c-1.3-47.3-4.1-95.1-12.5-141.9
|
||||
c-8.5-47.5-22.5-91.7-44.5-134.9c-21.6-42.4-49.8-81.2-83.4-114.9c-33.7-33.6-72.4-61.8-114.9-83.4c-43.1-22-87.3-35.9-134.8-44.4
|
||||
c-46.8-8.4-94.6-11.2-141.9-12.5c-21.7-0.6-43.5-0.8-65.2-1C1535.9,0,1510.1,0,1484.3,0H675.7c-25.8,0-51.6,0-77.4,0.1
|
||||
c-21.7,0.1-43.5,0.4-65.2,1C485.8,2.4,438,5.2,391.2,13.6c-47.5,8.5-91.7,22.5-134.8,44.4c-42.4,21.6-81.3,49.8-114.9,83.4
|
||||
c-33.7,33.7-61.8,72.4-83.4,114.9c-22,43.2-35.9,87.4-44.5,134.9C5.2,438,2.4,485.8,1.2,533.1c-0.6,21.7-0.9,43.5-1,65.2
|
||||
C0,624.1,0,649.9,0,675.7v808.6c0,25.8,0,51.6,0.2,77.4c0.1,21.7,0.4,43.5,1,65.2c1.3,47.3,4.1,95.1,12.5,141.9
|
||||
c8.5,47.5,22.5,91.8,44.5,134.9c21.6,42.4,49.8,81.2,83.4,114.8c33.6,33.7,72.4,61.9,114.9,83.5c43.1,22,87.3,35.9,134.8,44.4
|
||||
c46.8,8.4,94.6,11.2,141.9,12.5c21.7,0.6,43.5,0.8,65.2,1c25.8,0.1,51.6,0.1,77.4,0.1h808.6c25.8,0,51.6,0,77.4-0.1
|
||||
c21.7-0.1,43.5-0.4,65.2-1c47.3-1.3,95.1-4.1,141.9-12.5c47.5-8.5,91.7-22.5,134.8-44.4c42.4-21.6,81.2-49.8,114.9-83.5
|
||||
c33.7-33.6,61.8-72.4,83.4-114.8c22-43.2,35.9-87.4,44.5-134.9c8.4-46.8,11.2-94.5,12.5-141.9c0.6-21.7,0.8-43.5,1-65.2
|
||||
c0.1-25.8,0.1-51.6,0.1-77.4V675.7z"/>
|
||||
<g>
|
||||
<path d="M806.2,1767.1V762.2H764L508,931.7v42.9l253.1-165.7h3v958.3H806.2L806.2,1767.1z"/>
|
||||
<path d="M1056.3,762.2v38.5h552v3l-443.3,963.5h47.4L1652,802.9v-40.7L1056.3,762.2L1056.3,762.2z"/>
|
||||
</g>
|
||||
<path class="st1" d="M392.5,565.1V347.8h0.8l85.9,195.1l28.5,0l85.6-195.1h1v217.3h28.5V277.3h-30.2L494,507.9h-0.8l-98.9-230.5
|
||||
h-30.2v287.7H392.5L392.5,565.1z"/>
|
||||
<path class="st1" d="M777.7,340.6c-60.1,0-93.5,43.7-93.5,102.9v21.7c0,59.5,33.1,103.4,93.5,103.4c60.3,0,93.2-43.9,93.2-103.4
|
||||
v-21.7C871,384.3,837.6,340.6,777.7,340.6L777.7,340.6z M777.7,367.8c39.2,0,62.2,29.3,62.2,77.2l0,18.6c0,48.1-23,77.4-62.2,77.4
|
||||
c-39.5,0-62.4-29.5-62.4-77.4V445C715.3,397.2,738.3,367.8,777.7,367.8L777.7,367.8z"/>
|
||||
<path class="st1" d="M929,565.1h31V429.8c0-31.6,17.3-60.8,57.8-60.8c34.6,0,56.8,20.9,56.8,58.6v137.3h31v-142
|
||||
c0-53.6-34-82.5-79.5-82.5c-36.3,0-57,19-65.2,33.8H960v-30.2h-31L929,565.1L929,565.1z"/>
|
||||
<path class="st1" d="M1248.3,340.4c-54.9,0-88.4,42.8-88.4,102.7v22.6c0,61,31,102.7,88.4,102.7c31.7,0,54.4-16,66.2-37.8h0.8
|
||||
l0,34.4h29.3V261.9h-31v113.9h-0.8C1302.1,356.7,1279.3,340.4,1248.3,340.4L1248.3,340.4z M1251.1,368.1
|
||||
c39.2,0,63.3,30.8,63.3,76.4v20.5c0,47.5-23.4,75.9-62.9,75.9c-35,0-60.5-25.5-60.5-76.1v-20
|
||||
C1190.9,393.2,1216.9,368.1,1251.1,368.1L1251.1,368.1z"/>
|
||||
<path class="st1" d="M1536.5,536.4h0.8v28.7h30.2V411.5c0-45.4-33.3-70.9-79.3-70.9c-51.2,0-77.6,26.8-80.2,65.2h29.3
|
||||
c2.5-23.4,19.2-38.4,49.6-38.4c31.4,0,49.6,16.7,49.6,47.7v23.8h-60.1c-50.4,0.2-77,24.7-77,62.9c0,40.5,29.3,66.9,72.2,66.9
|
||||
C1505.3,568.6,1525.9,554.1,1536.5,536.4L1536.5,536.4z M1478.9,541.9c-24.9,0-47.5-13.3-47.5-40.7c0-21.5,13.7-36.9,46-36.9h59.1
|
||||
v27.2C1536.5,521.2,1511.8,541.9,1478.9,541.9z"/>
|
||||
<path class="st1" d="M1795.9,344.2h-33.1L1699.7,529h-1.1l-63.1-184.8H1601l82.5,224.4l-4.2,13.5c-6.5,22.8-17.7,36.1-43.7,36.1
|
||||
c-4.8,0-12.7-0.6-16.2-1.3v26.4c5.5,0.8,13.7,1.7,21.1,1.7c43,0,57.8-29.5,69.4-61.4l6.3-16.5L1795.9,344.2L1795.9,344.2z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
Loading…
Reference in a new issue