diff --git a/src/components/UI/ErrorBoundary/ErrorBoundary.js b/src/components/UI/ErrorBoundary/ErrorBoundary.js
index d0d0e364d930..a8447e6d99a1 100644
--- a/src/components/UI/ErrorBoundary/ErrorBoundary.js
+++ b/src/components/UI/ErrorBoundary/ErrorBoundary.js
@@ -1,35 +1,36 @@
import PropTypes from 'prop-types';
import React from 'react';
-const ErrorComponent = () => {
- const issueUrl = "https://github.com/netlify/netlify-cms/issues/new";
- return (
-
-
Sorry!
-
- There's been an error - please
- report it!
-
-
- );
+const DefaultErrorComponent = () => {
};
-export class ErrorBoundary extends React.Component {
- static propTypes = {
- render: PropTypes.element,
- };
+const ISSUE_URL = "https://github.com/netlify/netlify-cms/issues/new";
+export class ErrorBoundary extends React.Component {
state = {
hasError: false,
+ errorMessage: '',
};
componentDidCatch(error) {
console.error(error);
- this.setState({ hasError: true });
+ this.setState({ hasError: true, errorMessage: error.toString() });
}
render() {
- const errorComponent = this.props.errorComponent ||
;
- return this.state.hasError ? errorComponent : this.props.children;
+ const { hasError, errorMessage } = this.state;
+ if (!hasError) {
+ return this.props.children;
+ }
+ return (
+
+
Sorry!
+
+ There's been an error - please
+ report it!
+
+
{errorMessage}
+
+ );
}
}
diff --git a/src/components/UI/Icon/images/_index.js b/src/components/UI/Icon/images/_index.js
index f974d8736b06..352280f40bbe 100644
--- a/src/components/UI/Icon/images/_index.js
+++ b/src/components/UI/Icon/images/_index.js
@@ -13,6 +13,7 @@ import iconDragHandle from './drag-handle.svg';
import iconEye from './eye.svg';
import iconFolder from './folder.svg';
import iconGithub from './github.svg';
+import iconGitlab from './gitlab.svg';
import iconGrid from './grid.svg';
import iconH1 from './h1.svg';
import iconH2 from './h2.svg';
@@ -55,6 +56,7 @@ const images = {
'eye': iconEye,
'folder': iconFolder,
'github': iconGithub,
+ 'gitlab': iconGitlab,
'grid': iconGrid,
'h1': iconH1,
'h2': iconH2,
diff --git a/src/components/UI/Icon/images/gitlab.svg b/src/components/UI/Icon/images/gitlab.svg
new file mode 100644
index 000000000000..9d3134afcaf3
--- /dev/null
+++ b/src/components/UI/Icon/images/gitlab.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/lib/implicit-oauth.js b/src/lib/implicit-oauth.js
new file mode 100644
index 000000000000..cf5f2a0a3c4f
--- /dev/null
+++ b/src/lib/implicit-oauth.js
@@ -0,0 +1,74 @@
+import { Map } from 'immutable';
+import { trim, trimEnd } from 'lodash';
+import { randomStr } from 'Lib/randomGenerator';
+import history from 'Routing/history';
+
+function createNonce() {
+ const nonce = randomStr();
+ window.sessionStorage.setItem("netlify-cms-auth", JSON.stringify({ nonce }));
+ return nonce;
+}
+
+function validateNonce(check) {
+ const auth = window.sessionStorage.getItem("netlify-cms-auth");
+ const valid = auth && JSON.parse(auth).nonce;
+ window.localStorage.removeItem("netlify-cms-auth");
+ return (check === valid);
+}
+
+export default class ImplicitAuthenticator {
+ constructor(config = {}) {
+ const baseURL = trimEnd(config.base_url, '/');
+ const authEndpoint = trim(config.auth_endpoint, '/');
+ this.auth_url = `${ baseURL }/${ authEndpoint }`;
+ this.appID = config.app_id;
+ }
+
+ authenticate(options, cb) {
+ if (
+ document.location.protocol !== "https:"
+ // TODO: Is insecure localhost a bad idea as well? I don't think it is, since you are not actually
+ // sending the token over the internet in this case, assuming the auth URL is secure.
+ && (document.location.hostname !== "localhost" && document.location.hostname !== "127.0.0.1")
+ ) {
+ return cb(new Error("Cannot authenticate over insecure protocol!"));
+ }
+
+ const authURL = new URL(this.auth_url);
+ authURL.searchParams.set('client_id', this.appID);
+ authURL.searchParams.set('redirect_uri', document.location.origin + document.location.pathname);
+ authURL.searchParams.set('response_type', 'token');
+ authURL.searchParams.set('scope', options.scope);
+ authURL.searchParams.set('state', createNonce());
+
+ document.location.assign(authURL.href);
+ }
+
+ /**
+ * Complete authentication if we were redirected back to from the provider.
+ */
+ completeAuth(cb) {
+ const hashParams = new URLSearchParams(document.location.hash.replace(/^#?\/?/, ''));
+ if (!hashParams.has("access_token") && !hashParams.has("error")) {
+ return;
+ }
+ // Remove tokens from hash so that token does not remain in browser history.
+ history.replace('/');
+
+ const params = Map(hashParams.entries());
+
+ const validNonce = validateNonce(params.get('state'));
+ if (!validNonce) {
+ return cb(new Error("Invalid nonce"));
+ }
+
+ if (params.has('error')) {
+ return cb(new Error(`${ params.get('error') }: ${ params.get('error_description') }`));
+ }
+
+ if (params.has('access_token')) {
+ const { access_token: token, ...data } = params.toJS();
+ cb(null, { token, ...data });
+ }
+ }
+}
diff --git a/src/lib/promiseHelper.js b/src/lib/promiseHelper.js
index 0d16bd5ec8d4..e4866d9bcff0 100644
--- a/src/lib/promiseHelper.js
+++ b/src/lib/promiseHelper.js
@@ -18,3 +18,5 @@ export const resolvePromiseProperties = (obj) => {
// resolved values
Object.assign({}, obj, zipObject(promiseKeys, resolvedPromises)));
};
+
+export const then = fn => p => Promise.resolve(p).then(fn);
diff --git a/src/lib/randomGenerator.js b/src/lib/randomGenerator.js
index 7d73aadc08c7..51831702df57 100644
--- a/src/lib/randomGenerator.js
+++ b/src/lib/randomGenerator.js
@@ -2,30 +2,17 @@
* Random number generator
*/
-let rng;
-
-if (window.crypto && crypto.getRandomValues) {
- // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
- // Moderately fast, high quality
- const _rnds32 = new Uint32Array(1);
- rng = function whatwgRNG() {
- crypto.getRandomValues(_rnds32);
- return _rnds32[0];
- };
+const padNumber = (num, base) => {
+ const padLen = (32 / Math.sqrt(base));
+ const str = num.toString(base);
+ return (('0' * padLen) + str).slice(-padLen);
}
-if (!rng) {
- // Math.random()-based (RNG)
- // If no Crypto available, use Math.random().
- rng = function() {
- const r = Math.random() * 0x100000000;
- const _rnds = r >>> 0;
- return _rnds;
- };
-}
+export function randomStr(len = 256) {
+ const _rnds = new Uint32Array(Math.ceil(len / 32));
+ window.crypto.getRandomValues(_rnds);
-export function randomStr() {
- return rng().toString(36);
-}
+ const str = _rnds.reduce((agg, val) => (agg + padNumber(val, 16)), '');
-export default rng;
+ return str.slice(-len);
+}
\ No newline at end of file
diff --git a/src/lib/unsentRequest.js b/src/lib/unsentRequest.js
new file mode 100644
index 000000000000..19f3cec7156f
--- /dev/null
+++ b/src/lib/unsentRequest.js
@@ -0,0 +1,79 @@
+import { fromJS, List, Map } from 'immutable';
+import { curry, flow, isString } from "lodash";
+
+const decodeParams = paramsString => List(paramsString.split("&"))
+ .map(s => List(s.split("=")).map(decodeURIComponent))
+ .update(Map);
+
+const fromURL = wholeURL => {
+ const [url, allParamsString] = wholeURL.split("?");
+ return Map({ url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) });
+};
+
+const encodeParams = params => params.entrySeq()
+ .map(([k, v]) => `${ encodeURIComponent(k) }=${ encodeURIComponent(v) }`)
+ .join("&");
+
+const toURL = req => `${ req.get("url") }${ req.get("params") ? `?${ encodeParams(req.get("params")) }` : "" }`;
+
+const toFetchArguments = req => [toURL(req), req.delete("url").delete("params").toJS()];
+
+const maybeRequestArg = req => {
+ if (isString(req)) { return fromURL(req); }
+ if (req) { return fromJS(req); }
+ return Map();
+};
+const ensureRequestArg = func => req => func(maybeRequestArg(req));
+const ensureRequestArg2 = func => (arg, req) => func(arg, maybeRequestArg(req));
+
+// This actually performs the built request object
+const performRequest = ensureRequestArg(req => fetch(...toFetchArguments(req)));
+
+// Each of the following functions takes options and returns another
+// function that performs the requested action on a request. They each
+// default to containing an empty object, so you can simply call them
+// without arguments to generate a request with only those properties.
+const getCurriedRequestProcessor = flow([ensureRequestArg2, curry]);
+const getPropSetFunctions = path => [
+ getCurriedRequestProcessor((val, req) => req.setIn(path, val)),
+ getCurriedRequestProcessor((val, req) => (req.getIn(path) ? req : req.setIn(path, val))),
+];
+const getPropMergeFunctions = path => [
+ getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p=Map()) => p.merge(obj))),
+ getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p=Map()) => Map(obj).merge(p))),
+];
+
+const [withMethod, withDefaultMethod] = getPropSetFunctions(["method"]);
+const [withBody, withDefaultBody] = getPropSetFunctions(["method"]);
+const [withParams, withDefaultParams] = getPropMergeFunctions(["params"]);
+const [withHeaders, withDefaultHeaders] = getPropMergeFunctions(["headers"]);
+
+// withRoot sets a root URL, unless the URL is already absolute
+const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
+const withRoot = getCurriedRequestProcessor((root, req) => req.update("url", p => {
+ if (absolutePath.test(p)) { return p; }
+ return (root && p && p[0] !== "/" && root[root.length - 1] !== "/")
+ ? `${ root }/${ p }`
+ : `${ root }${ p }`;
+}));
+
+// withTimestamp needs no argument and has to run as late as possible,
+// so it calls `withParams` only when it's actually called with a
+// request.
+const withTimestamp = ensureRequestArg(req => withParams({ ts: new Date().getTime() }, req));
+
+export default {
+ toURL,
+ fromURL,
+ performRequest,
+ withMethod,
+ withDefaultMethod,
+ withBody,
+ withDefaultBody,
+ withHeaders,
+ withDefaultHeaders,
+ withParams,
+ withDefaultParams,
+ withRoot,
+ withTimestamp,
+};
diff --git a/src/reducers/cursors.js b/src/reducers/cursors.js
new file mode 100644
index 000000000000..525f53b21e17
--- /dev/null
+++ b/src/reducers/cursors.js
@@ -0,0 +1,27 @@
+import { fromJS, Map } from 'immutable';
+import Cursor from "ValueObjects/Cursor";
+import {
+ ENTRIES_SUCCESS,
+} from 'Actions/entries';
+
+// Since pagination can be used for a variety of views (collections
+// and searches are the most common examples), we namespace cursors by
+// their type before storing them in the state.
+export const selectCollectionEntriesCursor = (state, collectionName) =>
+ new Cursor(state.getIn(["cursorsByType", "collectionEntries", collectionName]));
+
+const cursors = (state = fromJS({ cursorsByType: { collectionEntries: {} } }), action) => {
+ switch (action.type) {
+ case ENTRIES_SUCCESS: {
+ return state.setIn(
+ ["cursorsByType", "collectionEntries", action.payload.collection],
+ Cursor.create(action.payload.cursor).store
+ );
+ }
+
+ default:
+ return state;
+ }
+};
+
+export default cursors;
diff --git a/src/reducers/entries.js b/src/reducers/entries.js
index f6e39591de43..8670ac1ba19e 100644
--- a/src/reducers/entries.js
+++ b/src/reducers/entries.js
@@ -13,6 +13,7 @@ import { SEARCH_ENTRIES_SUCCESS } from 'Actions/search';
let collection;
let loadedEntries;
+let append;
let page;
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
@@ -32,6 +33,7 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
case ENTRIES_SUCCESS:
collection = action.payload.collection;
loadedEntries = action.payload.entries;
+ append = action.payload.append;
page = action.payload.page;
return state.withMutations((map) => {
loadedEntries.forEach(entry => (
@@ -41,7 +43,9 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(['pages', collection], Map({
page,
- ids: (!page || page === 0) ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids),
+ ids: append
+ ? map.getIn(['pages', collection, 'ids'], List()).concat(ids)
+ : ids,
}));
});
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 2dc4334868d1..3c2c4ba7f74b 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -2,6 +2,7 @@ import auth from './auth';
import config from './config';
import integrations, * as fromIntegrations from './integrations';
import entries, * as fromEntries from './entries';
+import cursors from './cursors';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
@@ -17,6 +18,7 @@ const reducers = {
search,
integrations,
entries,
+ cursors,
editorialWorkflow,
entryDraft,
mediaLibrary,
diff --git a/src/reducers/search.js b/src/reducers/search.js
index db73abe85209..b5a31420661d 100644
--- a/src/reducers/search.js
+++ b/src/reducers/search.js
@@ -38,7 +38,7 @@ const entries = (state = defaultState, action) => {
map.set('isFetching', false);
map.set('page', page);
map.set('term', searchTerm);
- map.set('entryIds', page === 0 ? entryIds : map.get('entryIds', List()).concat(entryIds));
+ map.set('entryIds', (!page || isNaN(page) || page === 0) ? entryIds : map.get('entryIds', List()).concat(entryIds));
});
case QUERY_REQUEST:
diff --git a/src/valueObjects/Cursor.js b/src/valueObjects/Cursor.js
new file mode 100644
index 000000000000..3b9f926aaf8e
--- /dev/null
+++ b/src/valueObjects/Cursor.js
@@ -0,0 +1,115 @@
+import { fromJS, Map, Set } from "immutable";
+
+const jsToMap = obj => {
+ if (obj === undefined) {
+ return Map();
+ }
+ const immutableObj = fromJS(obj);
+ if (!Map.isMap(immutableObj)) {
+ throw new Error("Object must be equivalent to a Map.");
+ }
+ return immutableObj;
+};
+
+const knownMetaKeys = Set(["index", "count", "pageSize", "pageCount", "usingOldPaginationAPI"]);
+const filterUnknownMetaKeys = meta => meta.filter((v, k) => knownMetaKeys.has(k));
+
+/*
+ createCursorMap takes one of three signatures:
+ - () -> cursor with empty actions, data, and meta
+ - (cursorMap: