From 5b14b23f651f80786bce9998b59762415a9be7b0 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Thu, 24 May 2018 14:55:58 -0400 Subject: [PATCH] Move settings to config; enable separate server for OAuth --- .../__snapshots__/sidebar.test.jsx.snap | 2 +- src/__test__/setup.js | 4 +- src/app.jsx | 10 +--- src/data/actions.js | 57 ++++++++++--------- src/data/auth.js | 54 ++++++++++++++---- src/data/reducers.js | 2 +- src/data/settings.js | 13 +++++ src/data/setup-store.js | 7 +-- src/pages/add-edit/add-edit-page.jsx | 11 ++-- src/pages/import/import.jsx | 7 ++- src/pages/subscription/subscription.jsx | 9 +-- src/sidebar/generate-ics-pane.jsx | 7 ++- src/sidebar/sidebar.jsx | 2 +- 13 files changed, 113 insertions(+), 72 deletions(-) create mode 100644 src/data/settings.js diff --git a/src/__test__/__snapshots__/sidebar.test.jsx.snap b/src/__test__/__snapshots__/sidebar.test.jsx.snap index f63dab8..0a14f86 100644 --- a/src/__test__/__snapshots__/sidebar.test.jsx.snap +++ b/src/__test__/__snapshots__/sidebar.test.jsx.snap @@ -498,7 +498,7 @@ exports[`Sidebar renders sign in 1`] = `

Sign in diff --git a/src/__test__/setup.js b/src/__test__/setup.js index 5312b07..5334224 100644 --- a/src/__test__/setup.js +++ b/src/__test__/setup.js @@ -32,7 +32,7 @@ class LocalStorageMock { } } -global.abe_url = 'http://abe_url'; global.localStorage = new LocalStorageMock(); -process.env.ABE_CLIENT_ID = '$abe-client-id'; +process.env.ABE_URL = 'http://abe_url'; +process.env.CLIENT_ID = '$abe-client-id'; diff --git a/src/app.jsx b/src/app.jsx index 9241ef3..3f4c2bf 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -12,16 +12,10 @@ import LabelsContainer from './containers/labels-container'; import SidebarContainer from './containers/sidebar-container'; import SubscriptionContainer from './containers/subscription-container'; import { withAccountInfo } from './containers/with-server-data'; -import { fetchLabels, fetchUser, toggleSidebarCollapsed } from './data/actions'; +import { fetchLabels, fetchAccessInfo, toggleSidebarCollapsed } from './data/actions'; import { initializeAccessToken } from './data/auth'; import setupStore from './data/setup-store'; -// Remove the trailing slash, if present -// -// TODO: change this from a global variable to an API Client that's supplied via -// the provider pattern. -window.abe_url = process.env.ABE_URL.replace(/\/$/, ''); - // React Router (with Redux middleware) const history = createHistory(); @@ -30,7 +24,7 @@ const store = setupStore(history); initializeAccessToken(); -store.dispatch(fetchUser()); +store.dispatch(fetchAccessInfo()); store.dispatch(fetchLabels()); const App = () => ( diff --git a/src/data/actions.js b/src/data/actions.js index 12ad77c..71ccc75 100644 --- a/src/data/actions.js +++ b/src/data/actions.js @@ -1,11 +1,13 @@ // This file contains a bunch of Redux actions import axios from 'axios'; +import _ from 'lodash'; import moment from 'moment'; import ReactGA from 'react-ga'; import { push } from 'react-router-redux'; -import { setAccessTokenFromResponse } from './auth'; +import { getAccessToken, setAccessTokenFromResponse } from './auth'; import { decodeEvent } from './encoding'; +import { API_SERVER_URL, TOKEN_INFO_ENDPOINT } from './settings'; /* eslint-disable max-len */ export const ActionTypes = { @@ -25,7 +27,7 @@ export const ActionTypes = { SET_FILTER_LABEL_SELECTED: 'SET_FILTER_LABEL_SELECTED', // Sets whether or not a specific label is selected as part // of the event filter SET_VIEW_MODE: 'SET_VIEW_MODE', // Sets which view mode (month, week, day, etc) the calendar is in - SET_USER: 'SET_USER', + SET_ACCESS_INFO: 'SET_ACCESS_INFO', // Event data SET_CURRENT_EVENT: 'SET_CURRENT_EVENT', // Keeps track of the data for the event currently being viewed or edited FETCH_EVENTS_IF_NEEDED: 'FETCH_EVENTS_IF_NEEDED', // Triggers FETCH_EVENTS if no event data is loaded @@ -231,24 +233,27 @@ export function setViewMode(mode) { /** * Performs a server request to refresh the user info. */ -export function fetchUser() { - return dispatch => - axios - .get(`${window.abe_url}/user/`) - .then(setAccessTokenFromResponse) - .then(response => response.data, error => dispatch(displayError(error))) - .then(user => dispatch(setUser(user))); -} - -/** - * Updates the user in the Redux store. - */ -export function setUser(user) { - const data = { - authenticated: user.authenticated, - scope: new Set(user.scope), - }; - return { type: ActionTypes.SET_USER, data }; +export function fetchAccessInfo() { + function setAccessInfo(user) { + const token = user.token || {}; + // TODO: remove support for string array scope + const { scope } = user; + const scopes = _.isString(scope) ? user.scope.split(' ') : scope; + const data = { + authenticated: token.active, + scope: new Set(scopes), + }; + return { type: ActionTypes.SET_ACCESS_INFO, data }; + } + const token = getAccessToken(); + return token + ? dispatch => + axios + .get(TOKEN_INFO_ENDPOINT, { params: { token } }) + .then(setAccessTokenFromResponse) + .then(response => response.data, error => dispatch(displayError(error))) + .then(user => dispatch(setAccessInfo(user))) + : dispatch => dispatch(setAccessInfo({})); } // ########## Begin Event Data Actions ########## // @@ -276,8 +281,8 @@ export function setCurrentEventById(id, recId) { } else { // We don't have the data, so request it from the server const url = recId - ? `${window.abe_url}/events/${id}/${recId}` - : `${window.abe_url}/events/${id}`; + ? `${API_SERVER_URL}/events/${id}/${recId}` + : `${API_SERVER_URL}/events/${id}`; axios .get(url) .then(response => dispatch(setCurrentEventData(response.data))) @@ -308,7 +313,7 @@ export function refreshEvents(start, end) { const startString = `${start.year()}-${start.month() + 1}-${start.date()}`; const endString = `${end.year()}-${end.month() + 1}-${end.date()}`; return axios - .get(`${window.abe_url}/events/?start=${startString}&end=${endString}`) + .get(`${API_SERVER_URL}/events/?start=${startString}&end=${endString}`) .then(response => response.data, error => dispatch(displayError(error))) .then((data) => { const events = data.map(decodeEvent); @@ -378,7 +383,7 @@ export function deleteCurrentEvent() { const store = getStore(); const event = store.events.current; axios - .delete(`${window.abe_url}/events/${event.id || event.sid}`) + .delete(`${API_SERVER_URL}/events/${event.id || event.sid}`) .then(() => dispatch(eventDeletedSuccessfully(event.id || event.sid))) .catch(response => eventDeleteFailed(event, response)); }; @@ -511,7 +516,7 @@ export function refreshLabelsIfNeeded() { export function fetchLabels() { return dispatch => axios - .get(`${window.abe_url}/labels/`) + .get(`${API_SERVER_URL}/labels/`) .then(response => response.data, error => dispatch(displayError(error))) .then(labels => dispatch(setLabels(labels))); } @@ -573,7 +578,7 @@ export function updateLabel(data) { return () => { // TODO: update model on success axios - .post(`${window.abe_url}/labels/${data.id}`, data) + .post(`${API_SERVER_URL}/labels/${data.id}`, data) .catch(error => alert(`Update label failed:\n${error}`)); }; } diff --git a/src/data/auth.js b/src/data/auth.js index 0b58ff2..0d0013e 100644 --- a/src/data/auth.js +++ b/src/data/auth.js @@ -1,15 +1,29 @@ import axios from 'axios'; +import { CLIENT_ID, OAUTH_AUTH_ENDPOINT } from './settings'; +// localStorage key const ACCESS_TOKEN_KEY = 'abeAccessToken'; +export const Scopes = new Set([ + 'create:events', + 'create:ics', + 'create:protected_events', + 'delete:events', + 'delete:protected_events', + 'edit:events', + 'edit:protected_events', + 'read:all_events', + 'read:labels', +]); + export function authorizationUrl(redirectUri) { - const oauthBaseUrl = `${window.abe_url}/oauth/authorize`; - const clientId = process.env.ABE_CLIENT_ID; - let url = `${oauthBaseUrl}?redirect_uri=${encodeURIComponent(redirectUri)}`; - url += '&response_type=token'; - if (clientId) { - url += `&client_id=${clientId}`; - } + const url = [ + OAUTH_AUTH_ENDPOINT, + `?client_id=${CLIENT_ID}`, + `&redirect_uri=${encodeURIComponent(redirectUri)}`, + '&response_type=token', + `&scope=${encodeURIComponent([...Scopes].join(' '))}`, + ].join(''); return url; } @@ -18,33 +32,49 @@ export function clearAccessToken() { axios.defaults.headers.common.Authorization = null; } +export function getAccessToken() { + return localStorage[ACCESS_TOKEN_KEY]; +} + +// Remember the access token into localStorage (where it's available the next +// time the page is visited), and also present it in subsequent API requests. export function setAccessToken(accessToken) { localStorage[ACCESS_TOKEN_KEY] = accessToken; axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; } +// Returns true if the user can sign out. export function canSignOut() { - return Boolean(localStorage[ACCESS_TOKEN_KEY]); + return Boolean(getAccessToken()); } +// Remove the OAuth flow parameters from the URL fragment export function removeOauthFragments(url) { return url - .replace(/([#&])(access_token|expires_in|state|token_type)=[^&]*/g, '$1') + .replace(/([#&])(access_token|expires_in|nonce|state|token_type)=[^&]*/g, '$1') .replace(/([#&])&+/, '$1') .replace(/[#&]$/, ''); } +// Called on startup. Reads the access token from localStorage or from the URL +// fragment (for an OAuth implicit grant callback), and sanitizes the URL in +// the latter case. export function initializeAccessToken() { - const token = localStorage[ACCESS_TOKEN_KEY]; + let token = localStorage[ACCESS_TOKEN_KEY]; const match = document.location.hash.match(/[#&]access_token=([^&]*)/); + // If there's a token in the URL, this replaces any locally-stored token. if (match) { - setAccessToken(decodeURIComponent(match[1])); + token = decodeURIComponent(match[1]); window.history.replaceState({}, document.title, removeOauthFragments(window.location.href)); - } else if (token) { + } + if (token) { setAccessToken(token); } } +// Set the token from the 'Access-Token' HTTP response header. +// +// TODO: I think this is obsolete — ows 2018-05-26 export function setAccessTokenFromResponse(response) { const token = response.headers['access-token']; if (token) { diff --git a/src/data/reducers.js b/src/data/reducers.js index c2d127b..179a176 100644 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -25,7 +25,7 @@ export function general(state = {}, action) { export function user(state = {}, action) { switch (action.type) { - case ActionTypes.SET_USER: + case ActionTypes.SET_ACCESS_INFO: return action.data; default: return state; diff --git a/src/data/settings.js b/src/data/settings.js new file mode 100644 index 0000000..367633c --- /dev/null +++ b/src/data/settings.js @@ -0,0 +1,13 @@ +export const API_SERVER_URL = process.env.ABE_URL.replace(/\/$/, ''); +export const OAUTH_AUTH_ENDPOINT = + process.env.OAUTH_AUTH_ENDPOINT || `${API_SERVER_URL}/oauth/authorize`; +// export const TOKEN_INFO_ENDPOINT = process.env.TOKEN_INFO_ENDPOINT || `${API_SERVER_URL}/user/`; +export const TOKEN_INFO_ENDPOINT = `${API_SERVER_URL}/oauth/introspect`; +export const CLIENT_ID = process.env.CLIENT_ID || '0'; + +export const debug = process.env.DEBUG || false; +export const googleAnalyticsId = process.env.GA_ID; + +// TODO: Replace these with some user-configurable option +export const dayEndHour = process.env.DAY_END_HOUR || 24; +export const dayStartHour = process.env.DAY_START_HOUR || 8; diff --git a/src/data/setup-store.js b/src/data/setup-store.js index 7644fce..2a2e227 100644 --- a/src/data/setup-store.js +++ b/src/data/setup-store.js @@ -4,14 +4,9 @@ import { applyMiddleware, combineReducers, createStore } from 'redux'; import thunkMiddleware from 'redux-thunk'; import * as reducers from './reducers'; import SidebarMode from './sidebar-modes'; +import { debug, googleAnalyticsId, dayStartHour, dayEndHour } from './settings'; export default function setupStore(history) { - const debug = process.env.DEBUG || false; - const googleAnalyticsId = process.env.GA_ID; - // TODO: Replace these with some user-configurable option - const dayStartHour = process.env.DAY_START_HOUR || 8; - const dayEndHour = process.env.DAY_END_HOUR || 24; - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); const initialState = { general: { diff --git a/src/pages/add-edit/add-edit-page.jsx b/src/pages/add-edit/add-edit-page.jsx index 5401af9..600b43b 100644 --- a/src/pages/add-edit/add-edit-page.jsx +++ b/src/pages/add-edit/add-edit-page.jsx @@ -16,6 +16,7 @@ import LocationField from './location-field'; import RecurrenceSelector from './recurrence-selector'; import SaveCancelButtons from './save-cancel-buttons'; import EventVisibilitySelector from './visibility-selector'; +import { API_SERVER_URL } from '../../data/settings'; export default class AddEditEventPage extends React.Component { constructor(props) { @@ -58,7 +59,7 @@ export default class AddEditEventPage extends React.Component { // Editing a specific recurrence in a series of events // Get the series data so we know how this event differs from the rest in the series axios - .get(`${window.abe_url}/events/${props.match.params.id}`) + .get(`${API_SERVER_URL}/events/${props.match.params.id}`) .then(this.receivedSuccessfulSeriesDataResponse); // TODO: Handle an unsuccessful response } @@ -154,17 +155,17 @@ export default class AddEditEventPage extends React.Component { } }); - url = `${window.abe_url}/events/${eventData.id || eventData.sid}/${ + url = `${API_SERVER_URL}/events/${eventData.id || eventData.sid}/${ this.props.match.params.recId }`; } else { - url = `${window.abe_url}/events/${eventData.id || eventData.sid}`; + url = `${API_SERVER_URL}/events/${eventData.id || eventData.sid}`; } delete eventData.color; // Don't send the color used for rendering the calendar requestMethod = axios.put; } else { // We're adding a new event - url = `${window.abe_url}/events/`; + url = `${API_SERVER_URL}/events/`; requestMethod = axios.post; } @@ -241,7 +242,7 @@ export default class AddEditEventPage extends React.Component { const { scope } = this.props.user; const editingExisting = this.state.eventData.id || this.state.eventData.sid; - const requiredScope = editingExisting ? 'create:protected_events' : 'edit:protected_events'; + const requiredScope = 'edit:protected_events'; const pageTitle = editingExisting ? 'Edit Event' : 'Add Event'; const submitButtonText = editingExisting ? 'Update Event' : 'Add Event'; const formUrl = 'https://goo.gl/forms/2cqVijokICZ5S20R2'; diff --git a/src/pages/import/import.jsx b/src/pages/import/import.jsx index cd36baa..bb17167 100644 --- a/src/pages/import/import.jsx +++ b/src/pages/import/import.jsx @@ -1,10 +1,11 @@ // This component allows the user to import a calendar from an ICS feed -import * as React from 'react'; import axios from 'axios'; -import SidebarModes from '../../data/sidebar-modes'; +import * as React from 'react'; import TagPane from '../../components/label-pane'; import MenuIconButton from '../../components/menu-icon-button'; +import SidebarModes from '../../data/sidebar-modes'; +import { API_SERVER_URL } from '../../data/settings'; export default class ImportPage extends React.Component { constructor(props) { @@ -36,7 +37,7 @@ export default class ImportPage extends React.Component { submitICS = () => { axios - .post(`${window.abe_url}/ics/`, this.state.importData) + .post(`${API_SERVER_URL}/ics/`, this.state.importData) .then( response => this.props.importSuccess(response, this.state.importData), (jqXHR, textStatus, errorThrown) => this.props.importFailed(errorThrown, jqXHR.message), diff --git a/src/pages/subscription/subscription.jsx b/src/pages/subscription/subscription.jsx index 93d1c6f..08c3abf 100644 --- a/src/pages/subscription/subscription.jsx +++ b/src/pages/subscription/subscription.jsx @@ -7,6 +7,7 @@ import TagPane from '../../components/label-pane'; import MenuIconButton from '../../components/menu-icon-button'; import SidebarModes from '../../data/sidebar-modes'; import docs from '../../docs'; +import { API_SERVER_URL } from '../../data/settings'; export default class SubscriptionEditorPage extends React.Component { constructor(props) { @@ -20,7 +21,7 @@ export default class SubscriptionEditorPage extends React.Component { }; axios - .get(`${window.abe_url}/subscriptions/${this.getIdFromURL(props)}`) + .get(`${API_SERVER_URL}/subscriptions/${this.getIdFromURL(props)}`) .then( response => this.setState({ data: Object.assign({}, this.state.data, response.data) }), (jqXHR, textStatus, errorThrown) => this.props.importFailed(errorThrown, jqXHR.message), @@ -56,7 +57,7 @@ export default class SubscriptionEditorPage extends React.Component { // }; submitSubscription = () => { - axios.put(`${window.abe_url}/subscriptions/${this.state.data.id}`, this.state.data).then( + axios.put(`${API_SERVER_URL}/subscriptions/${this.state.data.id}`, this.state.data).then( (response) => { this.setState({ data: Object.assign({}, this.state.data, response.data) }); this.props.importSuccess(response, response.data); @@ -66,7 +67,7 @@ export default class SubscriptionEditorPage extends React.Component { }; copyToClipboard() { - const url = window.abe_url + this.state.data.ics_url; + const url = API_SERVER_URL + this.state.data.ics_url; copy(url); alert('Link copied to clipboard'); } @@ -99,7 +100,7 @@ export default class SubscriptionEditorPage extends React.Component { />
Import into Outlook diff --git a/src/sidebar/generate-ics-pane.jsx b/src/sidebar/generate-ics-pane.jsx index e4edb13..04fc755 100644 --- a/src/sidebar/generate-ics-pane.jsx +++ b/src/sidebar/generate-ics-pane.jsx @@ -6,6 +6,7 @@ import copy from 'copy-to-clipboard'; import React from 'react'; import { OutboundLink } from 'react-ga'; import docs from '../docs'; +import { API_SERVER_URL } from '../data/settings'; export default class GenerateICSPane extends React.Component { constructor(props) { @@ -31,7 +32,7 @@ export default class GenerateICSPane extends React.Component { const labels = this.props.selectedLabels; - const url = `${window.abe_url}/subscriptions/`; + const url = `${API_SERVER_URL}/subscriptions/`; axios.post(url, { labels }).then( (response) => { @@ -42,7 +43,7 @@ export default class GenerateICSPane extends React.Component { } copyToClipboard(url) { - copy(window.abe_url + url); + copy(API_SERVER_URL + url); this.props.icsUrlCopiedToClipboard(url); alert('Feed URL copied to clipboard'); @@ -86,7 +87,7 @@ export default class GenerateICSPane extends React.Component { Import into Outlook diff --git a/src/sidebar/sidebar.jsx b/src/sidebar/sidebar.jsx index 6de282f..111de69 100644 --- a/src/sidebar/sidebar.jsx +++ b/src/sidebar/sidebar.jsx @@ -62,7 +62,7 @@ const Sidebar = (props) => { )} {mode.LINK_PANE && - scope.has('create:events') && ( + scope.has('edit:events') && (