diff --git a/CHANGELOG.md b/CHANGELOG.md
index e476313df..ba641a30c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,20 @@ https://github.com/sharetribe/flex-template-web/
## Upcoming version 2020-XX-XX
+## [v6.2.0] 2020-02-18
+
+This is update from from [upstream](https://github.com/sharetribe/ftw-daily): v4.2.0
+
+- [add] Show a banner when a user is logged in with limited access.
+ [#1259](https://github.com/sharetribe/ftw-daily/pull/1259)
+ [#1261](https://github.com/sharetribe/ftw-daily/pull/1261)
+- [add] Support for logging in as a user from Console.
+ [#1254](https://github.com/sharetribe/ftw-daily/pull/1254)
+- [change] Add `handlebars` 4.5.3 and `serialize-javascript` 2.1.1 to resolutions in `package.json`.
+ [#1251](https://github.com/sharetribe/ftw-daily/pull/1251)
+
+[v6.2.0]: https://github.com/sharetribe/ftw-hourly/compare/v6.1.0...v6.2.0
+
## [v6.1.0] 2020-02-03
Update from upstream (first 3 bullets) and a couple of pending changes.
diff --git a/package.json b/package.json
index d34f40082..7688ba3ff 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"seedrandom": "^3.0.3",
- "sharetribe-flex-sdk": "^1.8.0",
+ "sharetribe-flex-sdk": "^1.9.1",
"sharetribe-scripts": "3.1.1",
"smoothscroll-polyfill": "^0.4.0",
"source-map-support": "^0.5.9",
@@ -73,7 +73,9 @@
"resolutions": {
"react-dates/lodash": "^4.17.14",
"react-google-maps/lodash": "^4.17.14",
- "react-test-renderer": "^16.9.0"
+ "react-test-renderer": "^16.9.0",
+ "handlebars": "^4.5.3",
+ "serialize-javascript": "^2.1.1"
},
"nodemonConfig": {
"execMap": {
diff --git a/server/apiRouter.js b/server/apiRouter.js
new file mode 100644
index 000000000..8e3c6d571
--- /dev/null
+++ b/server/apiRouter.js
@@ -0,0 +1,156 @@
+/**
+ * This file contains server side endpoints that can be used to perform backend
+ * tasks that can not be handled in the browser.
+ *
+ * The endpoints should not clash with the application routes. Therefore, the
+ * 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 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'));
+});
+
+module.exports = router;
diff --git a/server/index.js b/server/index.js
index f45d5f7b5..bafed3384 100644
--- a/server/index.js
+++ b/server/index.js
@@ -32,6 +32,7 @@ const sharetribeSdk = require('sharetribe-flex-sdk');
const Decimal = require('decimal.js');
const sitemap = require('express-sitemap');
const auth = require('./auth');
+const apiRouter = require('./apiRouter');
const renderer = require('./renderer');
const dataLoader = require('./dataLoader');
const fs = require('fs');
@@ -132,6 +133,9 @@ if (!dev) {
}
}
+// Server-side routes that do not render the application
+app.use('/api', apiRouter);
+
const noCacheHeaders = {
'Cache-control': 'no-cache, no-store, must-revalidate',
};
@@ -210,14 +214,15 @@ app.get('*', (req, res) => {
// authentication.
const token = tokenStore.getToken();
- const refreshTokenExists = !!token && !!token.refresh_token;
-
- if (refreshTokenExists) {
- // If refresh token exists, we assume that client can handle the situation
+ const scopes = !!token && token.scopes;
+ const isAnonymous = !!scopes && scopes.length === 1 && scopes[0] === 'public-read';
+ if (isAnonymous) {
+ res.status(401).send(html);
+ } else {
+ // If the token is associated with other than public-read scopes, we
+ // assume that client can handle the situation
// TODO: improve by checking if the token is valid (needs an API call)
res.status(200).send(html);
- } else {
- res.status(401).send(html);
}
} else if (context.forbidden) {
res.status(403).send(html);
diff --git a/src/components/LimitedAccessBanner/LimitedAccessBanner.css b/src/components/LimitedAccessBanner/LimitedAccessBanner.css
new file mode 100644
index 000000000..3edc8658a
--- /dev/null
+++ b/src/components/LimitedAccessBanner/LimitedAccessBanner.css
@@ -0,0 +1,31 @@
+@import '../../marketplace.css';
+
+.root {
+ background-color: #df492a;
+ text-align: center;
+ padding: 10px 20px 9px;
+}
+
+.text {
+ margin: 0;
+ display: inline-block;
+ color: #fff;
+ font-size: 16px;
+ margin-bottom: 16px;
+ line-height: 20px;
+}
+
+.button {
+ background: #2a3d4b;
+ margin: 0 16px;
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 14px;
+ color: #fff;
+ border: 0;
+
+ &:hover {
+ text-decoration: none;
+ background: #364f61;
+ }
+}
diff --git a/src/components/LimitedAccessBanner/LimitedAccessBanner.js b/src/components/LimitedAccessBanner/LimitedAccessBanner.js
new file mode 100644
index 000000000..51c73ea19
--- /dev/null
+++ b/src/components/LimitedAccessBanner/LimitedAccessBanner.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { FormattedMessage } from '../../util/reactIntl';
+import { propTypes } from '../../util/types';
+import { Button } from '../../components';
+import { ensureCurrentUser } from '../../util/data';
+
+import css from './LimitedAccessBanner.css';
+
+// Due to the layout structure, do not render the banner on the following pages
+const disabledPages = ['SearchPage'];
+
+const LimitedAccessBanner = props => {
+ const {
+ rootClassName,
+ className,
+ isAuthenticated,
+ authScopes,
+ currentUser,
+ onLogout,
+ currentPage,
+ } = props;
+ const classes = classNames(rootClassName || css.root, className);
+ const user = ensureCurrentUser(currentUser);
+
+ const showBanner =
+ user.id &&
+ isAuthenticated &&
+ authScopes &&
+ authScopes.length === 1 &&
+ authScopes[0] === 'user:limited' &&
+ !disabledPages.includes(currentPage);
+
+ const { firstName, lastName } = user.attributes.profile;
+
+ return showBanner ? (
+
+