-
-
-
store.dispatch(toggleSidebarCollapsed())} />
-
-
-
-
-
-
-
-
-
-
-
-
-
+
,
document.getElementById('app'),
);
diff --git a/src/components/label-pane.jsx b/src/components/label-pane.jsx
index 4049f90..5228c4b 100644
--- a/src/components/label-pane.jsx
+++ b/src/components/label-pane.jsx
@@ -103,9 +103,11 @@ LabelPane.propTypes = {
editable: PropTypes.bool,
disableProtectedLabels: PropTypes.bool,
showUnselected: PropTypes.bool,
+ // TODO: DRY w/ sidebar.jsx
possibleLabels: PropTypes.objectOf(PropTypes.shape({
color: PropTypes.string,
description: PropTypes.string,
+ name: PropTypes.string.isRequired,
protected: PropTypes.bool,
visbility: PropTypes.string,
})),
diff --git a/src/containers/with-server-data.jsx b/src/containers/with-server-data.jsx
index db785e2..15848a7 100644
--- a/src/containers/with-server-data.jsx
+++ b/src/containers/with-server-data.jsx
@@ -1,14 +1,16 @@
import * as React from 'react';
+export const Loading = () =>
;
+
// Guard the wrapped component behind a loading message, until the required
// server data has been loaded. Currently this is the user info and the
// labels.
export const withAccountInfo = WrappedComponent => props =>
- (props.user ?
:
Loading…
);
+ (props.user ?
:
);
// Guard the wrapped component behind a loading message, until the required
// server data has been loaded. Currently this is the labels.
const withServerData = WrappedComponent => props =>
- (props.labels && props.labels.labelList ?
:
Loading…
);
+ (props.labels && props.labels.labelList ?
:
);
export default withServerData;
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 fcdc6f0..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';
@@ -339,7 +340,9 @@ export default class AddEditEventPage extends React.Component {
}
AddEditEventPage.propTypes = {
+ // TODO: DRY w/ sidebar.jsx
user: PropTypes.shape({ scope: PropTypes.instanceOf(Map) }).isRequired,
+ // TODO: DRY w/ sidebar.jsx
eventData: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string,
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/footer.jsx b/src/sidebar/footer.jsx
index b24973a..33f4fc7 100644
--- a/src/sidebar/footer.jsx
+++ b/src/sidebar/footer.jsx
@@ -2,7 +2,7 @@
import * as React from 'react';
-const Footer = props => (
+const Footer = () => (
);
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 91568a6..111de69 100644
--- a/src/sidebar/sidebar.jsx
+++ b/src/sidebar/sidebar.jsx
@@ -2,6 +2,8 @@
// content merely changes when pages are changed.
import React from 'react';
+import moment from 'moment';
+import PropTypes from 'prop-types';
import LabelPane from '../components/label-pane';
import SidebarHeader from '../components/sidebar-header';
import { authorizationUrl, canSignOut, clearAccessToken } from '../data/auth';
@@ -16,28 +18,51 @@ import SidebarItem from './sidebar-item';
const isProtectedEvent = (event, possibleLabels) =>
event && event.labels.some(label => (possibleLabels[label] || {}).protected);
+const olinBuildLogo = (
+
+ olin.build
+
+);
+
const Sidebar = (props) => {
const {
user: { scope },
sidebarMode: mode,
} = props;
+ const onSignOut = () => {
+ clearAccessToken();
+ window.location.reload();
+ };
+ const sidebarClasses = `app-sidebar${props.isCollapsed ? ' collapsed' : ' expanded'}`;
const content = (
{!scope.has('read:all_events') && (
-
You are viewing the public calendar.
+
You are viewing public events.
- Sign in to view and add Olin
- Community events.
+ Sign in to {olinBuildLogo} to see
+ and add Olin Community events.
)}
+ {mode.FILTER_PANE && (
+ // For viewing the calendar
+
+
+
+ )}
+
{mode.LINK_PANE &&
- scope.has('create:events') && (
+ scope.has('edit:events') && (
{
)}
- {mode.FILTER_PANE && (
- // For viewing the calendar
-
-
-
- )}
-
{mode.GENERATE_ICS_PANE && (
@@ -82,19 +100,23 @@ const Sidebar = (props) => {
)}
+
+ {canSignOut() && (
+ // For viewing the calendar
+
+ Sign out of {olinBuildLogo}. While signed out, you will only be
+ able to view public events when you are outside the intranet.
+
+ )}
);
- const onSignOut = () => {
- clearAccessToken();
- window.location.reload();
- };
- const sidebarClasses = `app-sidebar${props.isCollapsed ? ' collapsed' : ' expanded'}`;
return (
{content}
@@ -104,4 +126,52 @@ const Sidebar = (props) => {
);
};
+Sidebar.propTypes = {
+ // TODO: DRY w/ add-edit-page.js
+ currentEvent: PropTypes.shape({
+ title: PropTypes.string,
+ description: PropTypes.string,
+ start: PropTypes.instanceOf(moment),
+ end: PropTypes.instanceOf(moment),
+ labels: PropTypes.arrayOf(PropTypes.string),
+ }),
+ // TODO: DRY w/ label-pane.js
+ possibleLabels: PropTypes.objectOf(PropTypes.shape({
+ color: PropTypes.string,
+ description: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ protected: PropTypes.bool,
+ visbility: PropTypes.string,
+ })),
+ sidebarMode: PropTypes.shape({
+ LINK_PANE: PropTypes.bool,
+ FILTER_PANE: PropTypes.bool,
+ GENERATE_ICS_PANE: PropTypes.bool,
+ MARKDOWN_GUIDE: PropTypes.bool,
+ EVENT_ACTIONS: PropTypes.bool,
+ EVENT_LABELS_PANE: PropTypes.bool,
+ }).isRequired,
+ isCollapsed: PropTypes.bool.isRequired,
+ selectedLabels: PropTypes.arrayOf(PropTypes.string),
+ // TODO: DRY w/ add-edit-page.js
+ user: PropTypes.shape({ scope: PropTypes.instanceOf(Set) }).isRequired,
+
+ addEvent: PropTypes.func.isRequired,
+ deleteCurrentEvent: PropTypes.func.isRequired,
+ editCurrentEvent: PropTypes.func.isRequired,
+ editEventSeries: PropTypes.func.isRequired,
+ homeClicked: PropTypes.func.isRequired,
+ icsUrlCopiedToClipboard: PropTypes.func.isRequired,
+ importICSClicked: PropTypes.func.isRequired,
+ labelToggled: PropTypes.func.isRequired,
+ setVisibleLabels: PropTypes.func.isRequired,
+ toggleSidebarCollapsed: PropTypes.func.isRequired,
+};
+
+Sidebar.defaultProps = {
+ currentEvent: null,
+ possibleLabels: {},
+ selectedLabels: [],
+};
+
export default Sidebar;
diff --git a/webpack.config.js b/webpack.config.js
index bfcd401..6a4147c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -33,9 +33,10 @@ const config = {
plugins: [
new webpack.EnvironmentPlugin({
ABE_URL: 'http://localhost:3000/',
- ABE_CLIENT_ID: null,
+ CLIENT_ID: null,
DEBUG: true,
GA_ID: null,
+ OAUTH_AUTH_ENDPOINT: null,
}),
],
devServer: {