diff --git a/.eslintrc.json b/.eslintrc.json index 8cb839b77718e11cf14b047af0141eccf647f5c6..17a209196275b907b0409e275efd0576ddae02f3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,6 @@ { "extends": "airbnb", + "parser": "babel-eslint", "plugins": [ "react", "jsx-a11y", diff --git a/package.json b/package.json index e7a62217c17665ca333eeda568c64cf576ba81ea..f887a5cf7402844e914274cb2776f0b2ef9d515a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "chalk": "1.1.3", "connect-history-api-fallback": "1.3.0", "cross-spawn": "4.0.2", - "css-loader": "^0.25.0", + "css-loader": "0.25.0", "detect-port": "1.0.1", "dotenv": "2.0.0", "eslint": "3.9.1", @@ -35,19 +35,19 @@ "http-proxy-middleware": "0.17.2", "jest": "16.0.2", "json-loader": "0.5.4", - "node-sass": "^4.1.1", - "nodemon": "^1.11.0", + "node-sass": "4.1.1", + "nodemon": "1.11.0", "object-assign": "4.1.0", "path-exists": "2.1.0", "postcss-loader": "1.0.0", "promise": "7.1.1", "react-dev-utils": "^0.3.0", "recursive-readdir": "2.1.0", - "resolve-url-loader": "^1.6.1", + "resolve-url-loader": "1.6.1", "rimraf": "2.5.4", - "sass-loader": "^4.1.1", + "sass-loader": "4.1.1", "strip-ansi": "3.0.1", - "style-loader": "^0.13.1", + "style-loader": "0.13.1", "url-loader": "0.5.7", "webpack": "1.13.2", "webpack-dev-server": "1.16.2", diff --git a/src/components/NavBar/index.js b/src/components/NavBar/index.js new file mode 100644 index 0000000000000000000000000000000000000000..874bc241c2c7c179b25fdc0ec2282c1b3e792b1c --- /dev/null +++ b/src/components/NavBar/index.js @@ -0,0 +1,53 @@ +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; + +import GoogleLogin from '../../containers/GoogleLogin'; + +export default function NavBar({ SearchBar }) { + return ( +
+
+
+ + + + + + + + + + +
+ + {SearchBar ? :
} + + + +
+ {localStorage.imageUrl && } +
+
+
+ ); +} + +NavBar.propTypes = { + SearchBar: PropTypes.func, +}; diff --git a/src/components/SideBarDetail/index.js b/src/components/SideBarDetail/index.js index 6530ad2c82319cb53851099772a601b64de79076..c801501d08992dbb9d54990591b2ec602e114956 100644 --- a/src/components/SideBarDetail/index.js +++ b/src/components/SideBarDetail/index.js @@ -6,19 +6,17 @@ export default function SideBarDetail({ buildingId }) { const rootURL = `/buildings/${buildingId}`; return (
- Home -
- Overview + Overview
Dimensions
- Weather -
Utilities -
- Occupancy -
- Financials + {/*
*/} + {/* Weather*/} + {/*
*/} + {/* Occupancy*/} + {/*
*/} + {/* Financials*/}
); } diff --git a/src/containers/BuildingList/index.js b/src/containers/BuildingList/index.js deleted file mode 100644 index 4b7040723473b3149c406344f3fe9daefba447d8..0000000000000000000000000000000000000000 --- a/src/containers/BuildingList/index.js +++ /dev/null @@ -1,134 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; - -import { fetchBuildings, searchTerm } from './actions'; -import BuildingListTable from '../../components/BuildingListTable'; -import './styles.scss'; -import { SearchSVG } from '../../components/bpl'; - - -class BuildingList extends Component { - constructor(props) { - super(props); - - this.state = { term: this.props.buildingList.term }; - this.onInputChange = this.onInputChange.bind(this); - this.onFormSubmit = this.onFormSubmit.bind(this); - } - - componentDidMount() { - this.getBuildings(); - } - - onInputChange(event) { - this.setState({ term: event.target.value }); - } - - onFormSubmit(event) { - event.preventDefault(); - this.getBuildings(); - } - - getBuildings() { - this.props.searchTerm(this.state.term); - this.props.fetchBuildings(this.state.term); - } - - render() { - return ( -
-
-
-
- - - - - - - - -
- -
- -
- - Search -
-
-
Address
- -
-
-
- -
-
-   -
-
-
- -
- ); - } -} - -BuildingList.propTypes = { - buildingList: PropTypes.shape({ - buildings: PropTypes.arrayOf( - PropTypes.shape({ - address: PropTypes.string, - bbl: PropTypes.number, - building_id: PropTypes.number, - lot_id: PropTypes.number, - borough: PropTypes.string, - zipcode: PropTypes.number, - }) - ), - term: PropTypes.string, - }), - fetchBuildings: PropTypes.func, - searchTerm: PropTypes.func, -}; - -function mapDispatchToProps(dispatch) { - return bindActionCreators({ fetchBuildings, searchTerm }, dispatch); -} - -function mapStateToProps({ buildingList }) { - return { buildingList }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(BuildingList); diff --git a/src/containers/BuildingOverview/sagas.js b/src/containers/BuildingOverview/sagas.js index 2aa9622c449cb1e0b85619f2d3cc87f4f478020c..2a410499591eb17fbb8889f814f018186ce27c88 100644 --- a/src/containers/BuildingOverview/sagas.js +++ b/src/containers/BuildingOverview/sagas.js @@ -1,5 +1,6 @@ import { call, put, takeEvery } from 'redux-saga/effects'; import request from '../../utils/request'; +import { getHeaders, turkURL, buildingsURL } from '../../utils/rest_services'; import { LOAD_BUILDING_DETAIL, @@ -19,11 +20,6 @@ import { hitDecisionError, } from './actions'; -const BUILDING_SERVICE_URL = `${process.env.REACT_APP_BUILDING_SERVICE}`; -const HEADERS = new Headers({ - 'x-blocpower-app-key': process.env.REACT_APP_KEY, -}); - /** * Detail page request/response handler * @@ -33,9 +29,9 @@ function* getBuildingDetail(action) { const buildingId = action.payload; const data = yield call( request, - `${BUILDING_SERVICE_URL}/building/${buildingId}`, { + `${buildingsURL}${buildingId}`, { method: 'GET', - headers: HEADERS, + headers: getHeaders(), } ); @@ -48,9 +44,9 @@ function* getBuildingDetail(action) { function* loadHit(action) { const buildingId = action.payload; - const data = yield call(request, `${BUILDING_SERVICE_URL}/turkhit/${buildingId}`, { + const data = yield call(request, `${turkURL}${buildingId}`, { method: 'GET', - headers: HEADERS, + headers: getHeaders(), }); if (!data.err) { @@ -67,9 +63,9 @@ function* loadHit(action) { */ function* createHit(action) { const turkHitFormData = action.payload; - const data = yield call(request, `${BUILDING_SERVICE_URL}/turkhit/`, { + const data = yield call(request, turkURL, { method: 'POST', - headers: HEADERS, + headers: getHeaders(), body: JSON.stringify(turkHitFormData), }); @@ -86,9 +82,9 @@ function* createHit(action) { * @param {number} action 1 or 0 representing approve or reject */ function* approveHit(action) { - const data = yield call(request, `${BUILDING_SERVICE_URL}/turkhit/${action.buildingId}`, { + const data = yield call(request, `${turkURL}${action.buildingId}`, { method: 'PUT', - headers: HEADERS, + headers: getHeaders(), body: JSON.stringify({ accept: action.decision }), }); diff --git a/src/containers/GoogleLogin/actions.js b/src/containers/GoogleLogin/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..65d3316d2c061ed4a6a63fa2d9b98a4d5066b0c7 --- /dev/null +++ b/src/containers/GoogleLogin/actions.js @@ -0,0 +1,15 @@ +import { GOOGLE_NAME, GOOGLE_PHOTO } from './constants'; + +export function addName(name) { + return { + type: GOOGLE_NAME, + payload: name, + }; +} + +export function addPhoto(photoURL) { + return { + type: GOOGLE_PHOTO, + payload: photoURL, + }; +} diff --git a/src/containers/GoogleLogin/constants.js b/src/containers/GoogleLogin/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..8b4e33fd161a6a72acafc69df793065d3c2b28c1 --- /dev/null +++ b/src/containers/GoogleLogin/constants.js @@ -0,0 +1,2 @@ +export const GOOGLE_NAME = 'ADD_NAME'; +export const GOOGLE_PHOTO = 'GOOGLE_PHOTO'; diff --git a/src/containers/GoogleLogin/index.js b/src/containers/GoogleLogin/index.js new file mode 100644 index 0000000000000000000000000000000000000000..61b69cf2c6f7001f15750df3c54aab22074dff90 --- /dev/null +++ b/src/containers/GoogleLogin/index.js @@ -0,0 +1,242 @@ +import React, { PropTypes, Component } from 'react'; +import { withRouter, routerShape } from 'react-router'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import { addName, addPhoto } from './actions'; +import request from '../../utils/request'; + +class GoogleLogin extends Component { + static onFailure() { + // TODO handle error + // console.error(err); + } + + static onRequest() { + // TODO add spinner + // console.log('loading...'); + } + + static checkToken() { + return localStorage.gat && new Date() < localStorage.ed; + } + + static loggedIn() { + return new Promise((resolve, reject) => { + if (GoogleLogin.checkToken()) { + const gUrl = 'https://www.googleapis.com/oauth2/v1/tokeninfo'; + request(`${gUrl}?access_token=${localStorage.gat}`, { method: 'POST' }) + .then((res) => { + if (res.err) { + localStorage.clear(); + reject(Error('Invalid token')); + } + resolve(true); + }); + } else { + resolve(false); + } + }); + } + + constructor(props) { + super(props); + + this.state = { + disabled: true, + loggedIn: false, + }; + } + + componentWillMount() { + GoogleLogin.loggedIn().then((res) => { + if (res) { + this.setState({ loggedIn: true }); + } + }).catch(() => { + // TODO handle invalid token error + // console.error(err); + }); + } + + componentDidMount() { + const { scope, cookiePolicy, loginHint } = this.props; + const { name, photoURL } = this.props.googleLogin; + + // Include Google Platform Library + // https://developers.google.com/identity/sign-in/web/reference + const fjs = document.getElementsByTagName('script')[0]; + const js = document.createElement('script'); + js.id = 'google-login'; + js.src = '//apis.google.com/js/client:platform.js'; + fjs.parentNode.insertBefore(js, fjs); + + js.onload = () => { + const params = { + client_id: process.env.REACT_APP_GOOGLE_CLIENT, + cookiepolicy: cookiePolicy, + login_hint: loginHint, + hosted_domain: 'blocpower.org', + scope, + }; + + window.gapi.load('auth2', () => { + this.setState({ + disabled: false, + }); + + if (!window.gapi.auth2.getAuthInstance()) { + window.gapi.auth2.init(params); + } + + if (localStorage.gat) { + if (new Date() > localStorage.ed) { + this.refreshToken(); + } + + if (name && photoURL) { + localStorage.setItem('name', name); + localStorage.setItem('imageUrl', photoURL); + } + } + }); + }; + } + + onSuccess = (authResponse, user, goBack) => { + localStorage.setItem('git', authResponse.id_token); + localStorage.setItem('gat', authResponse.access_token); + localStorage.setItem('ed', authResponse.expires_at); + + if (user) { + localStorage.setItem('name', user.getGivenName()); + localStorage.setItem('imageUrl', user.getImageUrl()); + this.props.addName(user.getGivenName()); + this.props.addPhoto(user.getImageUrl()); + } + this.setState({ loggedIn: true }); + + if (goBack) { + // TODO go back to previous page + this.props.router.push('/'); + } + }; + + refreshToken = () => { + window.gapi.auth2.getAuthInstance().currentUser.get() + .reloadAuthResponse().then((authResponse) => { + this.onSuccess(authResponse); + }); + }; + + signIn = () => { + if (!this.state.disabled) { + const auth2 = window.gapi.auth2.getAuthInstance(); + const { offline, redirectUri, approvalPrompt, prompt } = this.props; + const options = { + redirect_uri: redirectUri, + approval_prompt: approvalPrompt, + prompt, + }; + GoogleLogin.onRequest(); + if (offline) { + auth2.grantOfflineAccess(options) + .then( + res => this.onSuccess(res), + err => GoogleLogin.onFailure(err) + ); + } else { + auth2.signIn(options) + .then((res) => { + const basicProfile = res.getBasicProfile(); + const authResponse = res.getAuthResponse(); + this.onSuccess(authResponse, basicProfile, true); + }, err => + GoogleLogin.onFailure(err) + ); + } + } + }; + + signOut = () => { + // TODO prevent logout button from being pressed before gapi.auth2.init + // has loaded + if (!this.state.disabled) { + window.gapi.auth2.getAuthInstance().signOut(); + localStorage.clear(); + this.setState({ loggedIn: false }); + this.props.router.push('/login'); + } + }; + + render() { + const initialStyle = { + color: 'white', + height: '40px', + marginTop: '15px', + }; + + const disabledStyle = { + opacity: 0.6, + }; + + const defaultStyle = (() => { + if (this.state.disabled) { + return { ...initialStyle, disabledStyle }; + } + return initialStyle; + })(); + + return ( + + ); + } +} + +GoogleLogin.propTypes = { + googleLogin: PropTypes.shape({ + name: PropTypes.string, + photoURL: PropTypes.string, + }), + offline: PropTypes.bool, + scope: PropTypes.string, + redirectUri: PropTypes.string, + cookiePolicy: PropTypes.string, + loginHint: PropTypes.string, + approvalPrompt: PropTypes.string, + prompt: PropTypes.string, + router: routerShape, + addName: PropTypes.func, + addPhoto: PropTypes.func, +}; + +GoogleLogin.defaultProps = { + offline: false, + scope: 'profile email', + redirectUri: 'postmessage', + cookiePolicy: 'single_host_origin', + prompt: '', +}; + +function mapStateToProps({ googleLogin }) { + return { googleLogin }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + addName, + addPhoto, + }, dispatch); +} + +export default withRouter(connect( + mapStateToProps, + mapDispatchToProps +)(GoogleLogin)); diff --git a/src/containers/GoogleLogin/reducer.js b/src/containers/GoogleLogin/reducer.js new file mode 100644 index 0000000000000000000000000000000000000000..32364735c91b6d586362b11cf6300dcdecad55b2 --- /dev/null +++ b/src/containers/GoogleLogin/reducer.js @@ -0,0 +1,20 @@ +import { GOOGLE_NAME, GOOGLE_PHOTO } from './constants'; + +export default function (state = {}, action) { + switch (action.type) { + case GOOGLE_NAME: + return { + ...state, + name: action.payload, + }; + + case GOOGLE_PHOTO: + return { + ...state, + photoURL: action.payload, + }; + + default: + return state; + } +} diff --git a/src/containers/BuildingList/actions.js b/src/containers/SearchBar/actions.js similarity index 56% rename from src/containers/BuildingList/actions.js rename to src/containers/SearchBar/actions.js index 0768281f8540058dca500bfc6b709130e1ad7e44..0fd5e8a0772686e219268e388b7b8d393512a196 100644 --- a/src/containers/BuildingList/actions.js +++ b/src/containers/SearchBar/actions.js @@ -1,18 +1,15 @@ import 'whatwg-fetch'; import { FETCH_BUILDINGS, SEARCH_TERM } from './constants'; +import { getHeaders, buildingsURL } from '../../utils/rest_services'; -const ROOT_URL = `${process.env.REACT_APP_BUILDING_SERVICE}/building/`; -const HEADERS = new Headers({ 'x-blocpower-app-key': process.env.REACT_APP_KEY }); +function fetchBuildings(address) { + const url = `${buildingsURL}?address=${address}`; -const init = { - method: 'GET', - headers: HEADERS, - mode: 'cors', - cache: 'default', -}; + const init = { + method: 'GET', + headers: getHeaders(), + }; -function fetchBuildings(address) { - const url = `${ROOT_URL}?address=${address}`; return { type: FETCH_BUILDINGS, payload: fetch(url, init).then(response => diff --git a/src/containers/BuildingList/constants.js b/src/containers/SearchBar/constants.js similarity index 100% rename from src/containers/BuildingList/constants.js rename to src/containers/SearchBar/constants.js diff --git a/src/containers/SearchBar/index.js b/src/containers/SearchBar/index.js new file mode 100644 index 0000000000000000000000000000000000000000..5000ed5df83a2548439998ce8e9b5e179b0c0875 --- /dev/null +++ b/src/containers/SearchBar/index.js @@ -0,0 +1,84 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { fetchBuildings, searchTerm } from './actions'; +import './styles.scss'; +import { SearchSVG } from '../../components/bpl'; + + +class BuildingList extends Component { + constructor(props) { + super(props); + + this.state = { term: this.props.buildingList.term }; + this.onInputChange = this.onInputChange.bind(this); + this.onFormSubmit = this.onFormSubmit.bind(this); + } + + componentDidMount() { + this.getBuildings(); + } + + onInputChange(event) { + this.setState({ term: event.target.value }); + } + + onFormSubmit(event) { + event.preventDefault(); + this.getBuildings(); + } + + getBuildings() { + this.props.searchTerm(this.state.term); + this.props.fetchBuildings(this.state.term); + } + + render() { + return ( +
+
+
+ +
+ Search +
+
+
Address
+ +
+
+
+
+ ); + } +} + +BuildingList.propTypes = { + buildingList: PropTypes.shape({ + term: PropTypes.string, + }), + fetchBuildings: PropTypes.func, + searchTerm: PropTypes.func, +}; + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ fetchBuildings, searchTerm }, dispatch); +} + +function mapStateToProps({ buildingList }) { + return { buildingList }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(BuildingList); diff --git a/src/containers/BuildingList/reducer.js b/src/containers/SearchBar/reducer.js similarity index 100% rename from src/containers/BuildingList/reducer.js rename to src/containers/SearchBar/reducer.js diff --git a/src/containers/BuildingList/styles.scss b/src/containers/SearchBar/styles.scss similarity index 100% rename from src/containers/BuildingList/styles.scss rename to src/containers/SearchBar/styles.scss diff --git a/src/reducers.js b/src/reducers.js index 82192a2c5c1b8cb9cfca1170b836e3fc470d3cb5..248796275a16072b312dcd8cf15073b220110e3c 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -1,11 +1,13 @@ import { combineReducers } from 'redux'; import { routerReducer } from 'react-router-redux'; -import BuildingListReducer from './containers/BuildingList/reducer'; +import SearchBarReducer from './containers/SearchBar/reducer'; import BuildingOverviewReducer from './containers/BuildingOverview/reducer'; +import GoogleLoginReducer from './containers/GoogleLogin/reducer'; export default combineReducers({ routing: routerReducer, - buildingList: BuildingListReducer, + buildingList: SearchBarReducer, buildingDetail: BuildingOverviewReducer, + googleLogin: GoogleLoginReducer, }); diff --git a/src/routes.js b/src/routes.js index 7e471c18c575a6168cf46f3ff9edfba965dfeccd..ef2a881642aef3532cc2eafddc14f6d29ceb20d6 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,26 +1,32 @@ import React from 'react'; import { Route, IndexRoute, IndexRedirect } from 'react-router'; +import { requireAuth, redirectIfLoggedIn } from './utils/auth'; + +import Login from './screens/Login'; +import NotFound from './screens/NotFound'; +import HomePage from './screens/HomePage'; import BuildingDetail from './screens/BuildingDetail'; -import BuildingList from './containers/BuildingList'; import BuildingOverview from './containers/BuildingOverview'; -import NotFound from './screens/NotFound'; + import Dummy from './components/dummyComponent'; export default ( - - - - - + + + + + + + - + ); diff --git a/src/screens/BuildingDetail/index.js b/src/screens/BuildingDetail/index.js index 8c3f4d9418af00a3744d8fd001f32965c7a64ad3..620e993e60e5fec0df6333648b3b34c8be76f47d 100644 --- a/src/screens/BuildingDetail/index.js +++ b/src/screens/BuildingDetail/index.js @@ -1,10 +1,13 @@ import React, { PropTypes } from 'react'; import SideBarDetail from '../../components/SideBarDetail'; +import NavBar from '../../components/NavBar'; + export default function BuildingDetail(props) { return ( -
+
+
diff --git a/src/screens/HomePage/index.js b/src/screens/HomePage/index.js new file mode 100644 index 0000000000000000000000000000000000000000..31aa3ccc8daa09c20bdefa803f952bd1433449e0 --- /dev/null +++ b/src/screens/HomePage/index.js @@ -0,0 +1,37 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; + +import NavBar from '../../components/NavBar'; +import BuildingList from '../../containers/SearchBar'; +import BuildingListTable from '../../components/BuildingListTable'; + +function HomePage({ buildingList }) { + return ( +
+ + +
+ ); +} + +HomePage.propTypes = { + buildingList: PropTypes.shape({ + buildings: PropTypes.arrayOf( + PropTypes.shape({ + address: PropTypes.string, + bbl: PropTypes.number, + building_id: PropTypes.number, + lot_id: PropTypes.number, + borough: PropTypes.string, + zipcode: PropTypes.number, + }) + ), + term: PropTypes.string, + }), +}; + +function mapStateToProps({ buildingList }) { + return { buildingList }; +} + +export default connect(mapStateToProps)(HomePage); diff --git a/src/screens/Login/index.js b/src/screens/Login/index.js new file mode 100644 index 0000000000000000000000000000000000000000..edddf946d64346e399b1a481e7e3e453c4df9a07 --- /dev/null +++ b/src/screens/Login/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import NavBar from '../../components/NavBar'; + +export default function Login() { + return ( +
+ +

Login to get started.

+
+ ); +} diff --git a/src/utils/auth.js b/src/utils/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..60a894143512749443932a21630f4cda7b54e147 --- /dev/null +++ b/src/utils/auth.js @@ -0,0 +1,20 @@ +import GoogleLogin from '../containers/GoogleLogin'; + +export function requireAuth(nextState, replace) { + // TODO Use GoogleLogin.loggedIn to verify token + if (!GoogleLogin.checkToken()) { + replace({ + pathname: '/login', + state: { nextPathname: nextState.location.pathname }, + }); + } +} + +export function redirectIfLoggedIn(nextState, replace) { + if (GoogleLogin.checkToken()) { + replace({ + pathname: '/', + state: { nextPathname: nextState.location.pathname }, + }); + } +} diff --git a/src/utils/rest_services.js b/src/utils/rest_services.js new file mode 100644 index 0000000000000000000000000000000000000000..04fb1634d10ae80e1b7d7a3cd7f6aff2212b21bb --- /dev/null +++ b/src/utils/rest_services.js @@ -0,0 +1,9 @@ +export function getHeaders() { + return new Headers({ + 'x-blocpower-app-key': process.env.REACT_APP_KEY, + 'x-blocpower-google-token': localStorage.git, + }); +} + +export const buildingsURL = `${process.env.REACT_APP_BUILDING_SERVICE}/building/`; +export const turkURL = `${process.env.REACT_APP_BUILDING_SERVICE}/turkhit/`;