diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d9ab5a8a..aab3a8774 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ forked repo, check that it meets these guidelines: * Create a separate PR for each small feature or bug fix. * [Squash](http://stackoverflow.com/questions/5189560/squash-my-last-x-commits-together-using-git) your commits into one for each PR. -* Run `yarn test` to make sure that your code style is OK and there are no any regression bugs. +* Run `yarn test` to make sure that your code style is OK and there are no any regression bugs. * When contributing to an opt-in feature, apply the `[feature/...]` tag as a prefix to your PR title #### Style Guide diff --git a/docs/how-to-configure-text-editors.md b/docs/how-to-configure-text-editors.md index 868f8f126..5c89d7751 100644 --- a/docs/how-to-configure-text-editors.md +++ b/docs/how-to-configure-text-editors.md @@ -53,8 +53,8 @@ yarn add --dev eslint babel-eslint eslint-plugin-react stylelint ### SublimeText -Install SublimeText packages -Easiest with [Package Control](https://packagecontrol.io/) and then "Package Control: Install Package" (Ctrl+Shift+P) +Install SublimeText packages +Easiest with [Package Control](https://packagecontrol.io/) and then "Package Control: Install Package" (Ctrl+Shift+P) * [Babel](https://packagecontrol.io/packages/Babel) * [Sublime-linter](http://www.sublimelinter.com/en/latest/) diff --git a/docs/recipes/how-to-integrate-react-intl.md b/docs/recipes/how-to-integrate-react-intl.md index 997177dbc..29f7ba414 100644 --- a/docs/recipes/how-to-integrate-react-intl.md +++ b/docs/recipes/how-to-integrate-react-intl.md @@ -49,7 +49,7 @@ the [`defineMessages()`](https://github.com/yahoo/react-intl/wiki/API#definemess [``](https://github.com/yahoo/react-intl/wiki/Components#formatteddate) [``](https://github.com/yahoo/react-intl/wiki/Components#formattedtime) [``](https://github.com/yahoo/react-intl/wiki/Components#formattedrelative) - + - For numbers and currencies: [``](https://github.com/yahoo/react-intl/wiki/Components#formattednumber) [``](https://github.com/yahoo/react-intl/wiki/Components#formattedplural) diff --git a/package.json b/package.json index f0282b2a9..5c36608d6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "normalize.css": "^5.0.0", "passport": "^0.3.2", "passport-facebook": "^2.1.1", + "passport-github2": "^0.1.10", "pretty-error": "^2.0.2", "query-string": "^4.3.1", "react": "^15.4.2", diff --git a/src/components/LoginForm/Button.css b/src/components/LoginForm/Button.css new file mode 100644 index 000000000..e5fd8356f --- /dev/null +++ b/src/components/LoginForm/Button.css @@ -0,0 +1,44 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +.button { + display: block; + box-sizing: border-box; + margin: 0; + padding: 10px 16px; + width: 100%; + outline: 0; + border: 1px solid #373277; + border-radius: 0; + background: #373277; + color: #fff; + text-align: center; + text-decoration: none; + font-size: 18px; + line-height: 1.3333333; + cursor: pointer; +} + +.button:hover { + background: rgba(54, 50, 119, 0.8); +} + +.button:focus { + border-color: #0074c2; + box-shadow: 0 0 8px rgba(0, 116, 194, 0.6); +} + +.icon { + display: inline-block; + margin: -2px 12px -2px 0; + width: 20px; + height: 20px; + vertical-align: middle; + fill: currentColor; +} diff --git a/src/components/LoginForm/LoginForm.css b/src/components/LoginForm/LoginForm.css new file mode 100644 index 000000000..cb74be0a3 --- /dev/null +++ b/src/components/LoginForm/LoginForm.css @@ -0,0 +1,43 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ +@import '../variables.css'; +@import '../LoginForm/Button.css'; + +.formGroup { + margin-bottom: 15px; +} + +.label { + display: inline-block; + margin-bottom: 5px; + max-width: 100%; + font-weight: 700; +} + +.input { + display: block; + box-sizing: border-box; + padding: 10px 16px; + width: 100%; + height: 46px; + outline: 0; + border: 1px solid #ccc; + border-radius: 0; + background: #fff; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + color: #616161; + font-size: 18px; + line-height: 1.3333333; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; +} + +.input:focus { + border-color: #0074c2; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6); +} diff --git a/src/components/LoginForm/LoginForm.js b/src/components/LoginForm/LoginForm.js new file mode 100644 index 000000000..0c8cb3d0f --- /dev/null +++ b/src/components/LoginForm/LoginForm.js @@ -0,0 +1,50 @@ +/* eslint-disable css-modules/no-undef-class */ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import React from 'react'; +import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import s from './LoginForm.css'; + +function LoginForm() { + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ); +} + +export default withStyles(s)(LoginForm); diff --git a/src/components/LoginForm/package.json b/src/components/LoginForm/package.json new file mode 100644 index 000000000..46d5c71ed --- /dev/null +++ b/src/components/LoginForm/package.json @@ -0,0 +1,6 @@ +{ + "name": "LoginForm", + "version": "0.0.0", + "private": true, + "main": "./LoginForm.js" +} diff --git a/src/components/LoginThirdParty/LoginThirdParty.css b/src/components/LoginThirdParty/LoginThirdParty.css new file mode 100644 index 000000000..88a48e058 --- /dev/null +++ b/src/components/LoginThirdParty/LoginThirdParty.css @@ -0,0 +1,84 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ +@import '../variables.css'; +@import '../LoginForm/Button.css'; + +.facebook { + border-color: #3b5998; + background: #3b5998; + composes: button; +} + +.facebook:hover { + background: #2d4373; +} + +.google { + border-color: #dd4b39; + background: #dd4b39; + composes: button; +} + +.google:hover { + background: #c23321; +} + +.twitter { + border-color: #55acee; + background: #55acee; + composes: button; +} + +.twitter:hover { + background: #2795e9; +} + +.github { + border-color: #497e71; + background: #497e71; + composes: button; +} + +.github:hover { + background: #257461; +} + +.lineThrough { + position: relative; + z-index: 1; + display: block; + margin-bottom: 15px; + width: 100%; + color: #757575; + text-align: center; + font-size: 80%; +} + +.lineThrough::before { + position: absolute; + top: 50%; + left: 50%; + z-index: -1; + margin-top: -5px; + margin-left: -20px; + width: 40px; + height: 10px; + background-color: #fff; + content: ''; +} + +.lineThrough::after { + position: absolute; + top: 49%; + z-index: -2; + display: block; + width: 100%; + border-bottom: 1px solid #ddd; + content: ''; +} diff --git a/src/components/LoginThirdParty/LoginThirdParty.js b/src/components/LoginThirdParty/LoginThirdParty.js new file mode 100644 index 000000000..76a73449c --- /dev/null +++ b/src/components/LoginThirdParty/LoginThirdParty.js @@ -0,0 +1,74 @@ +/* eslint-disable css-modules/no-unused-class */ +/* eslint-disable css-modules/no-undef-class */ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import React from 'react'; +import withStyles from 'isomorphic-style-loader/lib/withStyles'; +import fetch from '../../core/fetch'; +import LoginThirdPartyButton from './LoginThirdPartyButton'; +import s from '../LoginThirdParty/LoginThirdParty.css'; + +async function getAuthData() { + const resp = await fetch('/graphql', { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: '{auth{loginName,buttonClass,buttonText,routeTo,icon}}', + }), + credentials: 'include', + }); + + const { data } = await resp.json(); + if (!data || !data.auth) throw new Error('can\'t load third party auth data'); + + return data; +} + +class LoginThirdParty extends React.Component { + constructor(props) { + super(props); + + this.state = { + loginThirdParty: [], + }; + } + + componentDidMount() { + const stateSetter = this.setState.bind(this); + getAuthData().then((data) => { + stateSetter({ + loginThirdParty: data.auth, + }); + }); + } + + render() { + return ( +
+ {this.state.loginThirdParty.map(thirdPartyAuth => ( + + ))} + + { this.state.loginThirdParty.lenght >= 1 ? OR : '' } +
+ ); + } +} + +export default withStyles(s)(LoginThirdParty); diff --git a/src/components/LoginThirdParty/LoginThirdPartyButton.js b/src/components/LoginThirdParty/LoginThirdPartyButton.js new file mode 100644 index 000000000..960fcd47d --- /dev/null +++ b/src/components/LoginThirdParty/LoginThirdPartyButton.js @@ -0,0 +1,50 @@ +/* eslint-disable css-modules/no-unused-class */ +/* eslint-disable css-modules/no-undef-class */ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import React, { PropTypes } from 'react'; + +function LoginThirdPartyButton({ + thirdPartyAuth, + linkClassName, + iconClassName, + ...props +}) { + return ( + + ); +} + +LoginThirdPartyButton.propTypes = { + linkClassName: PropTypes.string.isRequired, + iconClassName: PropTypes.string.isRequired, + thirdPartyAuth: PropTypes.shape({ + loginName: PropTypes.string, + routeTo: PropTypes.string, + buttonClass: PropTypes.string, + icon: PropTypes.string, + buttonText: PropTypes.string, + }).isRequired, +}; + +export default LoginThirdPartyButton; diff --git a/src/components/LoginThirdParty/index.css b/src/components/LoginThirdParty/index.css new file mode 100644 index 000000000..2464ead9c --- /dev/null +++ b/src/components/LoginThirdParty/index.css @@ -0,0 +1,47 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ +@import '../variables.css'; + +.formGroup { + margin-bottom: 15px; +} + +.lineThrough { + position: relative; + z-index: 1; + display: block; + margin-bottom: 15px; + width: 100%; + color: #757575; + text-align: center; + font-size: 80%; +} + +.lineThrough::before { + position: absolute; + top: 50%; + left: 50%; + z-index: -1; + margin-top: -5px; + margin-left: -20px; + width: 40px; + height: 10px; + background-color: #fff; + content: ''; +} + +.lineThrough::after { + position: absolute; + top: 49%; + z-index: -2; + display: block; + width: 100%; + border-bottom: 1px solid #ddd; + content: ''; +} diff --git a/src/components/LoginThirdParty/package.json b/src/components/LoginThirdParty/package.json new file mode 100644 index 000000000..ae663837b --- /dev/null +++ b/src/components/LoginThirdParty/package.json @@ -0,0 +1,6 @@ +{ + "name": "LoginThirdParty", + "version": "0.0.0", + "private": true, + "main": "./LoginThirdParty.js" +} diff --git a/src/config.js b/src/config.js index 04f217fab..922034ae1 100644 --- a/src/config.js +++ b/src/config.js @@ -26,6 +26,54 @@ export const analytics = { export const auth = { jwt: { secret: process.env.JWT_SECRET || 'React Starter Kit' }, + authConfig: { + usedAuth: process.env.AUTH_CONFIG_USED_AUTH || 'facebook,twitter,google,github', + loginButtons: { + facebook: { + icon: 'M22 16l1-5h-5V7c0-1.544.784-2 3-2h2V0h-4c-4.072 0-7 2.435-7 7v4H7v5h5v14h6V16h4z', + routeTo: 'login/facebook', + buttonClass: 'facebook', + buttonText: 'Log in with Facebook', + }, + google: { + icon: 'M30 13h-4V9h-2v4h-4v2h4v4h2v-4h4m-15 2s-2-1.15-2-2c0 0-.5-1.828 1-3 ' + + '1.537-1.2 3-3.035 3-5 0-2.336-1.046-5-3-6h3l2.387-1H10C5.835 0 2 3.345 2 7c0 ' + + '3.735 2.85 6.56 7.086 6.56.295 0 .58-.006.86-.025-.273.526-.47 1.12-.47 1.735 ' + + '0 1.037.817 2.042 1.523 2.73H9c-5.16 0-9 2.593-9 6 0 3.355 4.87 6 10.03 6 5.882 ' + + '0 9.97-3 9.97-7 0-2.69-2.545-4.264-5-6zm-4-4c-2.395 0-5.587-2.857-6-6C4.587 ' + + '3.856 6.607.93 9 1c2.394.07 4.603 2.908 5.017 6.052C14.43 10.195 13 13 11 ' + + '13zm-1 15c-3.566 0-7-1.29-7-4 0-2.658 3.434-5.038 7-5 .832.01 2 0 2 0 1 0 ' + + '2.88.88 4 2 1 1 1 2.674 1 3 0 3-1.986 4-7 4z', + routeTo: 'login/google', + buttonClass: 'google', + buttonText: 'Log in with google', + }, + twitter: { + icon: 'M30 6.708c-1.105.49-2.756 1.143-4 1.292 1.273-.762 2.54-2.56 ' + + '3-4-.97.577-2.087 1.355-3.227 1.773L25 5c-1.12-1.197-2.23-2-4-2-3.398 0-6 ' + + '2.602-6 6 0 .4.047.7.11.956L15 10C9 10 5.034 8.724 2 5c-.53.908-1 1.872-1 ' + + '3 0 2.136 1.348 3.894 3 5-1.01-.033-2.17-.542-3-1 0 2.98 4.186 6.432 7 7-1 ' + + '1-4.623.074-5 0 .784 2.447 3.31 3.95 6 4-2.105 1.648-4.647 2.51-7.53 2.51-.5 ' + + '0-.988-.03-1.47-.084C2.723 27.17 6.523 28 10 28c11.322 0 17-8.867 17-17 ' + + '0-.268.008-.736 0-1 1.2-.868 2.172-2.058 3-3.292z', + routeTo: 'login/twitter', + buttonClass: 'twitter', + buttonText: 'Log in with twitter', + }, + github: { + icon: 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 ' + + '0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 ' + + '1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 ' + + '0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 ' + + '2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 ' + + '1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 ' + + '0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z', + routeTo: 'login/github', + buttonClass: 'github', + buttonText: 'Log in with github', + }, + }, + }, // https://developers.facebook.com/ facebook: { @@ -45,4 +93,10 @@ export const auth = { secret: process.env.TWITTER_CONSUMER_SECRET || 'KTZ6cxoKnEakQCeSpZlaUCJWGAlTEBJj0y2EMkUBujA7zWSvaQ', }, + // https://github.com/ + github: { + id: process.env.GITHUB_CONSUMER_KEY || 'xxxxxxxxxxxxxxxxxxxxx', + secret: process.env.GITHUB_CONSUMER_SECRET || 'xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxy', + }, + }; diff --git a/src/core/passport.js b/src/core/passport.js deleted file mode 100644 index 6772810ec..000000000 --- a/src/core/passport.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * React Starter Kit (https://www.reactstarterkit.com/) - * - * Copyright © 2014-present Kriasoft, LLC. All rights reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE.txt file in the root directory of this source tree. - */ - -/** - * Passport.js reference implementation. - * The database schema used in this sample is available at - * https://github.com/membership/membership.db/tree/master/postgres - */ - -import passport from 'passport'; -import { Strategy as FacebookStrategy } from 'passport-facebook'; -import { User, UserLogin, UserClaim, UserProfile } from '../data/models'; -import { auth as config } from '../config'; - -/** - * Sign in with Facebook. - */ -passport.use(new FacebookStrategy({ - clientID: config.facebook.id, - clientSecret: config.facebook.secret, - callbackURL: '/login/facebook/return', - profileFields: ['name', 'email', 'link', 'locale', 'timezone'], - passReqToCallback: true, -}, (req, accessToken, refreshToken, profile, done) => { - /* eslint-disable no-underscore-dangle */ - const loginName = 'facebook'; - const claimType = 'urn:facebook:access_token'; - const fooBar = async () => { - if (req.user) { - const userLogin = await UserLogin.findOne({ - attributes: ['name', 'key'], - where: { name: loginName, key: profile.id }, - }); - if (userLogin) { - // There is already a Facebook account that belongs to you. - // Sign in with that account or delete it, then link it with your current account. - done(); - } else { - const user = await User.create({ - id: req.user.id, - email: profile._json.email, - logins: [ - { name: loginName, key: profile.id }, - ], - claims: [ - { type: claimType, value: profile.id }, - ], - profile: { - displayName: profile.displayName, - gender: profile._json.gender, - picture: `https://graph.facebook.com/${profile.id}/picture?type=large`, - }, - }, { - include: [ - { model: UserLogin, as: 'logins' }, - { model: UserClaim, as: 'claims' }, - { model: UserProfile, as: 'profile' }, - ], - }); - done(null, { - id: user.id, - email: user.email, - }); - } - } else { - const users = await User.findAll({ - attributes: ['id', 'email'], - where: { '$logins.name$': loginName, '$logins.key$': profile.id }, - include: [ - { - attributes: ['name', 'key'], - model: UserLogin, - as: 'logins', - required: true, - }, - ], - }); - if (users.length) { - done(null, users[0]); - } else { - let user = await User.findOne({ where: { email: profile._json.email } }); - if (user) { - // There is already an account using this email address. Sign in to - // that account and link it with Facebook manually from Account Settings. - done(null); - } else { - user = await User.create({ - email: profile._json.email, - emailConfirmed: true, - logins: [ - { name: loginName, key: profile.id }, - ], - claims: [ - { type: claimType, value: accessToken }, - ], - profile: { - displayName: profile.displayName, - gender: profile._json.gender, - picture: `https://graph.facebook.com/${profile.id}/picture?type=large`, - }, - }, { - include: [ - { model: UserLogin, as: 'logins' }, - { model: UserClaim, as: 'claims' }, - { model: UserProfile, as: 'profile' }, - ], - }); - done(null, { - id: user.id, - email: user.email, - }); - } - } - } - }; - - fooBar().catch(done); -})); - -export default passport; diff --git a/src/core/passport/facebook.js b/src/core/passport/facebook.js new file mode 100644 index 000000000..e7c0ce0fe --- /dev/null +++ b/src/core/passport/facebook.js @@ -0,0 +1,144 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +/** + * Passport.js reference implementation. + * The database schema used in this sample is available at + * https://github.com/membership/membership.db/tree/master/postgres + */ + +import passport from 'passport'; +import jwt from 'jsonwebtoken'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { User, UserLogin, UserClaim, UserProfile } from '../../data/models'; +import { auth as config } from '../../config'; + +function passportInit(passportLib: passport) { + /** + * Sign in with Facebook. + */ + passportLib.use(new FacebookStrategy({ + clientID: config.facebook.id, + clientSecret: config.facebook.secret, + callbackURL: '/login/facebook/return', + profileFields: ['name', 'email', 'link', 'locale', 'timezone'], + passReqToCallback: true, + }, (req, accessToken, refreshToken, profile, done) => { + /* eslint-disable no-underscore-dangle */ + const loginName = 'facebook'; + const claimType = 'urn:facebook:access_token'; + const fooBar = async () => { + if (req.user) { + const userLogin = await UserLogin.findOne({ + attributes: ['name', 'key'], + where: { name: loginName, key: profile.id }, + }); + if (userLogin) { + // There is already a Facebook account that belongs to you. + // Sign in with that account or delete it, then link it with your current account. + done(); + } else { + const user = await User.create({ + id: req.user.id, + email: profile._json.email, + logins: [ + { name: loginName, key: profile.id }, + ], + claims: [ + { type: claimType, value: profile.id }, + ], + profile: { + displayName: profile.displayName, + gender: profile._json.gender, + picture: `https://graph.facebook.com/${profile.id}/picture?type=large`, + }, + }, { + include: [ + { model: UserLogin, as: 'logins' }, + { model: UserClaim, as: 'claims' }, + { model: UserProfile, as: 'profile' }, + ], + }); + done(null, { + id: user.id, + email: user.email, + }); + } + } else { + const users = await User.findAll({ + attributes: ['id', 'email'], + where: { '$logins.name$': loginName, '$logins.key$': profile.id }, + include: [ + { + attributes: ['name', 'key'], + model: UserLogin, + as: 'logins', + required: true, + }, + ], + }); + if (users.length) { + done(null, users[0]); + } else { + let user = await User.findOne({ where: { email: profile._json.email } }); + if (user) { + // There is already an account using this email address. Sign in to + // that account and link it with Facebook manually from Account Settings. + done(null); + } else { + user = await User.create({ + email: profile._json.email, + emailConfirmed: true, + logins: [ + { name: loginName, key: profile.id }, + ], + claims: [ + { type: claimType, value: accessToken }, + ], + profile: { + displayName: profile.displayName, + gender: profile._json.gender, + picture: `https://graph.facebook.com/${profile.id}/picture?type=large`, + }, + }, { + include: [ + { model: UserLogin, as: 'logins' }, + { model: UserClaim, as: 'claims' }, + { model: UserProfile, as: 'profile' }, + ], + }); + done(null, { + id: user.id, + email: user.email, + }); + } + } + } + }; + + fooBar().catch(done); + })); +} + +function expressInit(app) { + app.get('/facebook', + passport.authenticate('facebook', { scope: ['email', 'user_location'], session: false }), + ); + app.get('/facebook/return', + passport.authenticate('facebook', { failureRedirect: '/login', session: false }), + (req, res) => { + const expiresIn = 60 * 60 * 24 * 180; // 180 days + const token = jwt.sign(req.user, config.jwt.secret, { expiresIn }); + res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true }); + res.redirect('/'); + }, + ); +} + +export { passportInit, expressInit }; diff --git a/src/core/passport/github.js b/src/core/passport/github.js new file mode 100644 index 000000000..d7eba2f4f --- /dev/null +++ b/src/core/passport/github.js @@ -0,0 +1,169 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +/** + * Passport.js reference implementation. + * The database schema used in this sample is available at + * https://github.com/membership/membership.db/tree/master/postgres + */ + +import passport from 'passport'; +import jwt from 'jsonwebtoken'; +import { Strategy as GitHubStrategy } from 'passport-github2'; +import { User, UserLogin, UserClaim, UserProfile } from '../../data/models'; +import { auth as config, host } from '../../config'; + +const loginName = 'github'; +const claimType = 'urn:github:access_token'; +const routeLogin = '/github'; +const routeLoginCallback = '/github/callback'; +const createUserModel = { + include: [ + { model: UserLogin, as: 'logins' }, + { model: UserClaim, as: 'claims' }, + { model: UserProfile, as: 'profile' }, + ], +}; + +function buildAvatarUrl(profile) { + return `https://avatars0.githubusercontent.com/u/${profile.id}?v=3`; +} + +function getCreateUserOption(req, profile, accessToken) { + /* eslint-disable no-underscore-dangle */ + const options = { + email: profile._json.email, + logins: [ + { name: loginName, key: profile.id }, + ], + claims: [ + ], + profile: { + displayName: profile.displayName, + gender: profile._json.gender, + picture: buildAvatarUrl(profile), + }, + }; + + if (req.user && req.user.id) { + options.id = req.user.id; + options.claims.push({ type: claimType, value: profile.id }); + } else { + options.emailConfirmed = true; + options.claims.push({ type: claimType, value: accessToken }); + } + return options; + /* eslint-enable no-underscore-dangle */ +} + +async function handleCreateUser(profile, done, accessToken, req) { + const user = await User.create( + getCreateUserOption(req, profile, accessToken), + createUserModel, + ); + + done(null, { + id: user.id, + email: user.email, + }); +} + +async function handleSearchUserByProfileId(profile, done) { + const users = await User.findAll({ + attributes: ['id', 'email'], + where: { '$logins.name$': loginName, '$logins.key$': profile.id }, + include: [ + { + attributes: ['name', 'key'], + model: UserLogin, + as: 'logins', + required: true, + }, + ], + }); + + + if (users.length) { + done(null, users[0]); + return true; + } + return false; +} + +async function handleSearchUserByEmail(profile, done) { + // eslint-disable-next-line no-underscore-dangle + const user = await User.findOne({ where: { email: profile._json.email } }); + if (user) { + // There is already an account using this email address. Sign in to + // that account and link it with github manually from Account Settings. + done(null); + return true; + } + return false; +} + +async function handleRequestHasUserObject(profile, done, accessToken, req) { + const userLogin = await UserLogin.findOne({ + attributes: ['name', 'key'], + where: { name: loginName, key: profile.id }, + }); + if (userLogin) { + // There is already a github account that belongs to you. + // Sign in with that account or delete it, then link it with your current account. + done(); + return; + } + + handleCreateUser(profile, done, accessToken, req); +} + +async function handleRequestHasNoUserObject(profile, done, accessToken, req) { + /* eslint no-unused-expressions: ["error", { "allowShortCircuit": true }]*/ + await handleSearchUserByProfileId(profile, done) || + await handleSearchUserByEmail(profile, done) || + await handleCreateUser(profile, done, accessToken, req); +} + +function passportInit(passportLib: passport) { + passportLib.use(new GitHubStrategy({ + clientID: config.github.id, + clientSecret: config.github.secret, + callbackURL: `http://${host}/login${routeLoginCallback}`, + profileFields: ['name', 'email', 'link', 'locale', 'timezone'], + passReqToCallback: true, + }, + (req, accessToken, refreshToken, profile, done) => { + /* eslint-disable no-underscore-dangle */ + + if (req.user) { + handleRequestHasUserObject(profile, done, accessToken, req); + } else { + handleRequestHasNoUserObject(profile, done, accessToken, req); + } + }, +)); +} + +function expressInit(app) { + app.get(routeLogin, + passport.authenticate('github', { scope: ['user:email'], session: false }), + ); + + app.get(routeLoginCallback, + passport.authenticate('github', { failureRedirect: '/login', session: false }), + (req, res) => { + const expiresIn = 60 * 60 * 24 * 180; // 180 days + const token = jwt.sign(req.user, config.jwt.secret, { expiresIn }); + res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true }); + res.redirect('/'); + }, + ); +} + +export { passportInit, expressInit }; diff --git a/src/core/passport/google.js b/src/core/passport/google.js new file mode 100644 index 000000000..ce79dbabd --- /dev/null +++ b/src/core/passport/google.js @@ -0,0 +1,36 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +/** + * Passport.js reference implementation. + * The database schema used in this sample is available at + * https://github.com/membership/membership.db/tree/master/postgres + */ + +/* eslint-disable no-unused-vars */ +import passport from 'passport'; +import jwt from 'jsonwebtoken'; +// import { Strategy as GoogleStrategy } from 'passport-google'; +import { User, UserLogin, UserClaim, UserProfile } from '../../data/models'; +import { auth as config } from '../../config'; +/* eslint-enable no-unused-vars */ + +// eslint-disable-next-line no-unused-vars +function passportInit(passportLib: passport) { + // eslint-disable-next-line no-console + console.log('google passport init need logic'); +} + +// eslint-disable-next-line no-unused-vars +function expressInit(app) { + // eslint-disable-next-line no-console + console.log('google express init need logic'); +} + +export { passportInit, expressInit }; diff --git a/src/core/passport/index.js b/src/core/passport/index.js new file mode 100644 index 000000000..02a14fc6e --- /dev/null +++ b/src/core/passport/index.js @@ -0,0 +1,114 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +/** + * Passport.js reference implementation. + * The database schema used in this sample is available at + * https://github.com/membership/membership.db/tree/master/postgres + */ + +import Promise from 'bluebird'; +import passport from 'passport'; +import expressJwt from 'express-jwt'; +import { Router as ExpressRouter } from 'express'; +import { auth } from '../../config'; + +import * as passportFacebook from './facebook'; +import * as passportGoogle from './google'; +import * as passportGithub from './github'; +import * as passportTwitter from './twitter'; +import * as passportCustom from '../../custom/passport'; + +const fs = require('fs'); + +const pathCustom = '../../custom/passport/'; +let loadedPassportStrategies; + +function detectCustomPassportStrategy(item) { + const customPath = `${pathCustom}${item}.js`; + + return new Promise((fulfilledHandler) => { + fs.exists(customPath, (existsCustom) => { + fulfilledHandler(existsCustom); + }); + }); +} + +function addLoadedStrategy(strategyName) { + loadedPassportStrategies.push(strategyName); +} + +export function getLoadedStrategies() { + return loadedPassportStrategies; +} + +export function strategyIsLoaded(strategyName) { + return strategyName in loadedPassportStrategies; +} + +export function passportInit(app) { + app.use(expressJwt({ + secret: auth.jwt.secret, + credentialsRequired: false, + getToken: req => req.cookies.id_token, + })); + + const router = new ExpressRouter(); + + app.use(passport.initialize()); + + // add a sub router to fix the routing order + app.use('/login', router); + + loadedPassportStrategies = []; + + if (process.env.NODE_ENV !== 'production') { + app.enable('trust proxy'); + } + + const usedAuth = auth.authConfig.usedAuth.split(','); + + const PASSPORT_STRATEGIES = { + facebook: passportFacebook, + google: passportGoogle, + github: passportGithub, + twitter: passportTwitter, + }; + + + Promise.map(usedAuth, (passportStrategyName) => { + // check wanted strategy is a core + if (passportStrategyName in PASSPORT_STRATEGIES === false) { + return null; + } + + // detect custom overwrite + // use callback if not exists + detectCustomPassportStrategy(passportStrategyName) + .then((customExists) => { + if (customExists) { + return null; + } + + PASSPORT_STRATEGIES[passportStrategyName].passportInit(passport); + PASSPORT_STRATEGIES[passportStrategyName].expressInit(router); + addLoadedStrategy(passportStrategyName); + console.log('PassportStrategyLoader: load', passportStrategyName);// eslint-disable-line no-console + return null; + }); + return null; + }) + .then(() => { + // load custom if exists + passportCustom.passportInit(passport); + passportCustom.expressInit(router); + console.log('PassportStrategyLoader: load', 'custom');// eslint-disable-line no-console + return null; + }); +} diff --git a/src/core/passport/twitter.js b/src/core/passport/twitter.js new file mode 100644 index 000000000..676295d85 --- /dev/null +++ b/src/core/passport/twitter.js @@ -0,0 +1,36 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +/** + * Passport.js reference implementation. + * The database schema used in this sample is available at + * https://github.com/membership/membership.db/tree/master/postgres + */ + +/* eslint-disable no-unused-vars */ +import passport from 'passport'; +import jwt from 'jsonwebtoken'; +// import { Strategy as TwitterStrategy } from 'passport-twitter'; +import { User, UserLogin, UserClaim, UserProfile } from '../../data/models'; +import { auth as config } from '../../config'; +/* eslint-enable no-unused-vars */ + +// eslint-disable-next-line no-unused-vars +function passportInit(passportLib: passport) { + // eslint-disable-next-line no-console + console.log('twitter passport init need logic'); +} + +// eslint-disable-next-line no-unused-vars +function expressInit(app) { + // eslint-disable-next-line no-console + console.log('twitter express init need logic'); +} + +export { passportInit, expressInit }; diff --git a/src/custom/passport/index.js b/src/custom/passport/index.js new file mode 100644 index 000000000..39fa8e456 --- /dev/null +++ b/src/custom/passport/index.js @@ -0,0 +1,12 @@ + +// function expressInit(app) +function expressInit() { + + +} +// function passportInit(passportLib) +function passportInit() { + +} + +export { passportInit, expressInit }; diff --git a/src/data/queries/auth.js b/src/data/queries/auth.js new file mode 100644 index 000000000..ce9e9c83a --- /dev/null +++ b/src/data/queries/auth.js @@ -0,0 +1,44 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import { GraphQLList as List } from 'graphql'; +import AuthType from '../types/AuthType'; + +import { getLoadedStrategies } from '../../core/passport'; +import { auth as authConfig } from '../../config'; + + +let items; + +const auth = { + type: new List(AuthType), + resolve() { + if (items && items.length) { + return items; + } + + items = []; + + const loadedStrategies = getLoadedStrategies(); + loadedStrategies.forEach((authName) => { + const loginButtons = authConfig.authConfig.loginButtons[authName]; + items.push({ + loginName: authName, + icon: loginButtons.icon, + buttonClass: loginButtons.buttonClass, + buttonText: loginButtons.buttonText, + routeTo: loginButtons.routeTo, + }); + }); + + return items; + }, +}; + +export default auth; diff --git a/src/data/schema.js b/src/data/schema.js index 6156b07cd..938ae02c1 100644 --- a/src/data/schema.js +++ b/src/data/schema.js @@ -14,6 +14,7 @@ import { import me from './queries/me'; import news from './queries/news'; +import auth from './queries/auth'; const schema = new Schema({ query: new ObjectType({ @@ -21,6 +22,7 @@ const schema = new Schema({ fields: { me, news, + auth, }, }), }); diff --git a/src/data/types/AuthType.js b/src/data/types/AuthType.js new file mode 100644 index 000000000..ca02fe1f0 --- /dev/null +++ b/src/data/types/AuthType.js @@ -0,0 +1,27 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-present Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import { + GraphQLObjectType as ObjectType, + GraphQLString as StringType, + GraphQLNonNull as NonNull, +} from 'graphql'; + +const AuthType = new ObjectType({ + name: 'Auth', + fields: { + loginName: { type: new NonNull(StringType) }, + icon: { type: new NonNull(StringType) }, + buttonClass: { type: new NonNull(StringType) }, + buttonText: { type: new NonNull(StringType) }, + routeTo: { type: new NonNull(StringType) }, + }, +}); + +export default AuthType; diff --git a/src/routes/login/Login.css b/src/routes/login/Login.css index fc31f4b32..2e12c33ba 100644 --- a/src/routes/login/Login.css +++ b/src/routes/login/Login.css @@ -22,136 +22,3 @@ .lead { font-size: 1.25em; } - -.formGroup { - margin-bottom: 15px; -} - -.label { - display: inline-block; - margin-bottom: 5px; - max-width: 100%; - font-weight: 700; -} - -.input { - display: block; - box-sizing: border-box; - padding: 10px 16px; - width: 100%; - height: 46px; - outline: 0; - border: 1px solid #ccc; - border-radius: 0; - background: #fff; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - color: #616161; - font-size: 18px; - line-height: 1.3333333; - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; -} - -.input:focus { - border-color: #0074c2; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6); -} - -.button { - display: block; - box-sizing: border-box; - margin: 0; - padding: 10px 16px; - width: 100%; - outline: 0; - border: 1px solid #373277; - border-radius: 0; - background: #373277; - color: #fff; - text-align: center; - text-decoration: none; - font-size: 18px; - line-height: 1.3333333; - cursor: pointer; -} - -.button:hover { - background: rgba(54, 50, 119, 0.8); -} - -.button:focus { - border-color: #0074c2; - box-shadow: 0 0 8px rgba(0, 116, 194, 0.6); -} - -.facebook { - border-color: #3b5998; - background: #3b5998; - composes: button; -} - -.facebook:hover { - background: #2d4373; -} - -.google { - border-color: #dd4b39; - background: #dd4b39; - composes: button; -} - -.google:hover { - background: #c23321; -} - -.twitter { - border-color: #55acee; - background: #55acee; - composes: button; -} - -.twitter:hover { - background: #2795e9; -} - -.icon { - display: inline-block; - margin: -2px 12px -2px 0; - width: 20px; - height: 20px; - vertical-align: middle; - fill: currentColor; -} - -.lineThrough { - position: relative; - z-index: 1; - display: block; - margin-bottom: 15px; - width: 100%; - color: #757575; - text-align: center; - font-size: 80%; -} - -.lineThrough::before { - position: absolute; - top: 50%; - left: 50%; - z-index: -1; - margin-top: -5px; - margin-left: -20px; - width: 40px; - height: 10px; - background-color: #fff; - content: ''; -} - -.lineThrough::after { - position: absolute; - top: 49%; - z-index: -2; - display: block; - width: 100%; - border-bottom: 1px solid #ddd; - content: ''; -} diff --git a/src/routes/login/Login.js b/src/routes/login/Login.js index 6eb0c0070..c06d35e17 100644 --- a/src/routes/login/Login.js +++ b/src/routes/login/Login.js @@ -9,6 +9,12 @@ import React, { PropTypes } from 'react'; import withStyles from 'isomorphic-style-loader/lib/withStyles'; + +import LoginThirdParty from '../../components/LoginThirdParty'; + +import LoginForm from '../../components/LoginForm'; + +// eslint-disable-next-line import s from './Login.css'; class Login extends React.Component { @@ -22,98 +28,9 @@ class Login extends React.Component {

{this.props.title}

Log in with your username or company email address.

- - - - OR -
-
- - -
-
- - -
-
- -
-
+ + +
); diff --git a/src/routes/login/index.js b/src/routes/login/index.js index 07283124c..655c0e061 100644 --- a/src/routes/login/index.js +++ b/src/routes/login/index.js @@ -17,7 +17,7 @@ export default { path: '/login', - action() { + async action() { return { title, component: , diff --git a/src/server.js b/src/server.js index 144759884..874913856 100644 --- a/src/server.js +++ b/src/server.js @@ -11,9 +11,7 @@ import path from 'path'; import express from 'express'; import cookieParser from 'cookie-parser'; import bodyParser from 'body-parser'; -import expressJwt from 'express-jwt'; import expressGraphQL from 'express-graphql'; -import jwt from 'jsonwebtoken'; import React from 'react'; import ReactDOM from 'react-dom/server'; import UniversalRouter from 'universal-router'; @@ -22,12 +20,12 @@ import App from './components/App'; import Html from './components/Html'; import { ErrorPageWithoutStyle } from './routes/error/ErrorPage'; import errorPageStyle from './routes/error/ErrorPage.css'; -import passport from './core/passport'; +import { passportInit } from './core/passport'; import models from './data/models'; import schema from './data/schema'; import routes from './routes'; import assets from './assets.json'; // eslint-disable-line import/no-unresolved -import { port, auth } from './config'; +import { port } from './config'; const app = express(); @@ -49,28 +47,8 @@ app.use(bodyParser.json()); // // Authentication // ----------------------------------------------------------------------------- -app.use(expressJwt({ - secret: auth.jwt.secret, - credentialsRequired: false, - getToken: req => req.cookies.id_token, -})); -app.use(passport.initialize()); - -if (process.env.NODE_ENV !== 'production') { - app.enable('trust proxy'); -} -app.get('/login/facebook', - passport.authenticate('facebook', { scope: ['email', 'user_location'], session: false }), -); -app.get('/login/facebook/return', - passport.authenticate('facebook', { failureRedirect: '/login', session: false }), - (req, res) => { - const expiresIn = 60 * 60 * 24 * 180; // 180 days - const token = jwt.sign(req.user, auth.jwt.secret, { expiresIn }); - res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true }); - res.redirect('/'); - }, -); + +passportInit(app); // // Register API middleware diff --git a/yarn.lock b/yarn.lock index 944ef011f..04e7d8452 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4689,6 +4689,12 @@ passport-facebook@^2.1.1: dependencies: passport-oauth2 "1.x.x" +passport-github2@^0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/passport-github2/-/passport-github2-0.1.10.tgz#50a21c1e95b83113e4da32c81c2c1a64429bb5bd" + dependencies: + passport-oauth2 "1.x.x" + passport-oauth2@1.x.x: version "1.4.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"