Skip to content

Commit

Permalink
Merge pull request #76 from sharetribe/update-v4.2.0-from-upstream
Browse files Browse the repository at this point in the history
Update v4.2.0 from upstream
  • Loading branch information
lyyder authored Feb 18, 2020
2 parents 544261b + 944e065 commit c4766c4
Show file tree
Hide file tree
Showing 15 changed files with 416 additions and 30 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
156 changes: 156 additions & 0 deletions server/apiRouter.js
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 11 additions & 6 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
};
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions src/components/LimitedAccessBanner/LimitedAccessBanner.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
69 changes: 69 additions & 0 deletions src/components/LimitedAccessBanner/LimitedAccessBanner.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<div className={classes}>
<p className={css.text}>
<FormattedMessage id="LimitedAccessBanner.message" values={{ firstName, lastName }} />
</p>
<Button rootClassName={css.button} onClick={onLogout}>
<FormattedMessage id="LimitedAccessBanner.logout" />
</Button>
</div>
) : null;
};

LimitedAccessBanner.defaultProps = {
rootClassName: null,
className: null,
currentUser: null,
authScopes: [],
currentPage: null,
};

const { array, bool, func, string } = PropTypes;

LimitedAccessBanner.propTypes = {
rootClassName: string,
className: string,
isAuthenticated: bool.isRequired,
authScopes: array,
currentUser: propTypes.currentUser,
onLogout: func.isRequired,
currentPage: string,
};

export default LimitedAccessBanner;
13 changes: 12 additions & 1 deletion src/components/Topbar/Topbar.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { bool, func, number, shape, string } from 'prop-types';
import { array, bool, func, number, shape, string } from 'prop-types';
import { compose } from 'redux';
import { FormattedMessage, intlShape, injectIntl } from '../../util/reactIntl';
import pickBy from 'lodash/pickBy';
Expand All @@ -12,6 +12,7 @@ import { createResourceLocatorString, pathByRouteName } from '../../util/routes'
import { propTypes } from '../../util/types';
import {
Button,
LimitedAccessBanner,
Logo,
Modal,
ModalMissingInformation,
Expand Down Expand Up @@ -132,6 +133,7 @@ class TopbarComponent extends Component {
mobileRootClassName,
mobileClassName,
isAuthenticated,
authScopes,
authInProgress,
currentUser,
currentUserHasListings,
Expand Down Expand Up @@ -191,6 +193,13 @@ class TopbarComponent extends Component {

return (
<div className={classes}>
<LimitedAccessBanner
isAuthenticated={isAuthenticated}
authScopes={authScopes}
currentUser={currentUser}
onLogout={this.handleLogout}
currentPage={currentPage}
/>
<div className={classNames(mobileRootClassName || css.container, mobileClassName)}>
<Button
rootClassName={css.menu}
Expand Down Expand Up @@ -287,6 +296,7 @@ TopbarComponent.defaultProps = {
currentUserHasOrders: null,
currentPage: null,
sendVerificationEmailError: null,
authScopes: [],
};

TopbarComponent.propTypes = {
Expand All @@ -296,6 +306,7 @@ TopbarComponent.propTypes = {
mobileRootClassName: string,
mobileClassName: string,
isAuthenticated: bool.isRequired,
authScopes: array,
authInProgress: bool.isRequired,
currentUser: propTypes.currentUser,
currentUserHasListings: bool.isRequired,
Expand Down
Loading

0 comments on commit c4766c4

Please sign in to comment.