diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b4d782bf..67d16f6aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ https://github.com/sharetribe/flex-template-web/ ## Upcoming version 2020-XX-XX +## [v6.6.0] 2020-06-04 + +### Updates from upstream + +- [fix] In some situations, ProfileMenu has began to overflow on TopbarDesktop. + [#1290](https://github.com/sharetribe/ftw-daily/pull/1290) +- [change] Update dependencies (patch updates only) + [#1291](https://github.com/sharetribe/ftw-daily/pull/1291) +- [change] Refactor server API routes into separate files. + [#1294](https://github.com/sharetribe/ftw-daily/pull/1294) +- [change] Start the backend API router in dev mode with a dev server. + [#1297](https://github.com/sharetribe/ftw-daily/pull/1297) + +[v6.6.0]: https://github.com/sharetribe/flex-template-web/compare/v6.5.1...v6.6.0 + ## [v6.5.1] 2020-05-13 - [fix] Check length of `selectedConfigOptions` in `SectionFeaturesMaybe` to choose between one and diff --git a/package.json b/package.json index 957e1acc4..e95676c23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "6.5.1", + "version": "6.6.0", "private": true, "license": "Apache-2.0", "dependencies": { @@ -9,13 +9,13 @@ "@sentry/browser": "5.7.1", "@sentry/node": "5.7.1", "array-includes": "^3.0.3", - "array.prototype.find": "^2.0.4", + "array.prototype.find": "^2.1.1", "autosize": "^4.0.0", "basic-auth": "^2.0.1", "body-parser": "^1.18.3", "classnames": "^2.2.6", "compression": "^1.7.4", - "cookie-parser": "^1.4.4", + "cookie-parser": "^1.4.5", "core-js": "^3.1.4", "decimal.js": "10.2.0", "dotenv": "6.2.0", @@ -24,8 +24,8 @@ "express-enforces-ssl": "^1.1.0", "express-sitemap": "^1.8.0", "final-form": "^4.18.5", - "final-form-arrays": "^3.0.0", - "full-icu": "^1.3.0", + "final-form-arrays": "^3.0.2", + "full-icu": "^1.3.1", "helmet": "^3.21.2", "intl-pluralrules": "^1.0.3", "jstimezonedetect": "^1.0.7", @@ -33,8 +33,8 @@ "mapbox-gl-multitouch": "^1.0.3", "moment": "^2.22.2", "moment-timezone": "^0.5.26", - "object.entries": "^1.0.4", - "object.values": "^1.0.4", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", "path-to-regexp": "^3.0.0", "prop-types": "^15.7.2", "query-string": "^5.1.1", @@ -45,14 +45,14 @@ "react-final-form": "^6.3.0", "react-final-form-arrays": "^3.1.1", "react-google-maps": "^9.4.5", - "react-helmet-async": "^1.0.2", + "react-helmet-async": "^1.0.6", "react-intl": "^3.1.13", "react-moment-proptypes": "^1.6.0", "react-redux": "^7.1.1", "react-router-dom": "^5.0.1", - "redux": "^4.0.1", + "redux": "^4.0.5", "redux-thunk": "^2.3.0", - "seedrandom": "^3.0.3", + "seedrandom": "^3.0.5", "sharetribe-flex-sdk": "^1.9.1", "sharetribe-scripts": "3.1.1", "smoothscroll-polyfill": "^0.4.0", @@ -61,11 +61,12 @@ }, "devDependencies": { "babel-jest": "24.9.0", - "bfj": "^7.0.1", + "bfj": "^7.0.2", "chalk": "^2.4.1", + "concurrently": "^5.2.0", "enzyme": "^3.9.0", "enzyme-adapter-react-16": "^1.12.0", - "enzyme-to-json": "^3.3.5", + "enzyme-to-json": "^3.4.4", "inquirer": "^7.0.0", "nodemon": "^1.17.2", "prettier": "^1.18.2" @@ -86,7 +87,10 @@ "audit": "yarn audit --json | node scripts/audit.js", "clean": "rm -rf build/*", "config": "node scripts/config.js", - "dev": "node scripts/config.js --check && sharetribe-scripts start", + "config-check": "node scripts/config.js --check", + "dev-frontend": "sharetribe-scripts start", + "dev-backend": "DEV_API_SERVER_PORT=3500 nodemon server/apiServer.js", + "dev": "yarn run config-check&&concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", "build": "sharetribe-scripts build", "format": "prettier --write '**/*.{js,css}'", "format-ci": "prettier --list-different '**/*.{js,css}'", diff --git a/server/api/initiate-login-as.js b/server/api/initiate-login-as.js new file mode 100644 index 000000000..c7c7b4d35 --- /dev/null +++ b/server/api/initiate-login-as.js @@ -0,0 +1,74 @@ +const crypto = require('crypto'); + +const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; +const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL; +const CONSOLE_URL = + process.env.SERVER_SHARETRIBE_CONSOLE_URL || 'https://flex-console.sharetribe.com'; +const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; + +// redirect_uri param used when initiating a login as authentication flow and +// when requesting a token using an authorization code +const loginAsRedirectUri = `${ROOT_URL.replace(/\/$/, '')}/api/login-as`; + +// Cookies used for authorization code authentication. +const stateKey = `st-${CLIENT_ID}-oauth2State`; +const codeVerifierKey = `st-${CLIENT_ID}-pkceCodeVerifier`; + +/** + * Makes a base64 string URL friendly by + * replacing unaccepted characters. + */ +const urlifyBase64 = base64Str => + base64Str + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + +// Initiates an authorization code authentication flow. This authentication flow +// enables marketplace operators that have an ongoing Console session to log +// into their marketplace as a user of the marketplace. +// +// The authorization code is requested from Console and it is used to request a +// token from the Flex Auth API. +// +// This endpoint will return a 302 to Console which requests the authorization +// code. Console returns a 302 with the code to the `redirect_uri` that is +// passed in this response. The request to the redirect URI is handled with the +// `/login-as` endpoint. +module.exports = (req, res) => { + const userId = req.query.user_id; + + if (!userId) { + return res.status(400).send('Missing query parameter: user_id.'); + } + if (!ROOT_URL) { + return res.status(409).send('Marketplace canonical root URL is missing.'); + } + + const state = urlifyBase64(crypto.randomBytes(32).toString('base64')); + const codeVerifier = urlifyBase64(crypto.randomBytes(32).toString('base64')); + const hash = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64'); + const codeChallenge = urlifyBase64(hash); + const authorizeServerUrl = `${CONSOLE_URL}/api/authorize-as`; + + const location = `${authorizeServerUrl}?\ +response_type=code&\ +client_id=${CLIENT_ID}&\ +redirect_uri=${loginAsRedirectUri}&\ +user_id=${userId}&\ +state=${state}&\ +code_challenge=${codeChallenge}&\ +code_challenge_method=S256`; + + const cookieOpts = { + maxAge: 1000 * 30, // 30 seconds + secure: USING_SSL, + }; + + res.cookie(stateKey, state, cookieOpts); + res.cookie(codeVerifierKey, codeVerifier, cookieOpts); + return res.redirect(location); +}; diff --git a/server/api/login-as.js b/server/api/login-as.js new file mode 100644 index 000000000..b8c40b79b --- /dev/null +++ b/server/api/login-as.js @@ -0,0 +1,93 @@ +const http = require('http'); +const https = require('https'); +const sharetribeSdk = require('sharetribe-flex-sdk'); +const Decimal = require('decimal.js'); + +const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; +const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL; +const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL; +const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true'; +const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; + +// redirect_uri param used when initiating a login as authentication flow and +// when requesting a token using an authorization code +const loginAsRedirectUri = `${ROOT_URL.replace(/\/$/, '')}/api/login-as`; + +// Instantiate HTTP(S) Agents with keepAlive set to true. +// This will reduce the request time for consecutive requests by +// reusing the existing TCP connection, thus eliminating the time used +// for setting up new TCP connections. +const httpAgent = new http.Agent({ keepAlive: true }); +const httpsAgent = new https.Agent({ keepAlive: true }); + +// Cookies used for authorization code authentication. +const stateKey = `st-${CLIENT_ID}-oauth2State`; +const codeVerifierKey = `st-${CLIENT_ID}-pkceCodeVerifier`; + +/** + * Makes a base64 string URL friendly by + * replacing unaccepted characters. + */ +const urlifyBase64 = base64Str => + base64Str + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + +// Works as the redirect_uri passed in an authorization code request. Receives +// an authorization code and uses that to log in and redirect to the landing +// page. +module.exports = (req, res) => { + const { code, state, error } = req.query; + const storedState = req.cookies[stateKey]; + + if (state !== storedState) { + res.status(401).send('Invalid state parameter.'); + return; + } + + if (error) { + res.status(401).send(`Failed to authorize as a user, error: ${error}.`); + return; + } + + const codeVerifier = req.cookies[codeVerifierKey]; + + // clear state and code verifier cookies + res.clearCookie(stateKey, { secure: USING_SSL }); + res.clearCookie(codeVerifierKey, { secure: USING_SSL }); + + const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {}; + const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({ + clientId: CLIENT_ID, + req, + res, + secure: USING_SSL, + }); + + const sdk = sharetribeSdk.createInstance({ + transitVerbose: TRANSIT_VERBOSE, + clientId: CLIENT_ID, + httpAgent: httpAgent, + httpsAgent: httpsAgent, + tokenStore, + typeHandlers: [ + { + type: sharetribeSdk.types.BigDecimal, + customType: Decimal, + writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), + reader: v => new Decimal(v.value), + }, + ], + ...baseUrl, + }); + + sdk + .login({ + code, + redirect_uri: loginAsRedirectUri, + code_verifier: codeVerifier, + }) + .then(() => res.redirect('/')) + .catch(() => res.status(401).send('Unable to authenticate as a user')); +}; diff --git a/server/apiRouter.js b/server/apiRouter.js index 8e3c6d571..60c2b9e3a 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -6,151 +6,14 @@ * endpoints are prefixed in the main server where this file is used. */ -const http = require('http'); -const https = require('https'); const express = require('express'); -const crypto = require('crypto'); -const sharetribeSdk = require('sharetribe-flex-sdk'); -const Decimal = require('decimal.js'); -const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; -const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL; -const CONSOLE_URL = - process.env.SERVER_SHARETRIBE_CONSOLE_URL || 'https://flex-console.sharetribe.com'; -const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL; -const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true'; -const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; +const initiateLoginAs = require('./api/initiate-login-as'); +const loginAs = require('./api/login-as'); const router = express.Router(); -// redirect_uri param used when initiating a login as authentication flow and -// when requesting a token using an authorization code -const loginAsRedirectUri = `${ROOT_URL.replace(/\/$/, '')}/api/login-as`; - -// Instantiate HTTP(S) Agents with keepAlive set to true. -// This will reduce the request time for consecutive requests by -// reusing the existing TCP connection, thus eliminating the time used -// for setting up new TCP connections. -const httpAgent = new http.Agent({ keepAlive: true }); -const httpsAgent = new https.Agent({ keepAlive: true }); - -// Cookies used for authorization code authentication. -const stateKey = `st-${CLIENT_ID}-oauth2State`; -const codeVerifierKey = `st-${CLIENT_ID}-pkceCodeVerifier`; - -/** - * Makes a base64 string URL friendly by - * replacing unaccepted characters. - */ -const urlifyBase64 = base64Str => - base64Str - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); - -// Initiates an authorization code authentication flow. This authentication flow -// enables marketplace operators that have an ongoing Console session to log -// into their marketplace as a user of the marketplace. -// -// The authorization code is requested from Console and it is used to request a -// token from the Flex Auth API. -// -// This endpoint will return a 302 to Console which requests the authorization -// code. Console returns a 302 with the code to the `redirect_uri` that is -// passed in this response. The request to the redirect URI is handled with the -// `/login-as` endpoint. -router.get('/initiate-login-as', (req, res) => { - const userId = req.query.user_id; - - if (!userId) { - return res.status(400).send('Missing query parameter: user_id.'); - } - if (!ROOT_URL) { - return res.status(409).send('Marketplace canonical root URL is missing.'); - } - - const state = urlifyBase64(crypto.randomBytes(32).toString('base64')); - const codeVerifier = urlifyBase64(crypto.randomBytes(32).toString('base64')); - const hash = crypto - .createHash('sha256') - .update(codeVerifier) - .digest('base64'); - const codeChallenge = urlifyBase64(hash); - const authorizeServerUrl = `${CONSOLE_URL}/api/authorize-as`; - - const location = `${authorizeServerUrl}?\ -response_type=code&\ -client_id=${CLIENT_ID}&\ -redirect_uri=${loginAsRedirectUri}&\ -user_id=${userId}&\ -state=${state}&\ -code_challenge=${codeChallenge}&\ -code_challenge_method=S256`; - - const cookieOpts = { - maxAge: 1000 * 30, // 30 seconds - secure: USING_SSL, - }; - - res.cookie(stateKey, state, cookieOpts); - res.cookie(codeVerifierKey, codeVerifier, cookieOpts); - return res.redirect(location); -}); - -// Works as the redirect_uri passed in an authorization code request. Receives -// an authorization code and uses that to log in and redirect to the landing -// page. -router.get('/login-as', (req, res) => { - const { code, state, error } = req.query; - const storedState = req.cookies[stateKey]; - - if (state !== storedState) { - return res.status(401).send('Invalid state parameter.'); - } - - if (error) { - return res.status(401).send(`Failed to authorize as a user, error: ${error}.`); - } - - const codeVerifier = req.cookies[codeVerifierKey]; - - // clear state and code verifier cookies - res.clearCookie(stateKey, { secure: USING_SSL }); - res.clearCookie(codeVerifierKey, { secure: USING_SSL }); - - const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {}; - const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({ - clientId: CLIENT_ID, - req, - res, - secure: USING_SSL, - }); - - const sdk = sharetribeSdk.createInstance({ - transitVerbose: TRANSIT_VERBOSE, - clientId: CLIENT_ID, - httpAgent: httpAgent, - httpsAgent: httpsAgent, - tokenStore, - typeHandlers: [ - { - type: sharetribeSdk.types.BigDecimal, - customType: Decimal, - writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), - reader: v => new Decimal(v.value), - }, - ], - ...baseUrl, - }); - - sdk - .login({ - code, - redirect_uri: loginAsRedirectUri, - code_verifier: codeVerifier, - }) - .then(() => res.redirect('/')) - .catch(() => res.status(401).send('Unable to authenticate as a user')); -}); +router.get('/initiate-login-as', initiateLoginAs); +router.get('/login-as', loginAs); module.exports = router; diff --git a/server/apiServer.js b/server/apiServer.js new file mode 100644 index 000000000..6488a115d --- /dev/null +++ b/server/apiServer.js @@ -0,0 +1,20 @@ +// NOTE: this server is purely a dev-mode server. In production, the +// server/index.js server also serves the API routes. + +// Configure process.env with .env.* files +require('./env').configureEnv(); + +const express = require('express'); +const cookieParser = require('cookie-parser'); +const apiRouter = require('./apiRouter'); + +const radix = 10; +const PORT = parseInt(process.env.DEV_API_SERVER_PORT, radix); +const app = express(); +app.use(cookieParser()); + +app.use('/api', apiRouter); + +app.listen(PORT, () => { + console.log(`API server listening on ${PORT}`); +}); diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index 5162e57fd..bc73f7061 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -129,7 +129,11 @@ class Menu extends Component { ? { right: contentPlacementOffset, minWidth: menuWidth } : { left: contentPlacementOffset, minWidth: menuWidth }; } - return {}; + + // When the MenuContent is rendered for the first time + // (for the sake of width calculation), + // move it outside of viewport to prevent possible overflow. + return this.state.isOpen ? {} : { left: '-10000px' }; } positionStyleForArrow(isPositionedRight) { diff --git a/src/components/Menu/__snapshots__/Menu.test.js.snap b/src/components/Menu/__snapshots__/Menu.test.js.snap index 3c79dbcb1..69bb7b568 100644 --- a/src/components/Menu/__snapshots__/Menu.test.js.snap +++ b/src/components/Menu/__snapshots__/Menu.test.js.snap @@ -16,7 +16,11 @@ exports[`Menu matches snapshot 1`] = `