From 43acfdf5a6b4e4bd4311babed102713a570c9d98 Mon Sep 17 00:00:00 2001 From: Md Mahidul Haque Alvi Date: Mon, 25 Oct 2021 23:24:38 -0500 Subject: [PATCH 1/2] Integrated calendar connection functionality with Nylas & TAAS APIs. The following changes are made in this commit: 1. NPM packages 'jsonwebtoken' & 'query-string' have been added to facilitate JWT related functionality & parsing url query parameters; 2. Updated README.md file with new configuration requirements; 3. Added new method 'connectCalendar' in interviews service; 4. Added new useEffect in 'src/routes/PositionDetails/components/PositionCandidates' component to detect calendar connection success/failure from the URL, then open ManageAvailability modal and show result toast notifications; 5. Updated 'src/routes/PositionDetails/components/InterviewPopup' component to pass required props for the 'ConnectCalendar' component; 6. Made changes to UI in 'src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar' component to integrate backend functionalities; 5. Added necessary styles for 'src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar' component; --- README.md | 8 +- package-lock.json | 117 ++++++++++++++++-- package.json | 2 + .../InterviewPopup/ConnectCalendar/index.jsx | 44 +++++-- .../ConnectCalendar/styles.module.scss | 1 + .../components/InterviewPopup/index.jsx | 8 +- .../components/PositionCandidates/index.jsx | 32 ++++- src/services/interviews.js | 33 +++++ 8 files changed, 222 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f387bb1..6626195 100644 --- a/README.md +++ b/README.md @@ -136,10 +136,16 @@ Some config files are using domain `local.topcoder-dev.com`. You can change it t nvm use # or make sure to use Node 10 npm i # to install dependencies - # set environment variables: + # set environment variables: export STRIPE_PUBLIC_KEY="" + # get the below client id from Nylas app settings in Nylas account + export NYLAS_CLIENT_ID="" + + # configure the below JWT secret by matching with the one set in TAAS APIS + export NYLAS_CONNECT_CALENDAR_JWT_SECRET="" + npm run dev # this host TaaS App as http://localhost:8501/taas-app/topcoder-micro-frontends-teams.js diff --git a/package-lock.json b/package-lock.json index 4239388..bae34d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4719,6 +4719,11 @@ "isarray": "^1.0.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -5975,8 +5980,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "deep-diff": { "version": "0.3.8", @@ -6265,6 +6269,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7296,6 +7308,11 @@ } } }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, "final-form": { "version": "4.20.1", "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.1.tgz", @@ -12117,6 +12134,23 @@ "minimist": "^1.2.5" } }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -12217,6 +12251,25 @@ "object.assign": "^4.1.0" } }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -12408,6 +12461,11 @@ "lodash.isarray": "^3.0.0" } }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", @@ -12420,11 +12478,30 @@ "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", "dev": true }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" }, "lodash.keys": { "version": "3.1.2", @@ -12437,6 +12514,11 @@ "lodash.isarray": "^3.0.0" } }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -12950,8 +13032,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "multicast-dns": { "version": "6.2.3", @@ -14597,6 +14678,17 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, + "query-string": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz", + "integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==", + "requires": { + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -15727,8 +15819,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "send": { "version": "0.17.1", @@ -16277,6 +16368,11 @@ } } }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -16443,6 +16539,11 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + }, "string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", diff --git a/package.json b/package.json index 7765e74..618633b 100644 --- a/package.json +++ b/package.json @@ -70,10 +70,12 @@ "final-form": "^4.20.1", "final-form-arrays": "^3.0.2", "immutability-helper": "^3.1.1", + "jsonwebtoken": "^8.5.1", "lodash": "^4.17.20", "moment": "^2.29.1", "moment-timezone": "^0.5.33", "prop-types": "^15.7.2", + "query-string": "^7.0.1", "react": "^16.12.0", "react-avatar": "^3.9.7", "react-datepicker": "^3.8.0", diff --git a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx index 3247a0e..7362caa 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx +++ b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx @@ -4,9 +4,10 @@ import PT from "prop-types"; import { BUTTON_SIZE, BUTTON_TYPE } from "constants"; import GoogleLogo from "../../../../../assets/images/icon-google.svg"; import MicrosoftLogo from "../../../../../assets/images/icon-microsoft.svg"; -import { deleteCalendar } from "../../../../../services/interviews"; -import React, { useState } from "react"; +import { connectCalendar, deleteCalendar } from "../../../../../services/interviews"; +import React, { useEffect, useState } from "react"; import { toastr } from "react-redux-toastr"; +import { useLocation } from "@reach/router"; import Spinner from "components/CenteredSpinner"; import "./styles.module.scss"; @@ -22,6 +23,7 @@ const ConnectCalendar = ({ userProfile, onGoingBack, onCalendarRemoved, + candidate, }) => { const [showLoader, setLoader] = useState(); @@ -42,6 +44,13 @@ const ConnectCalendar = ({ }); }; + const handleConnectCalendar = () => { + // construct the appRedirectUrl with current window url and added query params + const appRedirectUrl = `${window.location.href}?interviewWithCandidate=${candidate.id}`; + + return connectCalendar(userProfile.id, appRedirectUrl) + }; + return (
{!isConnected && ( @@ -72,16 +81,26 @@ const ConnectCalendar = ({
Switch to a New calendar
)} -
- - -
+ {showLoader && } + {!showLoader && <> +
+ + +
+ }
Our calendar integration only checks the duration and free/busy status @@ -118,6 +137,7 @@ ConnectCalendar.propTypes = { }), onCalendarRemoved: PT.func, onGoingBack: PT.func, + candidate: PT.object, }; export default ConnectCalendar; diff --git a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss index 8ef2dfa..45b1dec 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss +++ b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss @@ -38,6 +38,7 @@ font-size: 14px; line-height: 24px; color: #2a2a2a; + margin-top: 16px; } // Section shown when email is connected diff --git a/src/routes/PositionDetails/components/InterviewPopup/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/index.jsx index bfde83c..5485c89 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/index.jsx +++ b/src/routes/PositionDetails/components/InterviewPopup/index.jsx @@ -164,7 +164,12 @@ const InterviewPopup = ({ return ; case POPUP_STAGES.CONNECT_CALENDAR: return ( - + ); case POPUP_STAGES.MANAGE_CALENDAR: const calendar = getCalendar(userSettings); @@ -175,6 +180,7 @@ const InterviewPopup = ({ calendar={calendar} onGoingBack={onGoingBack} onCalendarRemoved={onCalendarRemoved} + candidate={candidate} /> ); case POPUP_STAGES.MANAGE_AVAILABILITY: diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx index 24421aa..1fa999b 100644 --- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -7,6 +7,8 @@ import React, { useMemo, useState, useCallback, useEffect } from "react"; import PT from "prop-types"; import cn from "classnames"; import _ from "lodash"; +import qs from "query-string"; +import { useLocation, navigate } from "@reach/router"; import CardHeader from "components/CardHeader"; import "./styles.module.scss"; import Select from "components/Select"; @@ -67,6 +69,7 @@ const populateSkillsMatched = (position, candidate) => ({ }); const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => { + const location = useLocation(); const [interviewDetailsOpen, setInterviewDetailsOpen] = useState(false); const [prevInterviewsOpen, setPrevInterviewsOpen] = useState(false); const [selectedCandidate, setSelectedCandidate] = useState(null); @@ -207,6 +210,34 @@ const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => { [updateCandidate] ); + /* + * Useeffect to check if calendar has been connected, then remove params + * found in redirected url and show toast notification + */ + useEffect(() => { + // wait till pageCandidates is properly set + if (location.search && pageCandidates.length > 0) { + const queryParams = qs.parse(location.search); + + if (queryParams.calendarConnected === 'true') { + // check if any candidate found with id parsed from redirected url + // if found, open Schedule Interview modal for that candidate + const candidateToScheduleInterviewWith = _.find(pageCandidates, (c) => c.id = queryParams.interviewWithCandidate); + if (candidateToScheduleInterviewWith) { + setSelectedCandidate(candidateToScheduleInterviewWith); + setInterviewDetailsOpen(true); + } + + toastr.success('Calendar was successfully connected'); + navigate(location.pathname, { replace: true }); + } + else { + toastr.error(`Failed to connect calendar: ${queryParams.error}`); + navigate(location.pathname, { replace: true }); + } + } + }, [location.search, pageCandidates]) + return ( <>
@@ -369,7 +400,6 @@ const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => { setInterviewDetailsOpen(false)} candidate={selectedCandidate} /> diff --git a/src/services/interviews.js b/src/services/interviews.js index 139d68a..1003ebe 100644 --- a/src/services/interviews.js +++ b/src/services/interviews.js @@ -1,5 +1,8 @@ +/* global process */ + import { axiosInstance as axios } from "./requestInterceptor"; import config from "../../config"; +import jwt from "jsonwebtoken"; export const getUserSettings = (userId) => { return axios.get(`${config.API.V5}/taas/user-meeting-settings/${userId}`); @@ -11,6 +14,36 @@ export const deleteCalendar = (userId, calendarId) => { ); }; +/** + * Connect calendar + * + * @param {UUID of current user} userId + * @param {Url to get redirected to when api finishes saving calendar in UserMeetingSettings record} appRedirectUrl + * + * @returns Redirects to Nylas server so user can authenticate & connect account in Nylas + */ +export const connectCalendar = (userId, appRedirectUrl) => { + const apiRedirectUrl = encodeURIComponent( + `${config.API.V5}/taas/user-meeting-settings/callback` + ); + + const state = jwt.sign( + { + userId, + redirectTo: appRedirectUrl, + }, + process.env.NYLAS_CONNECT_CALENDAR_JWT_SECRET, + { + algorithm: "HS256", + expiresIn: 60, + } + ); + + const nylasCalendarConnectionUrl = `https://api.nylas.com/oauth/authorize?client_id=${process.env.NYLAS_CLIENT_ID}&redirect_uri=${apiRedirectUrl}&response_type=code&scopes=calendar&state=${state}`; + + return (window.location.href = nylasCalendarConnectionUrl); +}; + export const confirmInterview = (candidateJobId, data) => { return axios.patch( `${config.API.V5}/jobCandidates/${candidateJobId}/requestInterview`, From 263a659db066e84f09526b262624ef252aaf03fb Mon Sep 17 00:00:00 2001 From: Md Mahidul Haque Alvi Date: Tue, 26 Oct 2021 01:58:22 -0500 Subject: [PATCH 2/2] Fixed an issue with env variables not being properly configured. --- src/services/interviews.js | 6 ++++-- webpack.config.js | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/services/interviews.js b/src/services/interviews.js index 1003ebe..cf7e18e 100644 --- a/src/services/interviews.js +++ b/src/services/interviews.js @@ -3,6 +3,8 @@ import { axiosInstance as axios } from "./requestInterceptor"; import config from "../../config"; import jwt from "jsonwebtoken"; +const connectCalendarJWTSecret = process.env.NYLAS_CONNECT_CALENDAR_JWT_SECRET; +const nylasClientId = process.env.NYLAS_CLIENT_ID; export const getUserSettings = (userId) => { return axios.get(`${config.API.V5}/taas/user-meeting-settings/${userId}`); @@ -32,14 +34,14 @@ export const connectCalendar = (userId, appRedirectUrl) => { userId, redirectTo: appRedirectUrl, }, - process.env.NYLAS_CONNECT_CALENDAR_JWT_SECRET, + connectCalendarJWTSecret, { algorithm: "HS256", expiresIn: 60, } ); - const nylasCalendarConnectionUrl = `https://api.nylas.com/oauth/authorize?client_id=${process.env.NYLAS_CLIENT_ID}&redirect_uri=${apiRedirectUrl}&response_type=code&scopes=calendar&state=${state}`; + const nylasCalendarConnectionUrl = `https://api.nylas.com/oauth/authorize?client_id=${nylasClientId}&redirect_uri=${apiRedirectUrl}&response_type=code&scopes=calendar&state=${state}`; return (window.location.href = nylasCalendarConnectionUrl); }; diff --git a/webpack.config.js b/webpack.config.js index 3d543b5..396a47b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -81,6 +81,8 @@ module.exports = (webpackConfigEnv) => { "process.env": { APPENV: JSON.stringify(process.env.APPENV), STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY), + NYLAS_CLIENT_ID: JSON.stringify(process.env.NYLAS_CLIENT_ID), + NYLAS_CONNECT_CALENDAR_JWT_SECRET: JSON.stringify(process.env.NYLAS_CONNECT_CALENDAR_JWT_SECRET) }, }), ],