diff --git a/README.md b/README.md index d437cc3..871cfae 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,8 @@ Set the environment variables found in `set_envs.sample`. #### Important: if running the development server, prefix all `SERVER` variables with http://. The proxy won't work otherwise and all the servers will break + +- Important debug note: due to limitation set by Chrome (which doesn't +allow cookies to be set by localhost), the login feature might not work +properly in Chrome. Therefore, please test with other browsers (e.g. +Safari) or put the site on a standard domain to test. \ No newline at end of file diff --git a/client/actions/index.js b/client/actions/index.js index 0f6c0af..c7d402f 100644 --- a/client/actions/index.js +++ b/client/actions/index.js @@ -64,8 +64,136 @@ export const RESIZE_WINDOW = 'RESIZE_WINDOW'; // Logging - track data, doesn't impact rendering export const LOG_BOUNDS = 'LOG_BOUNDS'; +// User Settings +export const TOGGLE_TRACKING = 'TOGGLE_TRACKING'; +export const OPEN_USER_SETTINGS_PANE = 'OPEN_USER_SETTINGS_PANE'; +export const CLOSE_USER_SETTINGS_PANE = 'CLOSE_USER_SETTINGS_PANE'; +export const OPEN_PROFILE_MANAGER_PANE = 'OPEN_PROFILE_MANAGER_PANE'; +export const CLOSE_PROFILE_MANAGER_PANE = 'CLOSE_PROFILE_MANAGER_PANE'; +export const FETCHING_USER_ROUTING_PROFILE = 'FETCHING_USER_ROUTING_PROFILE'; +export const FINISHED_FETCHING_USER_ROUTING_PROFILE = 'FINISHED_FETCHING_USER_ROUTING_PROFILE'; +export const UPDATE_USER_ROUTING_PROFILE = 'UPDATE_USER_ROUTING_PROFILE'; +export const CHANGE_USER_ROUTING_PROFILE_SELECTION = 'CHANGE_USER_ROUTING_PROFILE_SELECTION'; // Action creators +export function changeUserRoutingProfileSelection (selectedIndex) { + return { + type: CHANGE_USER_ROUTING_PROFILE_SELECTION, + payload: selectedIndex, + meta: { + analytics: { + type: 'change-user-routing-profile-selection', + } + } + }; +} + +export function refreshUserRoutingProfiles() { + return (dispatch) => { + dispatch(isFetchingUserRoutingProfiles(true)); + const loadUserProfiles = require('../utils/api').loadUserProfiles; + loadUserProfiles().then(result => { + if (result.error != null) { + dispatch(updateUserRoutingProfiles(null)); + dispatch(isFetchingUserRoutingProfiles(false)); + return; + } + const profiles = result.data; + dispatch(updateUserRoutingProfiles([])); + dispatch(updateUserRoutingProfiles(profiles)); + dispatch(isFetchingUserRoutingProfiles(false)); + }); + }; +} + +export function isFetchingUserRoutingProfiles(isFetching) { + if (isFetching) { + return { + type: FETCHING_USER_ROUTING_PROFILE, + meta: { + analytics: { + type: 'fetching-user-routing-profile', + } + } + }; + } + return { + type: FINISHED_FETCHING_USER_ROUTING_PROFILE, + meta: { + analytics: { + type: 'finished-fetching-user-routing-profile', + } + } + }; +} + +export function updateUserRoutingProfiles(profiles) { + return { + type: UPDATE_USER_ROUTING_PROFILE, + payload: profiles, + meta: { + analytics: { + type: 'update-user-routing-profile', + } + } + }; +} + +export function openRoutingProfileManager() { + return { + type: OPEN_PROFILE_MANAGER_PANE, + meta: { + analytics: { + type: 'open-routing-profile-manager', + } + } + }; +} + +export function closeRoutingProfileManager() { + return { + type: CLOSE_PROFILE_MANAGER_PANE, + meta: { + analytics: { + type: 'close-routing-profile-manager', + } + } + }; +} + +export function openUserPreferences() { + return { + type: OPEN_USER_SETTINGS_PANE, + meta: { + analytics: { + type: 'open-user-setting', + } + } + }; +} + +export function closeUserPreferences() { + return { + type: CLOSE_USER_SETTINGS_PANE, + meta: { + analytics: { + type: 'close-user-setting', + } + } + }; +} + +export function toggleTracking() { + return { + type: TOGGLE_TRACKING, + meta: { + analytics: { + type: 'toggle-tracking', + } + } + }; +} + export function toggleTripPlanning(planningTrip) { return (dispatch, getState) => { const {waypoints} = getState(); diff --git a/client/components/OpenIDAuth/AuthProviderCallback.js b/client/components/OpenIDAuth/AuthProviderCallback.js new file mode 100644 index 0000000..d2ff78f --- /dev/null +++ b/client/components/OpenIDAuth/AuthProviderCallback.js @@ -0,0 +1,27 @@ +import React from "react"; +import { connect } from "react-redux"; +import { CallbackComponent } from "redux-oidc"; +import { push } from "react-router-redux"; +import userManager from "../../utils/UserManager"; + +class CallbackPage extends React.Component { + render() { + // just redirect to '/' in both cases + return ( + this.props.dispatch(push("/"))} + errorCallback={() => this.props.dispatch(push("/"))} + > +
+
+    Redirecting...

+    If this page take too long to respond, please try logging in again or + contact AccessMap group. +
+
+ ); + } +} + +export default connect()(CallbackPage); diff --git a/client/components/OpenIDAuth/SilentRenew.js b/client/components/OpenIDAuth/SilentRenew.js new file mode 100644 index 0000000..cd7c438 --- /dev/null +++ b/client/components/OpenIDAuth/SilentRenew.js @@ -0,0 +1,17 @@ +import React from "react"; +import { connect } from "react-redux"; +import { processSilentRenew } from 'redux-oidc'; + +class SilentRenewPage extends React.Component { + componentWillMount () { + processSilentRenew(); + } + + render() { + return ( +
+ ); + } +} + +export default connect()(SilentRenewPage); diff --git a/client/components/OpenIDAuth/index.js b/client/components/OpenIDAuth/index.js new file mode 100644 index 0000000..1d5e648 --- /dev/null +++ b/client/components/OpenIDAuth/index.js @@ -0,0 +1,130 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import userManager from '../../utils/UserManager'; +import { loadUserInfo } from '../../utils/api'; +import PropTypes from 'prop-types'; +import { DropdownMenu } from 'react-md/lib/Menus/index'; +import Avatar from 'react-md/lib/Avatars'; +import { + AccessibleFakeButton, + IconSeparator, +} from 'react-md/lib/Helpers'; +import { FontIcon } from 'react-md/lib/FontIcons'; +import * as AppActions from '../../actions'; +import { bindActionCreators } from 'redux'; + +function UserMenu (props) { + const { + actions, + user + } = props; + + const DropdownItems = []; + if (user && (!user.expired)) { + DropdownItems.push({ + primaryText: '[DEBUG] Show Token Info', + onClick: () => { + // TODO: delete debug prompt + alert( + JSON.stringify(user, null, 2), + ); + }, + }, + { + primaryText: '[DEBUG] Get User Info with Token', + onClick: () => loadUserInfo(), + }, + { + primaryText: 'Manage Routing Profiles', + onClick: actions.openRoutingProfileManager, + }); + } + DropdownItems.push({ + primaryText: 'Preferences', + onClick: actions.openUserPreferences, + }, + {divider: true}); + if (!user || user.expired) { + DropdownItems.push({ + primaryText: 'Sign In', + onClick: onSignInButtonClick, + }); + } else { + DropdownItems.push({ + primaryText: 'Manage Account', + onClick: () => window.open( + 'https://accounts.open-to-all.com/auth/realms/OpenToAll/account', + '_blank'), + }, { + primaryText: 'Sign Out', + onClick: onSignOutButtonClick, + }); + } + + const AccountMenu = ({simplifiedMenu}) => ( + + + arrow_drop_down + + } + > + + {user ? user.profile.name.charAt(0) : 'G'} + + + + ); + + AccountMenu.propTypes = { + simplifiedMenu: PropTypes.bool, + }; + + return (); +} + +function onSignInButtonClick (event) { + event.preventDefault(); + userManager.signinRedirect(); +} + +function onSignOutButtonClick (event) { + event.preventDefault(); + userManager.removeUser(); // removes the user data from sessionStorage +} + +function mapStateToProps (state) { + return { + user: state.oidc.user, + }; +} + +// function mapDispatchToProps (dispatch) { +// return { +// dispatch, +// }; +// } + +function mapDispatchToProps (dispatch) { + return { + actions: bindActionCreators(AppActions, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(UserMenu); diff --git a/client/components/Preferences/index.js b/client/components/Preferences/index.js new file mode 100644 index 0000000..c423737 --- /dev/null +++ b/client/components/Preferences/index.js @@ -0,0 +1,62 @@ +/* WithScrollingContent.jsx */ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; +import { connect } from 'react-redux'; +import { DialogContainer } from 'react-md/lib/Dialogs'; +import { Switch } from 'react-md/lib/SelectionControls'; +import * as AppActions from '../../actions'; +import { bindActionCreators } from 'redux'; + +function UserPreferences (props) { + const { + actions, + visible, + preferences, + } = props; + + const contentProps = {id: 'scrolling-content-dialog-content'}; + + return ( + + + + ); +} + +function mapStateToProps (state) { + return { + visible: state.viewVisibility.showUserSettingsPane, + preferences: state.userpreference, + }; +} + +function mapDispatchToProps (dispatch) { + return { + actions: bindActionCreators(AppActions, dispatch) + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(UserPreferences); \ No newline at end of file diff --git a/client/components/RoutingProfileManager/ProfileEntry.js b/client/components/RoutingProfileManager/ProfileEntry.js new file mode 100644 index 0000000..c92fcb1 --- /dev/null +++ b/client/components/RoutingProfileManager/ProfileEntry.js @@ -0,0 +1,154 @@ +import React, { PureComponent } from 'react'; +import SelectionControl from 'react-md/lib/SelectionControls'; +import InclineSlider from 'components/InclineSlider'; +import PropTypes from 'prop-types'; +import { + Card, + CardText, + CardTitle, + CardActions, +} from 'react-md/lib/Cards'; +import TextField from 'react-md/lib/TextFields'; +import Button from 'react-md/lib/Buttons'; +import { deleteUserProfile, updateUserProfile } from '../../utils/api'; + +export default class ProfileEntry extends PureComponent { + static propTypes = { + profileID: PropTypes.string.isRequired, + profileName: PropTypes.string.isRequired, + inclineMin: PropTypes.number.isRequired, + inclineMax: PropTypes.number.isRequired, + inclineIdeal: PropTypes.number.isRequired, + avoidCurbs: PropTypes.bool.isRequired, + avoidConstruction: PropTypes.bool.isRequired, + refreshList: PropTypes.func.isRequired, + }; + + constructor (props) { + super(); + + this.state = { + profileID: props.profileID, + profileName: props.profileName, + tempProfileName: props.profileName, + inclineMin: props.inclineMin, + tempInclineMin: props.inclineMin, + inclineMax: props.inclineMax, + tempInclineMax: props.inclineMax, + inclineIdeal: props.inclineIdeal, + avoidCurbs: props.avoidCurbs, + tempAvoidCurbs: props.avoidCurbs, + avoidConstruction: props.avoidConstruction, + refreshList: props.refreshList, + expanded: false, + }; + } + + render () { + const onSave = () => { + this.setState({expanded: false}); + updateUserProfile(this.state.profileID, { + profileName: this.state.tempProfileName.length > 0 + ? this.state.tempProfileName + : this.state.profileName, + inclineMin: this.state.tempInclineMin, + inclineMax: this.state.tempInclineMax, + inclineIdeal: this.state.inclineIdeal, + avoidCurbs: this.state.tempAvoidCurbs, + avoidConstruction: this.state.avoidConstruction, + }).then((result) => { + if (result.error != null) { + return; + } + this.state.refreshList(); + }); + }; + const onCancel = () => { + this.setState({ + tempProfileName: this.state.profileName, + tempInclineMax: this.state.inclineMax, + tempInclineMin: this.state.inclineMin, + tempAvoidCurbs: this.state.avoidCurbs, + expanded: false, + }); + }; + const onDelete = () => { + this.setState({expanded: false}); + deleteUserProfile(this.state.profileID).then((result) => { + if (result.error != null) { + return; + } + this.state.refreshList(); + }); + }; + + const uphillSlider = ( + this.setState({tempInclineMax: d / 100})} + /> + ); + + const downhillSlider = ( + this.setState({tempInclineMin: -d / 100})} + /> + ); + + const curbrampToggle = ( + this.setState({tempAvoidCurbs: d})} + /> + ); + return ( + this.setState({expanded: !this.state.expanded})} + > + + + { + this.setState({tempProfileName: newName}); + }} + /> + {uphillSlider} + {downhillSlider} + {curbrampToggle} + + + + + + + + ); + } +}; \ No newline at end of file diff --git a/client/components/RoutingProfileManager/ProfileList.js b/client/components/RoutingProfileManager/ProfileList.js new file mode 100644 index 0000000..c268e02 --- /dev/null +++ b/client/components/RoutingProfileManager/ProfileList.js @@ -0,0 +1,129 @@ +/* eslint-disable react/no-array-index-key */ +import React, { PureComponent } from 'react'; +import { + Card, + CardText, + CardTitle, +} from 'react-md/lib/Cards'; +import { Button } from 'react-md/lib/Buttons'; +import { Collapse } from 'react-md/lib/Helpers'; +import { CircularProgress } from 'react-md/lib/Progress'; +import { loadUserProfiles } from '../../utils/api'; +import ProfileEntry from './ProfileEntry'; +import PropTypes from 'prop-types'; + +const ACCESSIBILITY_PROPS = { + 'aria-busy': true, + 'aria-describedby': 'fake-feed-loading-progress', +}; + +export default class ProfileList extends PureComponent { + static propTypes = { + refreshProfileHandler: PropTypes.func.isRequired, + refreshStatusIndicator: PropTypes.bool.isRequired, + profileArray: PropTypes.array.isRequired, + }; + + constructor (props) { + super(); + + this.state = { + notRefreshing: true, + contents: props.profileArray, + refreshProfileHandler: props.refreshProfileHandler, + refreshStatusIndicator: props.refreshStatusIndicator, + }; + } + + componentWillReceiveProps (newProps) { + if (newProps.profileArray !== this.props.profileArray) { + this.setState({contents: newProps.profileArray}); + } + } + + componentWillMount () { + this.refreshContent(); + } + + refreshContent = () => { + // this.setState({notRefreshing: false}); + // + // loadUserProfiles().then(result => { + // if (result.error != null) { + // this.setState({contents: null, notRefreshing: true}); + // return; + // } + // const profiles = result.data; + // this.setState({contents: [], notRefreshing: true}); + // this.setState({contents: profiles, notRefreshing: true}); + // }); + this.state.refreshProfileHandler(); + }; + + render () { + const {notRefreshing, contents} = this.state; + + let accessibilityProps; + if (!notRefreshing) { + accessibilityProps = ACCESSIBILITY_PROPS; + } + + let cards; + if (contents == null) { + cards = ( + + + Error loading profile. + + + ); + } else if (contents.length < 1) { + cards = ( + + + No Profile Found + + + ); + } else { + cards = contents.map(({ + profileID, + userID, + profileName, + inclineMin, + inclineMax, + inclineIdeal, + avoidCurbs, + avoidConstruction, + }) => ( + + )); + } + + const refresh = ; + return ( +
+ {refresh} + +
+ +
+
+
+ {cards} +
+
+ ); + } +} \ No newline at end of file diff --git a/client/components/RoutingProfileManager/index.js b/client/components/RoutingProfileManager/index.js new file mode 100644 index 0000000..d5b428f --- /dev/null +++ b/client/components/RoutingProfileManager/index.js @@ -0,0 +1,61 @@ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; +import { connect } from 'react-redux'; +import { DialogContainer } from 'react-md/lib/Dialogs'; +import * as AppActions from '../../actions'; +import { bindActionCreators } from 'redux'; +import ProfileList from './ProfileList'; + +function RoutingProfileManager (props) { + const { + actions, + visible, + preferences, + userRoutingProfiles, + } = props; + + const contentProps = {id: 'scrolling-content-dialog-content'}; + + return ( + {}} + actions={[ + { + label: 'Close', + primary: true, + onClick: actions.closeRoutingProfileManager, + }]} + width={800} + contentProps={contentProps} + > + + + ); +} + +function mapStateToProps (state) { + return { + visible: state.viewVisibility.showRoutingProfilePane, + userRoutingProfiles: state.userpreference.userRoutingProfiles, + preferences: state.userpreference, + }; +} + +function mapDispatchToProps (dispatch) { + return { + actions: bindActionCreators(AppActions, dispatch), + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(RoutingProfileManager); \ No newline at end of file diff --git a/client/components/RoutingProfileSelector/index.js b/client/components/RoutingProfileSelector/index.js new file mode 100644 index 0000000..32c0254 --- /dev/null +++ b/client/components/RoutingProfileSelector/index.js @@ -0,0 +1,92 @@ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; +import { connect } from 'react-redux'; +import * as AppActions from '../../actions'; +import { bindActionCreators } from 'redux'; + +import List from 'react-md/lib/Lists'; +import SelectField from 'react-md/lib/SelectFields'; +import Button from 'react-md/lib/Buttons'; +import { createUserProfile } from '../../utils/api'; + +export function RoutingProfileSelector (props) { + const { + actions, + userRoutingProfiles, + currentlySelectedProfileIndex, + user, + currentActivatedProfile + } = props; + + const profileItems = []; + for (let i = 0; i < userRoutingProfiles.length; i++) { + profileItems.push({ + label: String(userRoutingProfiles[i].profileName), + value: i, + }); + } + + if (!user) { + return (
); + } + + return ( +
+ { + actions.changeUserRoutingProfileSelection(idx); + actions.setProfile({ + profileName: userRoutingProfiles[idx].profileName, + inclineIdeal: -0.01, + inclineMax: userRoutingProfiles[idx].inclineMax, + inclineMin: userRoutingProfiles[idx].inclineMin, + requireCurbRamps: userRoutingProfiles[idx].avoidCurbs, + }); + }} + onClick={() => actions.refreshUserRoutingProfiles()} + /> + +
+ ); +} + +function mapStateToProps (state) { + return { + userRoutingProfiles: state.userpreference.userRoutingProfiles, + currentlySelectedProfileIndex: state.userpreference.currentlySelectedProfileIndex, + currentActivatedProfile: state.routingprofile, + user: state.oidc.user, + }; +} + +function mapDispatchToProps (dispatch) { + return { + actions: bindActionCreators(AppActions, dispatch), + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(RoutingProfileSelector); \ No newline at end of file diff --git a/client/containers/App/index.js b/client/containers/App/index.js index fdb5763..f8b29b4 100644 --- a/client/containers/App/index.js +++ b/client/containers/App/index.js @@ -10,6 +10,10 @@ import Avatar from 'react-md/lib/Avatars'; import Button from 'react-md/lib/Buttons'; import Toolbar from 'react-md/lib/Toolbars'; +import UserMenu from '../../components/OpenIDAuth/'; +import UserPreferences from '../../components/Preferences' +import RoutingProfileManager from '../../components/RoutingProfileManager' + import AccessMap from 'containers/AccessMap'; import FloatingButtons from 'containers/FloatingButtons'; import OmniCard from 'containers/OmniCard'; @@ -70,9 +74,7 @@ class App extends Component { } themed actions={ - - U - + } fixed zDepth={0} @@ -109,6 +111,8 @@ class App extends Component {
+ + ); } diff --git a/client/containers/OmniCard/index.js b/client/containers/OmniCard/index.js index 4c1978c..fef771d 100644 --- a/client/containers/OmniCard/index.js +++ b/client/containers/OmniCard/index.js @@ -24,6 +24,8 @@ import CaneUserIcon from 'components/Icons/CaneUserIcon'; import PoweredWheelchairIcon from 'components/Icons/PoweredWheelchairIcon'; import WheelchairIcon from 'components/Icons/WheelchairIcon'; +import RoutingProfileSelector from 'components/RoutingProfileSelector'; + import './style.scss'; const OmniCard = (props) => { @@ -247,6 +249,7 @@ const OmniCard = (props) => { > settings + ); diff --git a/client/index.js b/client/index.js index 203bc0d..aedcc2c 100644 --- a/client/index.js +++ b/client/index.js @@ -6,16 +6,21 @@ import './style.scss'; import './fonts/index.css'; import './index.html'; +import { OidcProvider } from 'redux-oidc'; +import userManager from './utils/UserManager'; +import Routes from './routes' + // Note: order matters here (at least with webpack as of 2017-05-22). // If styles + html get imported after App, component-level styling breaks /* eslint-disable import/first */ -import App from 'containers/App'; import store from 'store'; /* eslint-enable import/first */ ReactDOM.render( - + + + , document.getElementById('root') ); diff --git a/client/reducers/defaults.js b/client/reducers/defaults.js index f0704e7..160471b 100644 --- a/client/reducers/defaults.js +++ b/client/reducers/defaults.js @@ -53,3 +53,16 @@ export const defaultMode = 'UPHILL'; export const defaultBrowser = { mediaType: null, }; + +export const defaultUserPreferences = { + showUserSettingsPane: false, + enableTracking: true, + routingProfiles:[], + currentlySelectedProfileIndex: -1, + fetchingUserRoutingProfiles: false, +}; + +export const defaultViewVisbility = { + showUserSettingsPane: false, + showRoutingProfileManager: false, +}; \ No newline at end of file diff --git a/client/reducers/index.js b/client/reducers/index.js index 04add39..9a1e47d 100644 --- a/client/reducers/index.js +++ b/client/reducers/index.js @@ -11,6 +11,11 @@ import tripplanning from './tripplanning'; import view from './view'; import waypoints from './waypoints'; +import { routerReducer } from 'react-router-redux'; +import { reducer as oidcReducer } from 'redux-oidc'; +import userPreference from './user-preference'; +import viewVisibility from './view-visibility'; + /** * Routing to be implemented */ @@ -25,4 +30,8 @@ export default combineReducers({ tripplanning, view, waypoints, + routing: routerReducer, + oidc: oidcReducer, + userpreference: userPreference, + viewVisibility: viewVisibility, }); diff --git a/client/reducers/routing-profile.js b/client/reducers/routing-profile.js index 4f0c58f..160c5d2 100644 --- a/client/reducers/routing-profile.js +++ b/client/reducers/routing-profile.js @@ -34,6 +34,8 @@ const handleInclineIdeal = (state = defaults.inclineIdeal, action) => { case 'cane': case 'custom': return -0.01 + default: + return action.payload.inclineIdeal; } case SET_INCLINE_IDEAL: return action.payload; @@ -55,6 +57,7 @@ const handleInclineMax = (state = defaults.inclineMax, action) => { case 'custom': return state; default: + return action.payload.inclineMax; } case SET_INCLINE_MAX: return action.payload; @@ -75,6 +78,8 @@ const handleInclineMin = (state = defaults.inclineMin, action) => { profiles.cane.inclineMin; case 'custom': return state; + default: + return action.payload.inclineMin; } case SET_INCLINE_MIN: return action.payload; @@ -95,6 +100,8 @@ const handleCurbRamps = (state = defaults.requireCurbRamps, action) => { return false case 'custom': return state; + default: + return action.payload.requireCurbRamps; } case TOGGLE_CURBRAMPS: return !state; diff --git a/client/reducers/user-preference.js b/client/reducers/user-preference.js new file mode 100644 index 0000000..5f36f46 --- /dev/null +++ b/client/reducers/user-preference.js @@ -0,0 +1,57 @@ +import { + TOGGLE_TRACKING, + UPDATE_USER_ROUTING_PROFILE, + FETCHING_USER_ROUTING_PROFILE, + FINISHED_FETCHING_USER_ROUTING_PROFILE, + CHANGE_USER_ROUTING_PROFILE_SELECTION, +} from 'actions'; + +import { defaultUserPreferences as defaults } from './defaults'; +import { combineReducers } from 'redux'; + +function handleUserTracking (state = defaults.enableTracking, action) { + switch (action.type) { + case TOGGLE_TRACKING: + return !state; + default: + return state; + } +} + +function handleUpdateUserRoutingProfiles ( + state = defaults.routingProfiles, action) { + switch (action.type) { + case UPDATE_USER_ROUTING_PROFILE: + return action.payload; + default: + return state; + } +} + +function handleFetchingUserRoutingProfiles ( + state = defaults.fetchingUserRoutingProfiles, action) { + switch (action.type) { + case FETCHING_USER_ROUTING_PROFILE: + return true; + case FINISHED_FETCHING_USER_ROUTING_PROFILE: + return false; + default: + return state; + } +} + +function handleChangeUserRoutingProfileSelection ( + state = defaults.fetchingUserRoutingProfiles, action) { + switch (action.type) { + case CHANGE_USER_ROUTING_PROFILE_SELECTION: + return action.payload; + default: + return state; + } +} + +export default combineReducers({ + enableTracking: handleUserTracking, + userRoutingProfiles: handleUpdateUserRoutingProfiles, + fetchingUserRoutingProfiles: handleFetchingUserRoutingProfiles, +}); \ No newline at end of file diff --git a/client/reducers/user-settings.js b/client/reducers/user-settings.js new file mode 100644 index 0000000..91b497b --- /dev/null +++ b/client/reducers/user-settings.js @@ -0,0 +1,16 @@ +import { + SET_TRACKING +} from 'actions'; + +import { defaultUserSettings as defaults } from './defaults'; + +export default function handle(state = defaults, action) { + switch (action.type) { + case SET_TRACKING: + return { + track: action.payload + }; + default: + return state; + } +} diff --git a/client/reducers/view-visibility.js b/client/reducers/view-visibility.js new file mode 100644 index 0000000..06f06c3 --- /dev/null +++ b/client/reducers/view-visibility.js @@ -0,0 +1,35 @@ +import { + OPEN_USER_SETTINGS_PANE, + CLOSE_USER_SETTINGS_PANE, + OPEN_PROFILE_MANAGER_PANE, + CLOSE_PROFILE_MANAGER_PANE +} from 'actions'; +import { defaultViewVisbility as defaults } from './defaults'; +import { combineReducers } from 'redux'; + +function handleUserSettingsPane(state = defaults.showUserSettingsPane, action) { + switch (action.type) { + case OPEN_USER_SETTINGS_PANE: + return true; + case CLOSE_USER_SETTINGS_PANE: + return false; + default: + return state; + } +} + +function handleRoutingProfileManagerPane(state = defaults.showRoutingProfileManager, action) { + switch (action.type) { + case OPEN_PROFILE_MANAGER_PANE: + return true; + case CLOSE_PROFILE_MANAGER_PANE: + return false; + default: + return state; + } +} + +export default combineReducers({ + showUserSettingsPane: handleUserSettingsPane, + showRoutingProfilePane: handleRoutingProfileManagerPane +}); \ No newline at end of file diff --git a/client/routes/index.js b/client/routes/index.js new file mode 100644 index 0000000..1b2b832 --- /dev/null +++ b/client/routes/index.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Router, Route, browserHistory } from 'react-router'; +import store from '../store'; +import { syncHistoryWithStore } from 'react-router-redux'; +import App from '../containers/App'; +import AuthProviderCallbackPage from '../components/OpenIDAuth/AuthProviderCallback'; +import SilentRenewPage from '../components/OpenIDAuth/SilentRenew'; + +const history = syncHistoryWithStore(browserHistory, store); + +export default function Routes(props) { + return ( + + + + + + ); +} diff --git a/client/store/index.js b/client/store/index.js index c05850f..6eecbbd 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -1,4 +1,8 @@ import { createStore, applyMiddleware } from 'redux'; +import { loadUser } from "redux-oidc"; +import userManager from "../utils/UserManager"; +import { browserHistory } from "react-router"; +import { routerMiddleware } from "react-router-redux"; import thunkMiddleware from 'redux-thunk'; import analytics from 'redux-analytics'; @@ -12,7 +16,25 @@ middlewares.push(thunkMiddleware); /* eslint-disable global-require */ if (process.env.NODE_ENV === 'development') { const { logger } = require('redux-logger'); + + // Rakam analytics support - using npm package appears to be uncommon, but + // is nice for consistency and bundling + rakam.init(`${process.env.ANALYTICS_KEY}`, null, { + apiEndpoint:`${process.env.ANALYTICS_SERVER}`, + includeUtm: true, + trackClicks: true, + trackForms: true, + includeReferrer: true + }); + + const analyticsMiddleware = analytics(({ type, payload }, state) => { + if (state.userpreference.enableTracking || process.env.NODE_ENV === 'development') { + rakam.logEvent(type, { ...state.analytics, ...payload }); + } + }); + middlewares.push(logger); + middlewares.push(analyticsMiddleware); } // @@ -43,9 +65,12 @@ if (useAnalytics) { } +middlewares.push(routerMiddleware(browserHistory)); + const store = createStore( rootReducer, applyMiddleware(...middlewares) ); +loadUser(store, userManager); export default store; diff --git a/client/utils/UserManager.js b/client/utils/UserManager.js new file mode 100644 index 0000000..461c4bb --- /dev/null +++ b/client/utils/UserManager.js @@ -0,0 +1,19 @@ +import { createUserManager } from 'redux-oidc'; +import { WebStorageStateStore } from 'oidc-client' + +const userManagerConfig = { + client_id: 'test-dev', + redirect_uri: `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/callback`, + response_type: 'token id_token', + scope: 'openid profile offline_access', + authority: 'https://accounts.open-to-all.com/auth/realms/OpenToAll', + silent_redirect_uri: `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/silent_renew`, + automaticSilentRenew: true, + filterProtocolClaims: true, + loadUserInfo: true, + userStore: new WebStorageStateStore({ store: window.localStorage }) +}; + +const userManager = createUserManager(userManagerConfig); + +export default userManager; \ No newline at end of file diff --git a/client/utils/api.js b/client/utils/api.js new file mode 100644 index 0000000..381f9fa --- /dev/null +++ b/client/utils/api.js @@ -0,0 +1,60 @@ +import store from "../store"; + +export function loadUserInfo() { + const url = + "https://accounts.open-to-all.com/auth/realms/OpenToAll/protocol/openid-connect/userinfo"; + + return apiRequest(url).then(result => { + const note = "\n\nNote: If the auth token expires, then you won't be able to " + + "see the any user info above. This could be caused by automatic renew " + + "not working properly in this browser."; + alert(JSON.stringify(result.data, null, 2) + note); + }); +} + +export function loadUserProfiles() { + const url = + "http://localhost:4040/api/profiles"; + + return apiRequest(url); +} + +export function deleteUserProfile(profileID) { + const url = + `http://localhost:4040/api/profile?profileID=${profileID}`; + + return apiRequest(url, "DELETE"); +} + +export function updateUserProfile(profileID, newProfile) { + const url = + `http://localhost:4040/api/profile?profileID=${profileID}`; + + return apiRequest(url, "PUT", newProfile); +} + +export function createUserProfile(newProfile) { + const url = + `http://localhost:4040/api/profile`; + + return apiRequest(url, "POST", newProfile); +} + +// a request helper which reads the access_token from the redux state and passes it in its HTTP request +function apiRequest(url, method = "GET", body = undefined) { + const token = store.getState().oidc.user.access_token; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + headers.append("Authorization", `Bearer ${token}`); + + const options = { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }; + + return fetch(url, options) + .then(res => res.json()) + .then(data => ({ data })) + .catch(error => ({ error })); +} diff --git a/package.json b/package.json index b6cf894..3db9366 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,13 @@ "@turf/bbox-polygon": "4.7.3", "@turf/inside": "^4.5.2", "chroma-js": "^1.3.3", + "classnames": "^2.2.5", "immutable": "^3.8.1", "lodash.throttle": "^4.1.1", "mapbox": "^1.0.0-beta7", "mapbox-gl": "0.42.2", "node-sass": "^4.5.3", + "oidc-client": "^1.3.0", "prop-types": "15.5.10", "rakam-js": "^1.0.3", "react": "^16.2.0", @@ -56,10 +58,14 @@ "react-mapbox-gl": "^3.5.1", "react-md": "^1.2.11", "react-redux": "^5.0.6", + "react-router": "^3.2.0", + "react-router-redux": "^4.0.8", "react-transition-group": "^2.2.1", "redux": "3.6.0", "redux-analytics": "^0.3.1", "redux-logger": "^3.0.6", + "redux-oidc": "^3.0.0-beta.14", + "redux-saga": "^0.10.4", "redux-thunk": "^2.2.0", "sass-loader": "^6.0.5", "transform-loader": "^0.2.4"