Merge pull request #87 from emrysal/main

Adds Office 365 / Outlook.com Calendar Integration
pull/91/head
Bailey Pumfleet 2021-04-22 15:04:56 +01:00 committed by GitHub
commit 6091f7ba86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 365 additions and 110 deletions

View File

@ -1,3 +1,7 @@
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
GOOGLE_API_CREDENTIALS='secret'
NEXTAUTH_URL='http://localhost:3000'
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
GOOGLE_API_CREDENTIALS='secret'
NEXTAUTH_URL='http://localhost:3000'
# Used for the Office 365 / Outlook.com Calendar integration
MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET=

203
lib/calendarClient.ts Normal file
View File

@ -0,0 +1,203 @@
const {google} = require('googleapis');
const credentials = process.env.GOOGLE_API_CREDENTIALS;
const googleAuth = () => {
const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
};
function handleErrors(response) {
if (!response.ok) {
response.json().then( console.log );
throw Error(response.statusText);
}
return response.json();
}
const o365Auth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +(new Date());
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite',
'client_id': process.env.MS_GRAPH_CLIENT_ID,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
})
})
.then(handleErrors)
.then( (responseBody) => {
credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
return {
getToken: () => ! isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
};
};
interface CalendarEvent {
title: string;
startTime: string;
timeZone: string;
endTime: string;
description?: string;
organizer: { name?: string, email: string };
attendees: { name?: string, email: string }[];
};
const MicrosoftOffice365Calendar = (credential) => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => ({
subject: event.title,
body: {
contentType: 'HTML',
content: event.description,
},
start: {
dateTime: event.startTime,
timeZone: event.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.timeZone,
},
attendees: event.attendees.map(attendee => ({
emailAddress: {
address: attendee.email,
name: attendee.name
},
type: "required"
}))
});
return {
getAvailability: (dateFrom, dateTo) => {
const payload = {
schedules: [ credential.key.email ],
startTime: {
dateTime: dateFrom,
timeZone: 'UTC',
},
endTime: {
dateTime: dateTo,
timeZone: 'UTC',
},
availabilityViewInterval: 60
};
return auth.getToken().then(
(accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', {
method: 'post',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(handleErrors)
.then( responseBody => {
return responseBody.value[0].scheduleItems.map( (evt) => ({ start: evt.start.dateTime + 'Z', end: evt.end.dateTime + 'Z' }))
})
).catch( (err) => {
console.log(err);
});
},
createEvent: (event: CalendarEvent) => auth.getToken().then( accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(translateEvent(event))
}))
}
};
const GoogleCalendar = (credential) => {
const myGoogleAuth = googleAuth();
myGoogleAuth.setCredentials(credential.key);
return {
getAvailability: (dateFrom, dateTo) => new Promise( (resolve, reject) => {
const calendar = google.calendar({ version: 'v3', auth: myGoogleAuth });
calendar.freebusy.query({
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: [ {
"id": "primary"
} ]
}
}, (err, apires) => {
if (err) {
reject(err);
}
resolve(apires.data.calendars.primary.busy)
});
}),
createEvent: (event: CalendarEvent) => new Promise( (resolve, reject) => {
const payload = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [
{'method': 'email', 'minutes': 60}
],
},
};
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth });
calendar.events.insert({
auth: myGoogleAuth,
calendarId: 'primary',
resource: payload,
}, function(err, event) {
if (err) {
console.log('There was an error contacting the Calendar service: ' + err);
return reject(err);
}
return resolve(event.data);
});
})
};
};
// factory
const calendars = (withCredentials): [] => withCredentials.map( (cred) => {
switch(cred.type) {
case 'google_calendar': return GoogleCalendar(cred);
case 'office365_calendar': return MicrosoftOffice365Calendar(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
calendars(withCredentials).map( c => c.getAvailability(dateFrom, dateTo) )
).then(
(results) => results.reduce( (acc, availability) => acc.concat(availability) )
);
const createEvent = (credential, evt: CalendarEvent) => calendars([ credential ])[0].createEvent(evt);
export { getBusyTimes, createEvent, CalendarEvent };

View File

@ -2,16 +2,16 @@ export function getIntegrationName(name: String) {
switch(name) {
case "google_calendar":
return "Google Calendar";
case "office365_calendar":
return "Office 365 Calendar";
default:
return "Unknown";
}
}
export function getIntegrationType(name: String) {
switch(name) {
case "google_calendar":
return "Calendar";
default:
return "Unknown";
if (name.endsWith('_calendar')) {
return 'Calendar';
}
}
return "Unknown";
}

View File

@ -48,15 +48,15 @@ export default function Type(props) {
// Need to define the bounds of the 24-hour window
const lowerBound = useMemo(() => {
if(!selectedDate) {
return
return
}
return selectedDate.startOf('day')
}, [selectedDate])
const upperBound = useMemo(() => {
if(!selectedDate) return
if(!selectedDate) return
return selectedDate.endOf('day')
}, [selectedDate])
@ -81,8 +81,8 @@ export default function Type(props) {
setLoading(true);
const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`);
const data = await res.json();
setBusy(data.primary.busy);
const busyTimes = await res.json();
if (busyTimes.length > 0) setBusy(busyTimes);
setLoading(false);
}, [selectedDate]);
@ -145,7 +145,7 @@ export default function Type(props) {
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
{dayjs.tz.guess()} <ChevronDownIcon className="inline-block w-4 h-4 mb-1" />
</button>
{ isTimeOptionsOpen &&
{ isTimeOptionsOpen &&
<div className="bg-white rounded shadow p-4 absolute w-72">
<Switch.Group as="div" className="flex items-center">
<Switch.Label as="span" className="mr-3">
@ -240,4 +240,4 @@ export async function getServerSideProps(context) {
eventType
},
}
}
}

View File

@ -1,8 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';
const {google} = require('googleapis');
const credentials = process.env.GOOGLE_API_CREDENTIALS;
import { getBusyTimes } from '../../../lib/calendarClient';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query
@ -17,32 +15,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
});
let availability = [];
authorise(getAvailability)
// Set up Google API credentials
function authorise(callback) {
const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
oAuth2Client.setCredentials(currentUser.credentials[0].key);
callback(oAuth2Client)
}
function getAvailability(auth) {
const calendar = google.calendar({version: 'v3', auth});
calendar.freebusy.query({
requestBody: {
timeMin: req.query.dateFrom,
timeMax: req.query.dateTo,
items: [{
"id": "primary"
}]
}
}, (err, apires) => {
if (err) return console.log('The API returned an error: ' + err);
availability = apires.data.calendars;
res.status(200).json(availability);
});
}
const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
res.status(200).json(availability);
}

View File

@ -1,8 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';
const {google} = require('googleapis');
const credentials = process.env.GOOGLE_API_CREDENTIALS;
import { createEvent, CalendarEvent } from '../../../lib/calendarClient';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query;
@ -17,50 +15,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
});
authorise(bookEvent);
const evt: CalendarEvent = {
title: 'Meeting with ' + req.body.name,
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
timeZone: currentUser.timeZone,
attendees: [
{ email: req.body.email, name: req.body.name }
]
};
// Set up Google API credentials
function authorise(callback) {
const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
oAuth2Client.setCredentials(currentUser.credentials[0].key);
callback(oAuth2Client);
}
function bookEvent(auth) {
var event = {
'summary': 'Meeting with ' + req.body.name,
'description': req.body.notes,
'start': {
'dateTime': req.body.start,
'timeZone': currentUser.timeZone,
},
'end': {
'dateTime': req.body.end,
'timeZone': currentUser.timeZone,
},
'attendees': [
{'email': req.body.email},
],
'reminders': {
'useDefault': false,
'overrides': [
{'method': 'email', 'minutes': 60}
],
},
};
const calendar = google.calendar({version: 'v3', auth});
calendar.events.insert({
auth: auth,
calendarId: 'primary',
resource: event,
}, function(err, event) {
if (err) {
console.log('There was an error contacting the Calendar service: ' + err);
return;
}
res.status(200).json({message: 'Event created'});
});
}
// TODO: for now, first integration created; primary = obvious todo; ability to change primary.
const result = await createEvent(currentUser.credentials[0], evt);
res.status(200).json(result);
}

View File

@ -30,8 +30,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
// A refresh token is only returned the first time the user
// consents to providing access. For illustration purposes,
// setting the prompt to 'consent' will force this consent
// every time, forcing a refresh_token to be returned.
prompt: 'consent',
});
res.status(200).json({url: authUrl});
}
}
}

View File

@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const scopes = ['User.Read', 'Calendars.Read', 'Calendars.ReadWrite'];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
// 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; }
// TODO: Add user ID to user session object
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true
}
});
const hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : '';
if ( ! hostname || ! req.headers.referer.startsWith(hostname)) {
throw new Error('Unable to determine external url, check server settings');
}
function generateAuthUrl() {
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code&scope=' + scopes.join(' ') + '&client_id=' + process.env.MS_GRAPH_CLIENT_ID + '&redirect_uri=' + hostname + '/api/integrations/office365calendar/callback';
}
res.status(200).json({url: generateAuthUrl() });
}
}

View File

@ -0,0 +1,54 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const scopes = ['offline_access', 'Calendars.Read', 'Calendars.ReadWrite'];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
// 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; }
// TODO: Add user ID to user session object
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true
}
});
const toUrlEncoded = payload => Object.keys(payload).map( (key) => key + '=' + encodeURIComponent(payload[ key ]) ).join('&');
const hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : '';
const body = toUrlEncoded({ client_id: process.env.MS_GRAPH_CLIENT_ID, grant_type: 'authorization_code', code, scope: scopes.join(' '), redirect_uri: hostname + '/api/integrations/office365calendar/callback', client_secret: process.env.MS_GRAPH_CLIENT_SECRET });
const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { method: 'POST', headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}, body });
const responseBody = await response.json();
if (!response.ok) {
return res.redirect('/integrations?error=' + JSON.stringify(responseBody));
}
const whoami = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': 'Bearer ' + responseBody.access_token } });
const graphUser = await whoami.json();
responseBody.email = graphUser.mail;
responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const credential = await prisma.credential.create({
data: {
type: 'office365_calendar',
key: responseBody,
userId: user.id
}
});
return res.redirect('/integrations');
}

View File

@ -56,16 +56,18 @@ export default function Home(props) {
</div>
</div>
<ul className="divide-y divide-gray-200">
{props.credentials.map((integration) =>
{props.credentials.map((integration) =>
<li className="pb-4 flex">
{integration.type == 'google_calendar' && <img className="h-10 w-10 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />}
{integration.type == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />}
<div className="ml-3">
{integration.type == 'office365_calendar' && <p className="text-sm font-medium text-gray-900">Office 365 / Outlook.com Calendar</p>}
{integration.type == 'google_calendar' && <p className="text-sm font-medium text-gray-900">Google Calendar</p>}
{integration.type == 'google_calendar' && <p className="text-sm text-gray-500">Calendar Integration</p>}
<p className="text-sm text-gray-500">Calendar Integration</p>
</div>
</li>
)}
{props.credentials.length == 0 &&
{props.credentials.length == 0 &&
<div className="text-center text-gray-400 py-2">
<p>You haven't added any integrations.</p>
</div>
@ -93,7 +95,7 @@ export async function getServerSideProps(context) {
id: true
}
});
credentials = await prisma.credential.findMany({
where: {
userId: user.id,

View File

@ -23,8 +23,8 @@ export default function Home(props) {
setShowAddModal(!showAddModal);
}
function googleCalendarHandler() {
fetch('/api/integrations/googlecalendar/add')
function integrationHandler(type) {
fetch('/api/integrations/' + type + '/add')
.then((response) => response.json())
.then((data) => window.location.href = data.url);
}
@ -47,18 +47,20 @@ export default function Home(props) {
<div className="min-w-0 flex-1 flex items-center">
<div className="flex-shrink-0">
{integration.type == 'google_calendar' && <img className="h-10 w-10 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />}
{integration.type == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />}
</div>
<div className="min-w-0 flex-1 px-4 md:grid md:grid-cols-2 md:gap-4">
<div>
{integration.type == 'google_calendar' && <p className="text-sm font-medium text-blue-600 truncate">Google Calendar</p>}
<p className="mt-2 flex items-center text-sm text-gray-500">
{integration.type == 'google_calendar' && <span className="truncate">Calendar Integration</span>}
{integration.type == 'office365_calendar' && <p className="text-sm font-medium text-blue-600 truncate">Office365 / Outlook.com Calendar</p>}
<p className="flex items-center text-sm text-gray-500">
{integration.type.endsWith('_calendar') && <span className="truncate">Calendar Integration</span>}
</p>
</div>
<div className="hidden md:block">
<div>
{integration.key &&
<p className="mt-3 flex items-center text text-gray-500">
<p className="mt-2 flex items-center text text-gray-500">
<CheckCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-green-400" />
Connected
</p>
@ -105,7 +107,7 @@ export default function Home(props) {
</div>
}
</div>
{showAddModal &&
{showAddModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* <!--
@ -148,6 +150,18 @@ export default function Home(props) {
</div>
<div className="my-4">
<ul className="divide-y divide-gray-200">
<li className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">Office 365 / Outlook.com Calendar</h2>
<p className="text-gray-400 text-sm">For personal and business accounts</p>
</div>
<div className="w-2/12 text-right pt-2">
<button onClick={() => integrationHandler('office365calendar')} className="font-medium text-blue-600 hover:text-blue-500">Add</button>
</div>
</li>
<li className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />
@ -157,7 +171,7 @@ export default function Home(props) {
<p className="text-gray-400 text-sm">For personal and business accounts</p>
</div>
<div className="w-2/12 text-right pt-2">
<button onClick={googleCalendarHandler} className="font-medium text-blue-600 hover:text-blue-500">Add</button>
<button onClick={() => integrationHandler('googlecalendar')} className="font-medium text-blue-600 hover:text-blue-500">Add</button>
</div>
</li>
</ul>
@ -201,4 +215,4 @@ export async function getServerSideProps(context) {
return {
props: {credentials}, // will be passed to the page component as props
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB