Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

microsoftCalendar.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. // @flow
  2. import { Client } from '@microsoft/microsoft-graph-client';
  3. import base64js from 'base64-js';
  4. import type { Dispatch } from 'redux';
  5. import { v4 as uuidV4 } from 'uuid';
  6. import { findWindows } from 'windows-iana';
  7. import { createDeferred } from '../../../../modules/util/helpers';
  8. import { parseStandardURIString, parseURLParams } from '../../base/util';
  9. import { getShareInfoText } from '../../invite';
  10. import { setCalendarAPIAuthState } from '../actions';
  11. /**
  12. * Constants used for interacting with the Microsoft API.
  13. *
  14. * @private
  15. * @type {object}
  16. */
  17. const MS_API_CONFIGURATION = {
  18. /**
  19. * The URL to use when authenticating using Microsoft API.
  20. *
  21. * @type {string}
  22. */
  23. AUTH_ENDPOINT:
  24. 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
  25. CALENDAR_ENDPOINT: '/me/calendars',
  26. /**
  27. * The Microsoft API scopes to request access for calendar.
  28. *
  29. * @type {string}
  30. */
  31. MS_API_SCOPES: 'openid profile Calendars.ReadWrite',
  32. /**
  33. * See https://docs.microsoft.com/en-us/azure/active-directory/develop/
  34. * v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
  35. * needed for passing in the proper domain_hint value when trying to refresh
  36. * a token silently.
  37. *
  38. * @type {string}
  39. */
  40. MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
  41. /**
  42. * The redirect URL to be used by the Microsoft API on successful
  43. * authentication.
  44. *
  45. * @type {string}
  46. */
  47. REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
  48. };
  49. /**
  50. * Store the window from an auth request. That way it can be reused if a new
  51. * request comes in and it can be used to indicate a request is in progress.
  52. *
  53. * @private
  54. * @type {Object|null}
  55. */
  56. let popupAuthWindow = null;
  57. /**
  58. * A stateless collection of action creators that implements the expected
  59. * interface for interacting with the Microsoft API in order to get calendar
  60. * data.
  61. *
  62. * @type {Object}
  63. */
  64. export const microsoftCalendarApi = {
  65. /**
  66. * Retrieves the current calendar events.
  67. *
  68. * @param {number} fetchStartDays - The number of days to go back
  69. * when fetching.
  70. * @param {number} fetchEndDays - The number of days to fetch.
  71. * @returns {function(Dispatch<any>, Function): Promise<CalendarEntries>}
  72. */
  73. getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
  74. return (dispatch: Dispatch<any>, getState: Function): Promise<*> => {
  75. const state = getState()['features/calendar-sync'] || {};
  76. const token = state.msAuthState && state.msAuthState.accessToken;
  77. if (!token) {
  78. return Promise.reject('Not authorized, please sign in!');
  79. }
  80. const client = Client.init({
  81. authProvider: done => done(null, token)
  82. });
  83. return client
  84. .api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
  85. .get()
  86. .then(response => {
  87. const calendarIds = response.value.map(en => en.id);
  88. const getEventsPromises = calendarIds.map(id =>
  89. requestCalendarEvents(
  90. client, id, fetchStartDays, fetchEndDays));
  91. return Promise.all(getEventsPromises);
  92. })
  93. // get .value of every element from the array of results,
  94. // which is an array of events and flatten it to one array
  95. // of events
  96. .then(result => [].concat(...result))
  97. .then(entries => entries.map(e => formatCalendarEntry(e)));
  98. };
  99. },
  100. /**
  101. * Returns the email address for the currently logged in user.
  102. *
  103. * @returns {function(Dispatch<*, Function>): Promise<string>}
  104. */
  105. getCurrentEmail(): Function {
  106. return (dispatch: Dispatch<any>, getState: Function) => {
  107. const { msAuthState = {} }
  108. = getState()['features/calendar-sync'] || {};
  109. const email = msAuthState.userSigninName || '';
  110. return Promise.resolve(email);
  111. };
  112. },
  113. /**
  114. * Sets the application ID to use for interacting with the Microsoft API.
  115. *
  116. * @returns {function(): Promise<void>}
  117. */
  118. load(): Function {
  119. return () => Promise.resolve();
  120. },
  121. /**
  122. * Prompts the participant to sign in to the Microsoft API Client Library.
  123. *
  124. * @returns {function(Dispatch<any>, Function): Promise<void>}
  125. */
  126. signIn(): Function {
  127. return (dispatch: Dispatch<any>, getState: Function) => {
  128. // Ensure only one popup window at a time.
  129. if (popupAuthWindow) {
  130. popupAuthWindow.focus();
  131. return Promise.reject('Sign in already in progress.');
  132. }
  133. const signInDeferred = createDeferred();
  134. const guids = {
  135. authState: uuidV4(),
  136. authNonce: uuidV4()
  137. };
  138. dispatch(setCalendarAPIAuthState(guids));
  139. const { microsoftApiApplicationClientID }
  140. = getState()['features/base/config'];
  141. const authUrl = getAuthUrl(
  142. microsoftApiApplicationClientID,
  143. guids.authState,
  144. guids.authNonce);
  145. const h = 600;
  146. const w = 480;
  147. popupAuthWindow = window.open(
  148. authUrl,
  149. 'Auth M$',
  150. `width=${w}, height=${h}, top=${
  151. (screen.height / 2) - (h / 2)}, left=${
  152. (screen.width / 2) - (w / 2)}`);
  153. const windowCloseCheck = setInterval(() => {
  154. if (popupAuthWindow && popupAuthWindow.closed) {
  155. signInDeferred.reject(
  156. 'Popup closed before completing auth.');
  157. popupAuthWindow = null;
  158. window.removeEventListener('message', handleAuth);
  159. clearInterval(windowCloseCheck);
  160. } else if (!popupAuthWindow) {
  161. // This case probably happened because the user completed
  162. // auth.
  163. clearInterval(windowCloseCheck);
  164. }
  165. }, 500);
  166. /**
  167. * Callback with scope access to other variables that are part of
  168. * the sign in request.
  169. *
  170. * @param {Object} event - The event from the post message.
  171. * @private
  172. * @returns {void}
  173. */
  174. function handleAuth({ data }) {
  175. if (!data || data.type !== 'ms-login') {
  176. return;
  177. }
  178. window.removeEventListener('message', handleAuth);
  179. popupAuthWindow && popupAuthWindow.close();
  180. popupAuthWindow = null;
  181. const params = getParamsFromHash(data.url);
  182. const tokenParts = getValidatedTokenParts(
  183. params, guids, microsoftApiApplicationClientID);
  184. if (!tokenParts) {
  185. signInDeferred.reject('Invalid token received');
  186. return;
  187. }
  188. dispatch(setCalendarAPIAuthState({
  189. authState: undefined,
  190. accessToken: tokenParts.accessToken,
  191. idToken: tokenParts.idToken,
  192. tokenExpires: params.tokenExpires,
  193. userDomainType: tokenParts.userDomainType,
  194. userSigninName: tokenParts.userSigninName
  195. }));
  196. signInDeferred.resolve();
  197. }
  198. window.addEventListener('message', handleAuth);
  199. return signInDeferred.promise;
  200. };
  201. },
  202. /**
  203. * Returns whether or not the user is currently signed in.
  204. *
  205. * @returns {function(Dispatch<any>, Function): Promise<boolean>}
  206. */
  207. _isSignedIn(): Function {
  208. return (dispatch: Dispatch<any>, getState: Function) => {
  209. const now = new Date().getTime();
  210. const state
  211. = getState()['features/calendar-sync'].msAuthState || {};
  212. const tokenExpires = parseInt(state.tokenExpires, 10);
  213. const isExpired = now > tokenExpires && !isNaN(tokenExpires);
  214. if (state.accessToken && isExpired) {
  215. // token expired, let's refresh it
  216. return dispatch(refreshAuthToken())
  217. .then(() => true)
  218. .catch(() => false);
  219. }
  220. return Promise.resolve(state.accessToken && !isExpired);
  221. };
  222. },
  223. /**
  224. * Updates calendar event by generating new invite URL and editing the event
  225. * adding some descriptive text and location.
  226. *
  227. * @param {string} id - The event id.
  228. * @param {string} calendarId - The id of the calendar to use.
  229. * @param {string} location - The location to save to the event.
  230. * @returns {function(Dispatch<any>): Promise<string|never>}
  231. */
  232. updateCalendarEvent(id: string, calendarId: string, location: string) {
  233. return (dispatch: Dispatch<any>, getState: Function): Promise<*> => {
  234. const state = getState()['features/calendar-sync'] || {};
  235. const token = state.msAuthState && state.msAuthState.accessToken;
  236. if (!token) {
  237. return Promise.reject('Not authorized, please sign in!');
  238. }
  239. return getShareInfoText(getState(), location, true/* use html */)
  240. .then(text => {
  241. const client = Client.init({
  242. authProvider: done => done(null, token)
  243. });
  244. return client
  245. .api(`/me/events/${id}`)
  246. .get()
  247. .then(description => {
  248. const body = description.body;
  249. if (description.bodyPreview) {
  250. body.content
  251. = `${description.bodyPreview}<br><br>`;
  252. }
  253. // replace all new lines from the text with html
  254. // <br> to make it pretty
  255. body.content += text.split('\n').join('<br>');
  256. return client
  257. .api(`/me/calendar/events/${id}`)
  258. .patch({
  259. body,
  260. location: {
  261. 'displayName': location
  262. }
  263. });
  264. });
  265. });
  266. };
  267. }
  268. };
  269. /**
  270. * Parses the Microsoft calendar entries to a known format.
  271. *
  272. * @param {Object} entry - The Microsoft calendar entry.
  273. * @private
  274. * @returns {{
  275. * calendarId: string,
  276. * description: string,
  277. * endDate: string,
  278. * id: string,
  279. * location: string,
  280. * startDate: string,
  281. * title: string
  282. * }}
  283. */
  284. function formatCalendarEntry(entry) {
  285. return {
  286. calendarId: entry.calendarId,
  287. description: entry.body.content,
  288. endDate: entry.end.dateTime,
  289. id: entry.id,
  290. location: entry.location.displayName,
  291. startDate: entry.start.dateTime,
  292. title: entry.subject
  293. };
  294. }
  295. /**
  296. * Constructs and returns the URL to use for renewing an auth token.
  297. *
  298. * @param {string} appId - The Microsoft application id to log into.
  299. * @param {string} userDomainType - The domain type of the application as
  300. * provided by Microsoft.
  301. * @param {string} userSigninName - The email of the user signed into the
  302. * integration with Microsoft.
  303. * @private
  304. * @returns {string} - The auth URL.
  305. */
  306. function getAuthRefreshUrl(appId, userDomainType, userSigninName) {
  307. return [
  308. getAuthUrl(appId, 'undefined', 'undefined'),
  309. 'prompt=none',
  310. `domain_hint=${userDomainType}`,
  311. `login_hint=${userSigninName}`
  312. ].join('&');
  313. }
  314. /**
  315. * Constructs and returns the auth URL to use for login.
  316. *
  317. * @param {string} appId - The Microsoft application id to log into.
  318. * @param {string} authState - The authState guid to use.
  319. * @param {string} authNonce - The authNonce guid to use.
  320. * @private
  321. * @returns {string} - The auth URL.
  322. */
  323. function getAuthUrl(appId, authState, authNonce) {
  324. const authParams = [
  325. 'response_type=id_token+token',
  326. `client_id=${appId}`,
  327. `redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
  328. `scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
  329. `state=${authState}`,
  330. `nonce=${authNonce}`,
  331. 'response_mode=fragment'
  332. ].join('&');
  333. return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
  334. }
  335. /**
  336. * Converts a url from an auth redirect into an object of parameters passed
  337. * into the url.
  338. *
  339. * @param {string} url - The string to parse.
  340. * @private
  341. * @returns {Object}
  342. */
  343. function getParamsFromHash(url) {
  344. const params = parseURLParams(parseStandardURIString(url), true, 'hash');
  345. // Get the number of seconds the token is valid for, subtract 5 minutes
  346. // to account for differences in clock settings and convert to ms.
  347. const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
  348. const now = new Date();
  349. const expireDate = new Date(now.getTime() + expiresIn);
  350. params.tokenExpires = expireDate.getTime().toString();
  351. return params;
  352. }
  353. /**
  354. * Converts the parameters from a Microsoft auth redirect into an object of
  355. * token parts. The value "null" will be returned if the params do not produce
  356. * a valid token.
  357. *
  358. * @param {Object} tokenInfo - The token object.
  359. * @param {Object} guids - The guids for authState and authNonce that should
  360. * match in the token.
  361. * @param {Object} appId - The Microsoft application this token is for.
  362. * @private
  363. * @returns {Object|null}
  364. */
  365. function getValidatedTokenParts(tokenInfo, guids, appId) {
  366. // Make sure the token matches the request source by matching the GUID.
  367. if (tokenInfo.state !== guids.authState) {
  368. return null;
  369. }
  370. const idToken = tokenInfo.id_token;
  371. // A token must exist to be valid.
  372. if (!idToken) {
  373. return null;
  374. }
  375. const tokenParts = idToken.split('.');
  376. if (tokenParts.length !== 3) {
  377. return null;
  378. }
  379. let payload;
  380. try {
  381. payload = JSON.parse(b64utoutf8(tokenParts[1]));
  382. } catch (e) {
  383. return null;
  384. }
  385. if (payload.nonce !== guids.authNonce
  386. || payload.aud !== appId
  387. || payload.iss
  388. !== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
  389. return null;
  390. }
  391. const now = new Date();
  392. // Adjust by 5 minutes to allow for inconsistencies in system clocks.
  393. const notBefore = new Date((payload.nbf - 300) * 1000);
  394. const expires = new Date((payload.exp + 300) * 1000);
  395. if (now < notBefore || now > expires) {
  396. return null;
  397. }
  398. return {
  399. accessToken: tokenInfo.access_token,
  400. idToken,
  401. userDisplayName: payload.name,
  402. userDomainType:
  403. payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
  404. ? 'consumers' : 'organizations',
  405. userSigninName: payload.preferred_username
  406. };
  407. }
  408. /**
  409. * Renews an existing auth token so it can continue to be used.
  410. *
  411. * @private
  412. * @returns {function(Dispatch<any>, Function): Promise<void>}
  413. */
  414. function refreshAuthToken(): Function {
  415. return (dispatch: Dispatch<any>, getState: Function) => {
  416. const { microsoftApiApplicationClientID }
  417. = getState()['features/base/config'];
  418. const { msAuthState = {} }
  419. = getState()['features/calendar-sync'] || {};
  420. const refreshAuthUrl = getAuthRefreshUrl(
  421. microsoftApiApplicationClientID,
  422. msAuthState.userDomainType,
  423. msAuthState.userSigninName);
  424. const iframe = document.createElement('iframe');
  425. iframe.setAttribute('id', 'auth-iframe');
  426. iframe.setAttribute('name', 'auth-iframe');
  427. iframe.setAttribute('style', 'display: none');
  428. iframe.setAttribute('src', refreshAuthUrl);
  429. const signInPromise = new Promise(resolve => {
  430. iframe.onload = () => {
  431. resolve(iframe.contentWindow.location.hash);
  432. };
  433. });
  434. // The check for body existence is done for flow, which also runs
  435. // against native where document.body may not be defined.
  436. if (!document.body) {
  437. return Promise.reject(
  438. 'Cannot refresh auth token in this environment');
  439. }
  440. document.body.appendChild(iframe);
  441. return signInPromise.then(hash => {
  442. const params = getParamsFromHash(hash);
  443. dispatch(setCalendarAPIAuthState({
  444. accessToken: params.access_token,
  445. idToken: params.id_token,
  446. tokenExpires: params.tokenExpires
  447. }));
  448. });
  449. };
  450. }
  451. /**
  452. * Retrieves calendar entries from a specific calendar.
  453. *
  454. * @param {Object} client - The Microsoft-graph-client initialized.
  455. * @param {string} calendarId - The calendar ID to use.
  456. * @param {number} fetchStartDays - The number of days to go back
  457. * when fetching.
  458. * @param {number} fetchEndDays - The number of days to fetch.
  459. * @returns {Promise<any> | Promise}
  460. * @private
  461. */
  462. function requestCalendarEvents( // eslint-disable-line max-params
  463. client,
  464. calendarId,
  465. fetchStartDays,
  466. fetchEndDays): Promise<*> {
  467. const startDate = new Date();
  468. const endDate = new Date();
  469. startDate.setDate(startDate.getDate() + fetchStartDays);
  470. endDate.setDate(endDate.getDate() + fetchEndDays);
  471. const filter = `Start/DateTime ge '${
  472. startDate.toISOString()}' and End/DateTime lt '${
  473. endDate.toISOString()}'`;
  474. const ianaTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
  475. const windowsTimeZone = findWindows(ianaTimeZone);
  476. return client
  477. .api(`/me/calendars/${calendarId}/events`)
  478. .filter(filter)
  479. .header('Prefer', `outlook.timezone="${windowsTimeZone}"`)
  480. .select('id,subject,start,end,location,body')
  481. .orderby('createdDateTime DESC')
  482. .get()
  483. .then(result => result.value.map(item => {
  484. return {
  485. ...item,
  486. calendarId
  487. };
  488. }));
  489. }
  490. /**
  491. * Convert a Base64URL encoded string to a UTF-8 encoded string including CJK or Latin.
  492. *
  493. * @param {string} str - The string that needs conversion.
  494. * @private
  495. * @returns {string} - The converted string.
  496. */
  497. function b64utoutf8(str) {
  498. let s = str;
  499. // Convert from Base64URL to Base64.
  500. if (s.length % 4 === 2) {
  501. s += '==';
  502. } else if (s.length % 4 === 3) {
  503. s += '=';
  504. }
  505. s = s.replace(/-/g, '+').replace(/_/g, '/');
  506. // Convert Base64 to a byte array.
  507. const bytes = base64js.toByteArray(s);
  508. // Convert bytes to hex.
  509. s = bytes.reduce((str_, byte) => str_ + byte.toString(16).padStart(2, '0'), '');
  510. // Convert a hexadecimal string to a URLComponent string
  511. s = s.replace(/(..)/g, '%$1');
  512. // Decodee the URI component
  513. return decodeURIComponent(s);
  514. }