Merge pull request #87 from emrysal/main
Adds Office 365 / Outlook.com Calendar Integrationpull/91/head
commit
6091f7ba86
10
.env.example
10
.env.example
|
@ -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=
|
|
@ -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 };
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() });
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue