diff --git a/crabfit-frontend/package.json b/crabfit-frontend/package.json index 32303b8..0ce5dd3 100644 --- a/crabfit-frontend/package.json +++ b/crabfit-frontend/package.json @@ -3,8 +3,10 @@ "version": "1.0.0", "private": true, "dependencies": { + "@azure/msal-browser": "^2.14.2", "@emotion/react": "^11.1.5", "@emotion/styled": "^11.1.5", + "@microsoft/microsoft-graph-client": "^2.2.1", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", diff --git a/crabfit-frontend/public/.well-known/microsoft-identity-association.json b/crabfit-frontend/public/.well-known/microsoft-identity-association.json new file mode 100644 index 0000000..4a95022 --- /dev/null +++ b/crabfit-frontend/public/.well-known/microsoft-identity-association.json @@ -0,0 +1,7 @@ +{ + "associatedApplications": [ + { + "applicationId": "78739601-9834-4d41-a281-74ca2a50b2e6" + } + ] +} \ No newline at end of file diff --git a/crabfit-frontend/public/i18n/en/event.json b/crabfit-frontend/public/i18n/en/event.json index 569723a..2c2b63e 100644 --- a/crabfit-frontend/public/i18n/en/event.json +++ b/crabfit-frontend/public/i18n/en/event.json @@ -58,6 +58,7 @@ "select_none": "Select none", "info": "Importing will overwrite your current availability", "button": "Import availability" - } + }, + "outlook_cal": "Sync with Outlook Calendar" } } diff --git a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx index 444cf7e..8488cab 100644 --- a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx +++ b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -5,6 +5,8 @@ import dayjs from 'dayjs'; import localeData from 'dayjs/plugin/localeData'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isBetween from 'dayjs/plugin/isBetween'; +import dayjs_timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; import { Wrapper, @@ -22,11 +24,13 @@ import { } from 'components/AvailabilityViewer/availabilityViewerStyle'; import { Time } from './availabilityEditorStyle'; -import { GoogleCalendar, Center } from 'components'; +import { GoogleCalendar, OutlookCalendar, Center } from 'components'; dayjs.extend(localeData); dayjs.extend(customParseFormat); dayjs.extend(isBetween); +dayjs.extend(utc); +dayjs.extend(dayjs_timezone); const AvailabilityEditor = ({ times, @@ -63,16 +67,28 @@ const AvailabilityEditor = ({ {isSpecificDates && ( - onChange( - times.filter(time => !busyArray.some(busy => - dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') - )) - )} - /> +
+ onChange( + times.filter(time => !busyArray.some(busy => + dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') + )) + )} + /> + onChange( + times.filter(time => !busyArray.some(busy => + dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)') + )) + )} + /> +
)} diff --git a/crabfit-frontend/src/components/CalendarField/calendarFieldStyle.ts b/crabfit-frontend/src/components/CalendarField/calendarFieldStyle.ts index e351b19..7aeafaa 100644 --- a/crabfit-frontend/src/components/CalendarField/calendarFieldStyle.ts +++ b/crabfit-frontend/src/components/CalendarField/calendarFieldStyle.ts @@ -70,6 +70,7 @@ export const Date = styled.button` color: inherit; background: none; border: 0; + margin: 0; appearance: none; background-color: ${props => props.theme.primaryBackground}; diff --git a/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx b/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx index 62f6235..b1517a0 100644 --- a/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx +++ b/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx @@ -12,6 +12,8 @@ import { CalendarLabel, Info, Options, + Title, + Icon, } from './googleCalendarStyle'; import googleLogo from 'res/google.svg'; @@ -109,13 +111,15 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => { ) : ( -

+ + <Icon src={googleLogo} alt="" /> {/* eslint-disable-next-line */} - <strong>{t('event:you.google_cal.login')}</strong> (<a href="#" onClick={e => { + <strong>{t('event:you.google_cal.login')}</strong> + (<a href="#" onClick={e => { e.preventDefault(); signOut(); }}>{t('event:you.google_cal.logout')}</a>) - </p> + {calendars !== undefined && !calendars.every(c => c.checked) && ( /* eslint-disable-next-line */ diff --git a/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts b/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts index dcb1cae..fe31510 100644 --- a/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts +++ b/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts @@ -12,6 +12,7 @@ export const LoginButton = styled.div` `; export const CalendarList = styled.div` + width: 100%; & > div { display: flex; margin: 2px 0; @@ -110,3 +111,18 @@ export const Options = styled.div` font-size: 14px; padding: 0 0 5px; `; + +export const Title = styled.p` + display: flex; + align-items: center; + + & strong { + margin-right: 1ex; + } +`; + +export const Icon = styled.img` + height: 24px; + width: 24px; + margin-right: 12px; +`; diff --git a/crabfit-frontend/src/components/OutlookCalendar/OutlookCalendar.tsx b/crabfit-frontend/src/components/OutlookCalendar/OutlookCalendar.tsx new file mode 100644 index 0000000..cae2983 --- /dev/null +++ b/crabfit-frontend/src/components/OutlookCalendar/OutlookCalendar.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react'; +import { PublicClientApplication } from "@azure/msal-browser"; +import { Client } from "@microsoft/microsoft-graph-client"; +import { useTranslation } from 'react-i18next'; + +import { Button, Center } from 'components'; +import { Loader } from '../Loading/loadingStyle'; +import { + LoginButton, + CalendarList, + CheckboxInput, + CheckboxLabel, + CalendarLabel, + Info, + Options, + Title, + Icon, +} from '../GoogleCalendar/googleCalendarStyle'; + +import outlookLogo from 'res/outlook.svg'; + +// Initialise the MSAL object +const publicClientApplication = new PublicClientApplication({ + auth: { + clientId: '78739601-9834-4d41-a281-74ca2a50b2e6', + redirectUri: process.env.NODE_ENV === 'production' ? 'https://crab.fit' : 'http://localhost:3000', + }, + cache: { + cacheLocation: 'sessionStorage', + storeAuthStateInCookie: true, + }, +}); + +const getAuthenticatedClient = accessToken => { + const client = Client.init({ + authProvider: done => done(null, accessToken), + }); + return client; +}; + +const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => { + const [client, setClient] = useState(undefined); + const [calendars, setCalendars] = useState(undefined); + const [freeBusyLoading, setFreeBusyLoading] = useState(false); + const { t } = useTranslation('event'); + + const checkLogin = async () => { + const accounts = publicClientApplication.getAllAccounts(); + if (accounts && accounts.length > 0) { + try { + const accessToken = await getAccessToken(); + setClient(getAuthenticatedClient(accessToken)); + } catch (e) { + console.error(e); + signOut(); + } + } else { + setClient(null); + } + }; + + const signIn = async () => { + try { + await publicClientApplication.loginPopup({ + scopes: ['Calendars.Read', 'Calendars.Read.Shared'], + }); + } catch (e) { + console.error(e); + } finally { + checkLogin(); + } + }; + + const signOut = async () => { + try { + await publicClientApplication.logoutRedirect({ + onRedirectNavigate: () => false, + }); + } catch (e) { + console.error(e); + } finally { + checkLogin(); + } + }; + + const getAccessToken = async () => { + try { + const accounts = publicClientApplication.getAllAccounts(); + if (accounts.length <= 0) throw new Error('login_required'); + + // Try to get silently + const result = await publicClientApplication.acquireTokenSilent({ + scopes: ['Calendars.Read', 'Calendars.Read.Shared'], + account: accounts[0], + }); + return result.accessToken; + } catch (e) { + if ([ + 'consent_required', + 'interaction_required', + 'login_required', + 'no_account_in_silent_request' + ].includes(e.message)) { + // Try to get with popup + const result = await publicClientApplication.acquireTokenPopup({ + scopes: ['Calendars.Read', 'Calendars.Read.Shared'], + }); + return result.accessToken; + } else { + throw e; + } + } + }; + + const importAvailability = () => { + setFreeBusyLoading(true); + gtag('event', 'outlook_cal_sync', { + 'event_category': 'event', + }); + client.api('/me/calendar/getSchedule').post({ + schedules: calendars.filter(c => c.checked).map(c => c.id), + startTime: { + dateTime: timeMin, + timeZone, + }, + endTime: { + dateTime: timeMax, + timeZone, + }, + availabilityViewInterval: 30, + }) + .then(response => { + onImport(response.value.reduce((busy, c) => c.hasOwnProperty('error') ? busy : [...busy, ...c.scheduleItems.filter(item => item.status === 'busy' || item.status === 'tentative')], [])); + }) + .catch(e => { + console.error(e); + signOut(); + }) + .finally(() => setFreeBusyLoading(false)); + }; + + useEffect(() => checkLogin(), []); + + useEffect(() => { + if (client) { + client.api('/me/calendars').get() + .then(response => { + setCalendars(response.value.map(item => ({ + 'name': item.name, + 'description': item.owner.name, + 'id': item.owner.address, + 'color': item.hexColor, + 'checked': item.isDefaultCalendar === true, + }))); + }) + .catch(e => { + console.error(e); + signOut(); + }); + } + }, [client]); + + return ( + <> + {!client ? ( +

+ +
+ ) : ( + + + <Icon src={outlookLogo} alt="" /> + {/* eslint-disable-next-line */} + <strong>{t('event:you.outlook_cal')}</strong> + (<a href="#" onClick={e => { + e.preventDefault(); + signOut(); + }}>{t('event:you.google_cal.logout')}</a>) + + + {calendars !== undefined && !calendars.every(c => c.checked) && ( + /* eslint-disable-next-line */ + { + e.preventDefault(); + setCalendars(calendars.map(c => ({...c, checked: true}))); + }}>{t('event:you.google_cal.select_all')} + )} + {calendars !== undefined && calendars.every(c => c.checked) && ( + /* eslint-disable-next-line */ + { + e.preventDefault(); + setCalendars(calendars.map(c => ({...c, checked: false}))); + }}>{t('event:you.google_cal.select_none')} + )} + + {calendars !== undefined ? calendars.map(calendar => ( +
+ setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))} + /> + + {calendar.name} +
+ )) : ( + + )} + {calendars !== undefined && ( + <> + {t('event:you.google_cal.info')} + + + )} +
+ )} + + ); +}; + +export default OutlookCalendar; diff --git a/crabfit-frontend/src/components/index.ts b/crabfit-frontend/src/components/index.ts index c0f472f..2838c00 100644 --- a/crabfit-frontend/src/components/index.ts +++ b/crabfit-frontend/src/components/index.ts @@ -11,6 +11,7 @@ export { default as AvailabilityEditor } from './AvailabilityEditor/Availability export { default as Error } from './Error/Error'; export { default as Loading } from './Loading/Loading'; export { default as GoogleCalendar } from './GoogleCalendar/GoogleCalendar'; +export { default as OutlookCalendar } from './OutlookCalendar/OutlookCalendar'; export { default as Center } from './Center/Center'; export { default as Donate } from './Donate/Donate'; diff --git a/crabfit-frontend/src/res/outlook.svg b/crabfit-frontend/src/res/outlook.svg new file mode 100644 index 0000000..702befb --- /dev/null +++ b/crabfit-frontend/src/res/outlook.svg @@ -0,0 +1,54 @@ + + + +image/svg+xml + + + + + + + + + + diff --git a/crabfit-frontend/yarn.lock b/crabfit-frontend/yarn.lock index 140d018..e7c363a 100644 --- a/crabfit-frontend/yarn.lock +++ b/crabfit-frontend/yarn.lock @@ -2,6 +2,20 @@ # yarn lockfile v1 +"@azure/msal-browser@^2.14.2": + version "2.14.2" + resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-2.14.2.tgz#4efa031ad16d5a3a527eddb6222e15dd6e2ea3e8" + integrity sha512-JKHE9Rer41CI8tweiyE91M8ZbGvQV9P+jOPB4ZtPxyxCi2f7ED3jNfdzyUJ1eGB+hCRnvO56M1Xc61T1R+JfYg== + dependencies: + "@azure/msal-common" "^4.3.0" + +"@azure/msal-common@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-4.3.0.tgz#b540e92748656724088bf77192e59943a93135bc" + integrity sha512-jFqUWe83wVb6O8cNGGBFg2QlKvqM1ezUgJTEV7kIsAPX0RXhGFE4B1DLNt6hCnkTXDbw+KGW0zgxOEr4MJQwLw== + dependencies: + debug "^4.1.1" + "@babel/code-frame@7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" @@ -1105,7 +1119,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.0", "@babel/runtime@^7.13.6": +"@babel/runtime@^7.12.0", "@babel/runtime@^7.13.6", "@babel/runtime@^7.4.4": version "7.14.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6" integrity sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA== @@ -1504,6 +1518,15 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@microsoft/microsoft-graph-client@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@microsoft/microsoft-graph-client/-/microsoft-graph-client-2.2.1.tgz#0ef045e1210551f234466a234bb0c60ea2ad8334" + integrity sha512-fbDN3UJ+jtSP9llAejqmslMcv498YuIrS3OS/Luivb8OSjdUESZKdP0gcUunnuNIayePVT0/bGYSJTzAIptJQQ== + dependencies: + "@babel/runtime" "^7.4.4" + msal "^1.4.4" + tslib "^1.9.3" + "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -7485,6 +7508,13 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +msal@^1.4.4: + version "1.4.11" + resolved "https://registry.yarnpkg.com/msal/-/msal-1.4.11.tgz#255e74e200ee5d603dca30e4e48e47fb57441370" + integrity sha512-8vW5/+irlcQQk87r8Qp3/kQEc552hr7FQLJ6GF5LLkqnwJDDxrswz6RYPiQhmiampymIs0PbHVZrNf8m+6DmgQ== + dependencies: + tslib "^1.9.3" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -10834,7 +10864,7 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==