From 5b14b23f651f80786bce9998b59762415a9be7b0 Mon Sep 17 00:00:00 2001
From: Oliver Steele
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') && (