Netlify auth (#194)

This commit is contained in:
Cássio Souza 2016-12-23 16:59:48 -02:00 committed by GitHub
parent 578ffc91df
commit 1efc59a9fb
15 changed files with 1807 additions and 1467 deletions

View File

@ -104,6 +104,7 @@
"markup-it": "git+https://github.com/cassiozen/markup-it.git",
"material-design-icons": "^3.0.1",
"moment": "^2.11.2",
"netlify-auth-js": "^0.5.2",
"normalize.css": "^4.2.0",
"pluralize": "^3.0.0",
"prismjs": "^1.5.1",

View File

@ -1,34 +1,34 @@
import yaml from 'js-yaml';
import { set, defaultsDeep } from 'lodash';
import { currentBackend } from '../backends/backend';
import { authenticate } from '../actions/auth';
import * as MediaProxy from '../valueObjects/MediaProxy';
import * as publishModes from '../constants/publishModes';
import yaml from "js-yaml";
import { set, defaultsDeep } from "lodash";
import { currentBackend } from "../backends/backend";
import { authenticate } from "../actions/auth";
import * as MediaProxy from "../valueObjects/MediaProxy";
import * as publishModes from "../constants/publishModes";
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
export const CONFIG_FAILURE = "CONFIG_FAILURE";
const defaults = {
publish_mode: publishModes.SIMPLE,
};
export function applyDefaults(config) {
if (!('media_folder' in config)) {
throw new Error('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
if (!("media_folder" in config)) {
throw new Error("Error in configuration file: A `media_folder` wasn't found. Check your config.yml file.");
}
// Make sure there is a public folder
set(defaults,
'public_folder',
config.media_folder.charAt(0) === '/' ? config.media_folder : `/${ config.media_folder }`);
"public_folder",
config.media_folder.charAt(0) === "/" ? config.media_folder : `/${ config.media_folder }`);
return defaultsDeep(config, defaults);
}
function parseConfig(data) {
const config = yaml.safeLoad(data);
if (typeof CMS_ENV === 'string' && config[CMS_ENV]) {
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
// TODO: Add tests and refactor
for (const key in config[CMS_ENV]) { // eslint-disable-line no-restricted-syntax
if (config[CMS_ENV].hasOwnProperty(key)) { // eslint-disable-line no-prototype-builtins
@ -57,7 +57,7 @@ export function configLoading() {
export function configFailed(err) {
return {
type: CONFIG_FAILURE,
error: 'Error loading config',
error: "Error loading config",
payload: err,
};
}
@ -76,7 +76,7 @@ export function loadConfig() {
return (dispatch) => {
dispatch(configLoading());
fetch('config.yml').then((response) => {
fetch("config.yml").then((response) => {
if (response.status !== 200) {
throw new Error(`Failed to load config.yml (${ response.status })`);
}
@ -88,7 +88,9 @@ export function loadConfig() {
.then((config) => {
dispatch(configDidLoad(config));
const backend = currentBackend(config);
const user = backend && backend.currentUser();
return backend && backend.currentUser();
})
.then((user) => {
if (user) dispatch(authenticate(user));
});
}).catch((err) => {

View File

@ -1,12 +1,13 @@
import TestRepoBackend from './test-repo/implementation';
import GitHubBackend from './github/implementation';
import NetlifyGitBackend from './netlify-git/implementation';
import { resolveFormat } from '../formats/formats';
import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from '../reducers/collections';
import { createEntry } from '../valueObjects/Entry';
import TestRepoBackend from "./test-repo/implementation";
import GitHubBackend from "./github/implementation";
import NetlifyGitBackend from "./netlify-git/implementation";
import NetlifyAuthBackend from "./netlify-auth/implementation";
import { resolveFormat } from "../formats/formats";
import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from "../reducers/collections";
import { createEntry } from "../valueObjects/Entry";
class LocalStorageAuthStore {
storageKey = 'nf-cms-user';
storageKey = "nf-cms-user";
retrieve() {
const data = window.localStorage.getItem(this.storageKey);
@ -22,21 +23,21 @@ class LocalStorageAuthStore {
}
}
const slugFormatter = (template = '{{slug}}', entryData) => {
const slugFormatter = (template = "{{slug}}", entryData) => {
const date = new Date();
const identifier = entryData.get('title', entryData.get('path'));
const identifier = entryData.get("title", entryData.get("path"));
return template.replace(/\{\{([^\}]+)\}\}/g, (_, field) => {
switch (field) {
case 'year':
case "year":
return date.getFullYear();
case 'month':
case "month":
return (`0${ date.getMonth() + 1 }`).slice(-2);
case 'day':
case "day":
return (`0${ date.getDate() }`).slice(-2);
case 'slug':
return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, '-');
case "slug":
return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, "-");
default:
return entryData.get(field, '').trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, '-');
return entryData.get(field, "").trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, "-");
}
});
};
@ -46,7 +47,7 @@ class Backend {
this.implementation = implementation;
this.authStore = authStore;
if (this.implementation === null) {
throw new Error('Cannot instantiate a Backend with no implementation');
throw new Error("Cannot instantiate a Backend with no implementation");
}
}
@ -54,10 +55,9 @@ class Backend {
if (this.user) { return this.user; }
const stored = this.authStore && this.authStore.retrieve();
if (stored) {
this.implementation.setUser(stored);
return stored;
return Promise.resolve(this.implementation.setUser(stored)).then(() => stored);
}
return null;
return Promise.resolve(null);
}
authComponent() {
@ -75,7 +75,7 @@ class Backend {
if (this.authStore) {
this.authStore.logout();
} else {
throw new Error('User isn\'t authenticated.');
throw new Error("User isn't authenticated.");
}
}
@ -84,7 +84,7 @@ class Backend {
return listMethod.call(this.implementation, collection)
.then(loadedEntries => (
loadedEntries.map(loadedEntry => createEntry(
collection.get('name'),
collection.get("name"),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data, label: loadedEntry.file.label }
@ -100,7 +100,7 @@ class Backend {
getEntry(collection, slug) {
return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug))
.then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry(
collection.get('name'),
collection.get("name"),
slug,
loadedEntry.file.path,
{ raw: loadedEntry.data, label: loadedEntry.file.label }
@ -123,21 +123,21 @@ class Backend {
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
.then(entries => (
entries.map((loadedEntry) => {
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
const entry = createEntry("draft", loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
entry.metaData = loadedEntry.metaData;
return entry;
})
))
.then(entries => ({
pagination: 0,
entries: entries.map(this.entryWithFormat('editorialWorkflow')),
entries: entries.map(this.entryWithFormat("editorialWorkflow")),
}));
}
unpublishedEntry(collection, slug) {
return this.implementation.unpublishedEntry(collection, slug)
.then((loadedEntry) => {
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
const entry = createEntry("draft", loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
entry.metaData = loadedEntry.metaData;
return entry;
})
@ -145,20 +145,20 @@ class Backend {
}
persistEntry(config, collection, entryDraft, MediaFiles, options) {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const newEntry = entryDraft.getIn(["entry", "newRecord"]) || false;
const parsedData = {
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
title: entryDraft.getIn(["entry", "data", "title"], "No Title"),
description: entryDraft.getIn(["entry", "data", "description"], "No Description!"),
};
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
const entryData = entryDraft.getIn(["entry", "data"]).toJS();
let entryObj;
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
throw (new Error('Not allowed to create new entries in this collection'));
throw (new Error("Not allowed to create new entries in this collection"));
}
const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data']));
const slug = slugFormatter(collection.get("slug"), entryDraft.getIn(["entry", "data"]));
const path = selectEntryPath(collection, slug);
entryObj = {
path,
@ -166,20 +166,20 @@ class Backend {
raw: this.entryToRaw(collection, entryData),
};
} else {
const path = entryDraft.getIn(['entry', 'path']);
const path = entryDraft.getIn(["entry", "path"]);
entryObj = {
path,
slug: entryDraft.getIn(['entry', 'slug']),
slug: entryDraft.getIn(["entry", "slug"]),
raw: this.entryToRaw(collection, entryData),
};
}
const commitMessage = `${ (newEntry ? 'Created ' : 'Updated ') +
collection.get('label') } ${ entryObj.slug }`;
const commitMessage = `${ (newEntry ? "Created " : "Updated ") +
collection.get("label") } ${ entryObj.slug }`;
const mode = config.get('publish_mode');
const mode = config.get("publish_mode");
const collectionName = collection.get('name');
const collectionName = collection.get("name");
return this.implementation.persistEntry(entryObj, MediaFiles, {
newEntry, parsedData, commitMessage, collectionName, mode, ...options,
@ -206,20 +206,22 @@ class Backend {
}
export function resolveBackend(config) {
const name = config.getIn(['backend', 'name']);
const name = config.getIn(["backend", "name"]);
if (name == null) {
throw new Error('No backend defined in configuration');
throw new Error("No backend defined in configuration");
}
const authStore = new LocalStorageAuthStore();
switch (name) {
case 'test-repo':
case "test-repo":
return new Backend(new TestRepoBackend(config), authStore);
case 'github':
case "github":
return new Backend(new GitHubBackend(config), authStore);
case 'netlify-git':
case "netlify-git":
return new Backend(new NetlifyGitBackend(config), authStore);
case "netlify-auth":
return new Backend(new NetlifyAuthBackend(config), authStore);
default:
throw new Error(`Backend not found: ${ name }`);
}
@ -230,7 +232,7 @@ export const currentBackend = (function () {
return (config) => {
if (backend) { return backend; }
if (config.get('backend')) {
if (config.get("backend")) {
return backend = resolveBackend(config);
}
};

View File

@ -1,29 +1,34 @@
import LocalForage from 'localforage';
import MediaProxy from '../../valueObjects/MediaProxy';
import { Base64 } from 'js-base64';
import _ from 'lodash';
import { SIMPLE, EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
const API_ROOT = 'https://api.github.com';
import LocalForage from "localforage";
import { Base64 } from "js-base64";
import _ from "lodash";
import MediaProxy from "../../valueObjects/MediaProxy";
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
export default class API {
constructor(token, repo, branch) {
this.token = token;
this.repo = repo;
this.branch = branch;
constructor(config) {
this.api_root = config.api_root || "https://api.github.com";
this.token = config.token || false;
this.branch = config.branch || "master";
this.repo = config.repo || "";
this.repoURL = `/repos/${ this.repo }`;
}
user() {
return this.request('/user');
return this.request("/user");
}
requestHeaders(headers = {}) {
return {
Authorization: `token ${ this.token }`,
'Content-Type': 'application/json',
const baseHeader = {
"Content-Type": "application/json",
...headers,
};
if (this.token) {
baseHeader.Authorization = `token ${ this.token }`;
return baseHeader;
}
return baseHeader;
}
parseJsonResponse(response) {
@ -44,16 +49,16 @@ export default class API {
}
}
if (params.length) {
path += `?${ params.join('&') }`;
path += `?${ params.join("&") }`;
}
return API_ROOT + path;
return this.api_root + path;
}
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
return fetch(url, { ...options, headers }).then((response) => {
const contentType = response.headers.get('Content-Type');
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
@ -64,27 +69,28 @@ export default class API {
checkMetadataRef() {
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
cache: 'no-store',
cache: "no-store",
})
.then(response => response.object)
.catch((error) => {
// Meta ref doesn't exist
const readme = {
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.',
raw: "# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.",
};
return this.uploadBlob(readme)
.then(item => this.request(`${ this.repoURL }/git/trees`, {
method: 'POST',
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }),
method: "POST",
body: JSON.stringify({ tree: [{ path: "README.md", mode: "100644", type: "blob", sha: item.sha }] }),
}))
.then(tree => this.commit('First Commit', tree))
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
.then(tree => this.commit("First Commit", tree))
.then(response => this.createRef("meta", "_netlify_cms", response.sha))
.then(response => response.object);
});
}
storeMetadata(key, data) {
console.log('Trying to store Metadata');
return this.checkMetadataRef()
.then((branchData) => {
const fileTree = {
@ -96,9 +102,9 @@ export default class API {
};
return this.uploadBlob(fileTree[`${ key }.json`])
.then(item => this.updateTree(branchData.sha, '/', fileTree))
.then(item => this.updateTree(branchData.sha, "/", fileTree))
.then(changeTree => this.commit(`Updating “${ key }” metadata`, changeTree))
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
.then(response => this.patchRef("meta", "_netlify_cms", response.sha))
.then(() => {
LocalForage.setItem(`gh.meta.${ key }`, {
expires: Date.now() + 300000, // In 5 minutes
@ -114,9 +120,9 @@ export default class API {
if (cached && cached.expires > Date.now()) { return cached.data; }
console.log("%c Checking for MetaData files", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
return this.request(`${ this.repoURL }/contents/${ key }.json`, {
params: { ref: 'refs/meta/_netlify_cms' },
headers: { Accept: 'application/vnd.github.VERSION.raw' },
cache: 'no-store',
params: { ref: "refs/meta/_netlify_cms" },
headers: { Accept: "application/vnd.github.VERSION.raw" },
cache: "no-store",
})
.then(response => JSON.parse(response))
.catch(error => console.log("%c %s does not have metadata", "line-height: 30px;text-align: center;font-weight: bold", key)); // eslint-disable-line
@ -129,7 +135,7 @@ export default class API {
if (cached) { return cached; }
return this.request(`${ this.repoURL }/contents/${ path }`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
headers: { Accept: "application/vnd.github.VERSION.raw" },
params: { ref: branch },
cache: false,
}).then((result) => {
@ -155,9 +161,7 @@ export default class API {
return this.readFile(data.objects.entry, null, data.branch);
})
.then(fileData => ({ metaData, fileData }))
.catch((error) => {
return null;
});
.catch(error => null);
return unpublishedPromise;
}
@ -178,7 +182,7 @@ export default class API {
files.forEach((file) => {
if (file.uploaded) { return; }
uploadPromises.push(this.uploadBlob(file));
parts = file.path.split('/').filter(part => part);
parts = file.path.split("/").filter(part => part);
filename = parts.pop();
subtree = fileTree;
while (part = parts.shift()) {
@ -191,7 +195,7 @@ export default class API {
return Promise.all(uploadPromises).then(() => {
if (!options.mode || (options.mode && options.mode === SIMPLE)) {
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
} else if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
@ -212,16 +216,13 @@ export default class API {
const branchName = `cms/${ contentKey }`;
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
.then(branchResponse => this.createPR(options.commitMessage, branchName))
.then((prResponse) => {
return this.user().then((user) => {
return user.name ? user.name : user.login;
})
.then(prResponse => this.user().then(user => user.name ? user.name : user.login)
.then(username => this.storeMetadata(contentKey, {
type: 'PR',
type: "PR",
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha,
@ -237,19 +238,16 @@ export default class API {
files: filesList,
},
timeStamp: new Date().toISOString(),
}));
});
})));
} else {
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
return this.getBranch(branchName)
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then((response) => {
const contentKey = entry.slug;
const branchName = `cms/${ contentKey }`;
return this.user().then((user) => {
return user.name ? user.name : user.login;
})
return this.user().then(user => user.name ? user.name : user.login)
.then(username => this.retrieveMetadata(contentKey))
.then((metadata) => {
let files = metadata.objects && metadata.objects.files || [];
@ -275,12 +273,10 @@ export default class API {
updateUnpublishedEntryStatus(collection, slug, status) {
const contentKey = slug;
return this.retrieveMetadata(contentKey)
.then((metadata) => {
return {
...metadata,
status,
};
})
.then(metadata => ({
...metadata,
status,
}))
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
}
@ -297,21 +293,21 @@ export default class API {
createRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs`, {
method: 'POST',
method: "POST",
body: JSON.stringify({ ref: `refs/${ type }/${ name }`, sha }),
});
}
patchRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, {
method: 'PATCH',
method: "PATCH",
body: JSON.stringify({ sha }),
});
}
deleteRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, {
method: 'DELETE',
method: "DELETE",
});
}
@ -320,30 +316,30 @@ export default class API {
}
createBranch(branchName, sha) {
return this.createRef('heads', branchName, sha);
return this.createRef("heads", branchName, sha);
}
patchBranch(branchName, sha) {
return this.patchRef('heads', branchName, sha);
return this.patchRef("heads", branchName, sha);
}
deleteBranch(branchName) {
return this.deleteRef('heads', branchName);
return this.deleteRef("heads", branchName);
}
createPR(title, head, base = 'master') {
const body = 'Automatically generated by Netlify CMS';
createPR(title, head, base = "master") {
const body = "Automatically generated by Netlify CMS";
return this.request(`${ this.repoURL }/pulls`, {
method: 'POST',
method: "POST",
body: JSON.stringify({ title, body, head, base }),
});
}
mergePR(headSha, number) {
return this.request(`${ this.repoURL }/pulls/${ number }/merge`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify({
commit_message: 'Automatically generated. Merged on Netlify CMS.',
commit_message: "Automatically generated. Merged on Netlify CMS.",
sha: headSha,
}),
});
@ -362,19 +358,17 @@ export default class API {
uploadBlob(item) {
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
return content.then((contentBase64) => {
return this.request(`${ this.repoURL }/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
}).then((response) => {
item.sha = response.sha;
item.uploaded = true;
return item;
});
});
return content.then(contentBase64 => this.request(`${ this.repoURL }/git/blobs`, {
method: "POST",
body: JSON.stringify({
content: contentBase64,
encoding: "base64",
}),
}).then((response) => {
item.sha = response.sha;
item.uploaded = true;
return item;
}));
}
updateTree(sha, path, fileTree) {
@ -402,19 +396,15 @@ export default class API {
if (added[filename]) { continue; }
updates.push(
fileOrDir.file ?
{ path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } :
{ path: filename, mode: "100644", type: "blob", sha: fileOrDir.sha } :
this.updateTree(null, filename, fileOrDir)
);
}
return Promise.all(updates)
.then((updates) => {
return this.request(`${ this.repoURL }/git/trees`, {
method: 'POST',
body: JSON.stringify({ base_tree: sha, tree: updates }),
});
}).then((response) => {
return { path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
});
.then(updates => this.request(`${ this.repoURL }/git/trees`, {
method: "POST",
body: JSON.stringify({ base_tree: sha, tree: updates }),
})).then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
});
}
@ -422,7 +412,7 @@ export default class API {
const tree = changeTree.sha;
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
return this.request(`${ this.repoURL }/git/commits`, {
method: 'POST',
method: "POST",
body: JSON.stringify({ message, tree, parents }),
});
}

View File

@ -2,7 +2,7 @@
display: flex;
align-items: center;
justify-content: center;
height: 100%;
height: 100vh;
}
.button {

View File

@ -1,17 +1,19 @@
import semaphore from 'semaphore';
import AuthenticationPage from './AuthenticationPage';
import API from './API';
import semaphore from "semaphore";
import AuthenticationPage from "./AuthenticationPage";
import API from "./API";
const MAX_CONCURRENT_DOWNLOADS = 10;
export default class GitHub {
constructor(config) {
constructor(config, proxied = false) {
this.config = config;
if (config.getIn(['backend', 'repo']) == null) {
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
if (!proxied && config.getIn(["backend", "repo"]) == null) {
throw new Error("The GitHub backend needs a \"repo\" in the backend configuration.");
}
this.repo = config.getIn(['backend', 'repo']);
this.branch = config.getIn(['backend', 'branch']) || 'master';
this.repo = config.getIn(["backend", "repo"], "");
this.branch = config.getIn(["backend", "branch"], "master");
}
authComponent() {
@ -19,11 +21,11 @@ export default class GitHub {
}
setUser(user) {
this.api = new API(user.token, this.repo, this.branch || 'master');
this.api = new API({ token: user.token, branch: this.branch, repo: this.repo });
}
authenticate(state) {
this.api = new API(state.token, this.repo, this.branch || 'master');
this.api = new API({ token: state.token, branch: this.branch, repo: this.repo });
return this.api.user().then((user) => {
user.token = state.token;
return user;
@ -31,14 +33,14 @@ export default class GitHub {
}
entriesByFolder(collection) {
return this.api.listFiles(collection.get('folder'))
return this.api.listFiles(collection.get("folder"))
.then(this.fetchFiles);
}
entriesByFiles(collection) {
const files = collection.get('files').map(collectionFile => ({
path: collectionFile.get('file'),
label: collectionFile.get('label'),
const files = collection.get("files").map(collectionFile => ({
path: collectionFile.get("file"),
label: collectionFile.get("label"),
}));
return this.fetchFiles(files);
}
@ -78,7 +80,7 @@ export default class GitHub {
const promises = [];
branches.map((branch) => {
promises.push(new Promise((resolve, reject) => {
const slug = branch.ref.split('refs/heads/cms/').pop();
const slug = branch.ref.split("refs/heads/cms/").pop();
return sem.take(() => this.api.readUnpublishedBranchFile(slug).then((data) => {
if (data === null || data === undefined) {
resolve(null);
@ -102,7 +104,7 @@ export default class GitHub {
return Promise.all(promises);
})
.catch((error) => {
if (error.message === 'Not Found') {
if (error.message === "Not Found") {
return Promise.resolve([]);
}
return error;

View File

@ -0,0 +1,74 @@
import GithubAPI from "../github/API";
export default class API extends GithubAPI {
constructor(config) {
super(config);
this.api_root = config.api_root;
this.jwtToken = config.jwtToken;
this.commitAuthor = config.commitAuthor;
this.repoURL = "";
}
requestHeaders(headers = {}) {
const baseHeader = {
Authorization: `Bearer ${ this.jwtToken }`,
"Content-Type": "application/json",
...headers,
};
return baseHeader;
}
urlFor(path, options) {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${ params.join("&") }`;
}
return this.api_root + path;
}
user() {
return Promise.resolve(this.commitAuthor);
}
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
return fetch(url, { ...options, headers }).then((response) => {
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
return response.text();
});
}
commit(message, changeTree) {
const commitParams = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request("/git/commits", {
method: "POST",
body: JSON.stringify(commitParams),
});
}
}

View File

@ -0,0 +1,21 @@
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.card {
width: 350px;
padding: 10px;
}
.card img {
display: block;
margin: auto;
}
.button {
padding: .25em 1em;
height: auto;
}

View File

@ -0,0 +1,50 @@
import React from "react";
import Input from "react-toolbox/lib/input";
import Button from "react-toolbox/lib/button";
import { Card, Icon } from "../../components/UI";
import logo from "./netlify_logo.svg";
import styles from "./AuthenticationPage.css";
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: React.PropTypes.func.isRequired,
};
state = { username: "", password: "" };
handleChange = (name, value) => {
this.setState({ ...this.state, [name]: value });
};
handleLogin = (e) => {
e.preventDefault();
AuthenticationPage.authClient.login(this.state.username, this.state.password, true)
.then((user) => {
this.props.onLogin(user);
})
.catch((err) => { throw err; });
};
render() {
const { loginError } = this.state;
return (
<section className={styles.root}>
{loginError && <p>{loginError}</p>}
<Card className={styles.card}>
<img src={logo} width={100} />
<Input type="text" label="Username" name="username" value={this.state.username} onChange={this.handleChange.bind(this, "username")} />
<Input type="password" label="Password" name="password" value={this.state.password} onChange={this.handleChange.bind(this, "password")} />
<Button
className={styles.button}
raised
onClick={this.handleLogin}
>
<Icon type="login" /> Login
</Button>
</Card>
</section>
);
}
}

View File

@ -0,0 +1,48 @@
import NetlifyAuthClient from "netlify-auth-js";
import { omit } from "lodash";
import GitHubBackend from "../github/implementation";
import API from "./API";
import AuthenticationPage from "./AuthenticationPage";
export default class NetlifyAuth extends GitHubBackend {
constructor(config) {
super(config, true);
if (config.getIn(["backend", "auth_url"]) == null) { throw new Error("The NetlifyAuth backend needs an \"auth_url\" in the backend configuration."); }
if (config.getIn(["backend", "github_proxy_url"]) == null) {
throw new Error("The NetlifyAuth backend needs an \"github_proxy_url\" in the backend configuration.");
}
this.github_proxy_url = config.getIn(["backend", "github_proxy_url"]);
this.authClient = new NetlifyAuthClient({
APIUrl: config.getIn(["backend", "auth_url"]),
});
AuthenticationPage.authClient = this.authClient;
}
setUser() {
const user = this.authClient.currentUser();
if (!user) return Promise.reject();
return this.authenticate(user);
}
authenticate(user) {
return user.jwt().then((token) => {
const userData = {
name: `${ user.user_metadata.firstname } ${ user.user_metadata.lastname }`,
email: user.email,
metadata: user.user_metadata,
};
this.api = new API({ api_root: this.github_proxy_url, jwtToken: token, commitAuthor: omit(userData, ["metadata"]) });
return userData;
});
}
authComponent() {
return AuthenticationPage;
}
}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="295px" height="284px" viewBox="0 0 295 284" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Netlify</title>
<g transform="translate(149.500000, 142.500000) rotate(-315.000000) translate(-149.500000, -142.500000) translate(45.000000, 38.000000)">
<g transform="translate(4.000000, 4.000000)" fill="#3FB5A0">
<path d="M0,0 L200,0 L200,200 L0,200 L0,0 L0,0 Z" id="Shape"></path>
</g>
<g stroke="#FFFFFF" stroke-width="8">
<path d="M209,70 L0,209 L209,70 Z" id="Shape"></path>
<path d="M209,6 L0,93 L209,6 Z" id="Shape"></path>
<path d="M209,180 L0,145 L209,180 Z" id="Shape"></path>
<path d="M88,209 L43,0 L88,209 Z" id="Shape"></path>
<path d="M209,172 L89,0 L209,172 Z" id="Shape"></path>
<path d="M137,0 L57,209 L137,0 Z" id="Shape"></path>
</g>
<g transform="translate(43.000000, 33.000000)" fill="#FFFFFF">
<circle id="Oval" cx="14" cy="38" r="14"></circle>
<circle id="Oval" cx="77" cy="12" r="12"></circle>
<circle id="Oval" cx="116" cy="70" r="12"></circle>
<circle id="Oval" cx="35" cy="125" r="16"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,13 +1,14 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import pluralize from 'pluralize';
import { IndexLink } from 'react-router';
import { IconMenu, Menu, MenuItem } from 'react-toolbox/lib/menu';
import Avatar from 'react-toolbox/lib/avatar';
import AppBar from 'react-toolbox/lib/app_bar';
import FontIcon from 'react-toolbox/lib/font_icon';
import FindBar from '../FindBar/FindBar';
import styles from './AppHeader.css';
import React, { PropTypes } from "react";
import ImmutablePropTypes from "react-immutable-proptypes";
import pluralize from "pluralize";
import { IndexLink } from "react-router";
import { IconMenu, Menu, MenuItem } from "react-toolbox/lib/menu";
import Avatar from "react-toolbox/lib/avatar";
import AppBar from "react-toolbox/lib/app_bar";
import FontIcon from "react-toolbox/lib/font_icon";
import FindBar from "../FindBar/FindBar";
import { stringToRGB } from "../../lib/textHelper";
import styles from "./AppHeader.css";
export default class AppHeader extends React.Component {
@ -68,6 +69,10 @@ export default class AppHeader extends React.Component {
userMenuActive,
} = this.state;
const avatarStyle = {
backgroundColor: `#${ stringToRGB(user.get("name")) }`,
};
return (
<AppBar
fixed
@ -76,8 +81,9 @@ export default class AppHeader extends React.Component {
rightIcon={
<div>
<Avatar
title={user.get('name')}
image={user.get('avatar_url')}
style={avatarStyle}
title={user.get("name")}
image={user.get("avatar_url")}
/>
<Menu
active={userMenuActive}
@ -102,7 +108,6 @@ export default class AppHeader extends React.Component {
/>
<IconMenu
theme={styles}
active={createMenuActive}
icon="create"
onClick={this.handleCreateButtonClick}
onHide={this.handleCreateMenuHide}
@ -110,10 +115,10 @@ export default class AppHeader extends React.Component {
{
collections.valueSeq().map(collection =>
<MenuItem
key={collection.get('name')}
value={collection.get('name')}
key={collection.get("name")}
value={collection.get("name")}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))} // eslint-disable-line
caption={pluralize(collection.get('label'), 1)}
caption={pluralize(collection.get("label"), 1)}
/>
)
}

View File

@ -1,3 +1,3 @@
exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"<div><button disabled=\"\" class=\"\" data-react-toolbox=\"button\">Saving...</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;
exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"<div><button disabled=\"\" class=\"\" type=\"button\" data-react-toolbox=\"button\">Saving...</button> <button class=\"\" type=\"button\" data-react-toolbox=\"button\">Cancel</button></div>"`;
exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"<div><button class=\"\" data-react-toolbox=\"button\">Save</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;
exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"<div><button class=\"\" type=\"button\" data-react-toolbox=\"button\">Save</button> <button class=\"\" type=\"button\" data-react-toolbox=\"button\">Cancel</button></div>"`;

View File

@ -1,6 +1,20 @@
export function truncateMiddle(string = '', size) {
export function truncateMiddle(string = "", size) {
if (string.length <= size) {
return string;
}
return string.substring(0, size / 2) + '\u2026' + string.substring(string.length - size / 2 + 1, string.length);
return `${ string.substring(0, size / 2) }\u2026${ string.substring(string.length - size / 2 + 1, string.length) }`;
}
export function stringToRGB(str) {
if (!str) return "000000";
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const c = (hash & 0x00FFFFFF)
.toString(16)
.toUpperCase();
return "00000".substring(0, 6 - c.length) + c;
}

2674
yarn.lock

File diff suppressed because it is too large Load Diff