Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move settings to config; enable separate server for OAuth #255

Merged
merged 1 commit into from
May 26, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/__test__/__snapshots__/sidebar.test.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ exports[`Sidebar renders sign in 1`] = `
</p>
<p>
<a
href="http://abe_url/oauth/authorize?redirect_uri=about%3Ablank&response_type=token&client_id=$abe-client-id"
href="http://abe_url/oauth/authorize?client_id=$abe-client-id&redirect_uri=about%3Ablank&response_type=token&scope=create%3Aevents%20create%3Aics%20create%3Aprotected_events%20delete%3Aevents%20delete%3Aprotected_events%20edit%3Aevents%20edit%3Aprotected_events%20read%3Aall_events%20read%3Alabels"
>
Sign in
</a>
Expand Down
4 changes: 2 additions & 2 deletions src/__test__/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
10 changes: 2 additions & 8 deletions src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -30,7 +24,7 @@ const store = setupStore(history);

initializeAccessToken();

store.dispatch(fetchUser());
store.dispatch(fetchAccessInfo());
store.dispatch(fetchLabels());

const App = () => (
Expand Down
57 changes: 31 additions & 26 deletions src/data/actions.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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 ########## //
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
};
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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}`));
};
}
Expand Down
54 changes: 42 additions & 12 deletions src/data/auth.js
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/data/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/data/settings.js
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 1 addition & 6 deletions src/data/setup-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 6 additions & 5 deletions src/pages/add-edit/add-edit-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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';
Expand Down
7 changes: 4 additions & 3 deletions src/pages/import/import.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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),
Expand Down
Loading