diff --git a/client/assets/css/reset.scss b/client/assets/css/reset.scss index 9402e71d..c18d1a5f 100644 --- a/client/assets/css/reset.scss +++ b/client/assets/css/reset.scss @@ -83,6 +83,7 @@ a { } select { + -webkit-appearance: none; -moz-appearance: none; } @@ -100,8 +101,7 @@ button::-moz-focus-inner { border: 0; } -input, -textarea { +input, textarea, select { transition: border 0.2s; outline: none; } @@ -109,9 +109,9 @@ textarea { input[type="checkbox"] { position: relative; top: 1px; - margin: 0; - padding: 0; vertical-align: top; + margin-top: auto; + margin-bottom: auto; } .no-select { diff --git a/client/components/alert.js b/client/components/alert.js index 7edafc48..5d142995 100644 --- a/client/components/alert.js +++ b/client/components/alert.js @@ -14,13 +14,16 @@ export class ModalAlert extends Popup { alert.subscribe((Component, okCallback) => { this.setState({ appear: true, - value: Component + value: Component, + fn: okCallback }); }); } onSubmit(e){ - this.setState({appear: false}); + this.setState({appear: false}, () => { + requestAnimationFrame(() => this.state.fn()) + }); } modalContentBody(){ diff --git a/client/components/button.scss b/client/components/button.scss index 369648e7..2ff373c5 100644 --- a/client/components/button.scss +++ b/client/components/button.scss @@ -23,6 +23,8 @@ button{ background: var(--emphasis); color: white } - &.transparent{ + &.dark{ + background: var(--dark); + color: white; } } diff --git a/client/components/container.js b/client/components/container.js index e010c367..b43c8cad 100644 --- a/client/components/container.js +++ b/client/components/container.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; - import './container.scss'; export class Container extends React.Component { diff --git a/client/components/decorator.js b/client/components/decorator.js index 39042931..84b3dca1 100644 --- a/client/components/decorator.js +++ b/client/components/decorator.js @@ -1,8 +1,8 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { browserHistory } from 'react-router' +import { browserHistory, Redirect } from 'react-router'; -import { Session } from '../model/'; +import { Session, Admin } from '../model/'; import { Container, Loader, Icon } from '../components/'; import { memory, currentShare } from '../helpers/'; @@ -47,7 +47,6 @@ export function LoggedInOnly(WrappedComponent){ }; } - export function ErrorPage(WrappedComponent){ return class extends React.Component { constructor(props){ @@ -89,3 +88,11 @@ export function ErrorPage(WrappedComponent){ } }; } + +export const LoadingPage = (props) => { + return ( +
+ +
+ ); +}; diff --git a/client/components/formbuilder.js b/client/components/formbuilder.js new file mode 100644 index 00000000..d8e68f22 --- /dev/null +++ b/client/components/formbuilder.js @@ -0,0 +1,170 @@ +import React from 'react'; +import { Input, Select, Enabler } from './'; +import { FormObjToJSON, bcrypt_password, format } from '../helpers/'; + +import "./formbuilder.scss"; + +export class FormBuilder extends React.Component { + constructor(props){ + super(props); + } + + section(struct, key, level = 0){ + if(struct == null) struct = ""; + const isALeaf = function(struct){ + if("label" in struct && "type" in struct && + "value" in struct && "default" in struct){ + return true; + } + return false; + }; + + if(Array.isArray(struct)) return null; + else if(isALeaf(struct) === false){ + if(level <= 1){ + return ( +
+ { + key ?

{ format(key) }

: "" + } + { + Object.keys(struct).map((key, index) => { + return ( +
+ { this.section(struct[key], key, level + 1) } +
+ ); + }) + } +
+ ); + } + return ( +
+
+ { format(key) } + { + Object.keys(struct).map((key, index) => { + return ( +
+ { this.section(struct[key], key, level + 1) } +
+ ); + }) + } +
+
+ ); + } + + let id = {}; + let target = []; + if(struct.id !== undefined){ + id.id = this.props.idx === undefined ? struct.id : struct.id + "_" + this.props.idx; + } + if(struct.type === "enable"){ + target = struct.target.map((target) => { + return this.props.idx === undefined ? target : target + "_" + this.props.idx; + }); + } + + const onChange = function(e, fn){ + struct.value = e; + if(typeof fn === "function"){ + fn(struct); + } + this.props.onChange.call( + this, + FormObjToJSON(this.props.form) + ); + }; + return ( ); + } + + render(){ + return this.section(this.props.form || {}); + } +} + + +const FormElement = (props) => { + const id = props.id !== undefined ? {id: props.id} : {}; + let struct = props.params; + let $input = ( props.onChange(e.target.value)} {...id} name={props.name} type="text" defaultValue={struct.value} placeholder={struct.placeholder} /> ); + switch(props.params["type"]){ + case "text": + const onTextChange = (value) => { + if(value === ""){ + value = null; + } + props.onChange(value); + }; + $input = ( onTextChange(e.target.value)} {...id} name={props.name} type="text" value={struct.value || ""} placeholder={struct.placeholder}/> ); + break; + case "number": + const onNumberChange = (value) => { + value = value === "" ? null : parseInt(value); + props.onChange(value); + }; + $input = ( onNumberChange(e.target.value)} {...id} name={props.name} type="number" value={struct.value || ""} placeholder={struct.placeholder} /> ); + break; + case "password": + const onPasswordChange = (value) => { + if(value === ""){ + value = null; + } + props.onChange(value); + }; + $input = ( onPasswordChange(e.target.value)} {...id} name={props.name} type="password" value={struct.value || ""} placeholder={struct.placeholder} /> ); + break; + case "bcrypt": + const onBcryptChange = (value) => { + if(value === ""){ + return props.onChange(null); + } + bcrypt_password(value).then((hash) => { + props.onChange(hash); + }); + }; + $input = ( onBcryptChange(e.target.value)} {...id} name={props.name} type="password" value={struct.value || ""} placeholder={struct.placeholder} /> ); + break; + case "hidden": + $input = ( ); + break; + case "boolean": + $input = ( props.onChange(e.target.checked)} {...id} name={props.name} type="checkbox" checked={struct.value === null ? !!struct.default : struct.value} /> ); + break; + case "select": + $input = ( { this.ref = comp; }} - /> + ref={(comp) => { this.ref = comp; }} /> ); } } @@ -22,3 +22,67 @@ Input.propTypes = { type: PropTypes.string, placeholder: PropTypes.string }; + + +export const Select = (props) => { + const choices = props.choices || []; + const id = props.id ? {id: props.id} : {}; + return ( + + ); +}; + +export class Enabler extends React.Component { + constructor(props){ + super(props); + } + + componentWillMount(){ + requestAnimationFrame(() => { + this.toggle(this.props.defaultValue || false); + }); + } + + onChange(e){ + this.toggle(e.target.checked); + this.props.onChange(e); + } + + toggle(value){ + const target = this.props.target || []; + target.map((t) => { + let $el = document.getElementById(t); + if(!$el) return; + if(value === true){ + $el.parentElement.parentElement.parentElement.style.display = "block"; + $el.parentElement.parentElement.parentElement.style.opacity = "1"; + } else { + $el.parentElement.parentElement.parentElement.style.display = "none"; + $el.parentElement.parentElement.parentElement.style.opacity = "0"; + + // reset value + if($el.value){ + $el.value = null; + let event = new Event('input', { bubbles: true}); + event.simulated = true; + $el.dispatchEvent(event); + } + } + }); + } + + render(){ + return ( + + ); + } +}; diff --git a/client/components/input.scss b/client/components/input.scss index dcf08f3d..76ab4886 100644 --- a/client/components/input.scss +++ b/client/components/input.scss @@ -21,3 +21,13 @@ border-color: var(--emphasis-primary); } } + +.component_select{ + background: inherit; + border-radius: 0; + border: none; + border-bottom: 2px solid rgba(70, 99, 114, 0.1); + color: inherit; + width: 100%; + font-size: 1em; +} diff --git a/client/helpers/ajax.js b/client/helpers/ajax.js index 8dd3aa6c..68ab2cc8 100644 --- a/client/helpers/ajax.js +++ b/client/helpers/ajax.js @@ -8,7 +8,7 @@ export function http_get(url, type = 'json'){ if(type === 'json'){ try{ let data = JSON.parse(xhr.responseText); - if(data.status === 'ok'){ + if("status" in data === false || data.status === 'ok'){ done(data); }else{ err(data); diff --git a/client/helpers/crypto.js b/client/helpers/crypto.js index 6718dacd..d6628033 100644 --- a/client/helpers/crypto.js +++ b/client/helpers/crypto.js @@ -1,4 +1,6 @@ import crypto from 'crypto'; +import bcrypt from 'bcryptjs'; + const algorithm = 'aes-256-cbc'; export function encrypt(obj, key){ @@ -11,3 +13,12 @@ export function decrypt(text, key){ var decipher = crypto.createDecipher(algorithm, key) return JSON.parse(decipher.update(text,'base64','utf8') + decipher.final('utf8')); } + +export function bcrypt_password(password) { + return new Promise((done, error) => { + bcrypt.hash(password, 10, (err, hash) => { + if(err) return error(err) + done(hash); + }) + }); +} diff --git a/client/helpers/form.js b/client/helpers/form.js new file mode 100644 index 00000000..90d169e4 --- /dev/null +++ b/client/helpers/form.js @@ -0,0 +1,16 @@ +export const FormObjToJSON = function(o, fn){ + let obj = Object.assign({}, o); + Object.keys(obj).map((key) => { + let t = obj[key]; + if("label" in t && "type" in t && "default" in t && "value" in t){ + if(typeof fn === "function"){ + fn(obj, key); + } else { + obj[key] = obj[key].value; + } + } else { + obj[key] = FormObjToJSON(obj[key], fn); + } + }); + return obj +}; diff --git a/client/helpers/index.js b/client/helpers/index.js index 1a04c97c..2f4febc5 100644 --- a/client/helpers/index.js +++ b/client/helpers/index.js @@ -1,7 +1,7 @@ export { URL_HOME, goToHome, URL_FILES, goToFiles, URL_VIEWER, goToViewer, URL_LOGIN, goToLogin, URL_LOGOUT, goToLogout } from './navigate'; export { opener } from './mimetype'; export { debounce, throttle } from './backpressure'; -export { encrypt, decrypt } from './crypto'; +export { encrypt, decrypt, bcrypt_password } from './crypto'; export { event } from './events'; export { cache } from './cache'; export { pathBuilder, basename, dirname, absoluteToRelative, filetype, currentShare, appendShareToUrl } from './path'; @@ -14,3 +14,5 @@ export { gid, randomString } from './random'; export { leftPad, copyToClipboard } from './common'; export { getMimeType } from './mimetype'; export { settings_get, settings_put } from './settings'; +export { FormObjToJSON } from './form'; +export { format } from './text'; diff --git a/client/helpers/text.js b/client/helpers/text.js new file mode 100644 index 00000000..d687c341 --- /dev/null +++ b/client/helpers/text.js @@ -0,0 +1,10 @@ +export function format(str = ""){ + if(str.length === 0) return str; + return str.split("_") + .map((word, index) => { + + if(index != 0) return word; + return word[0].toUpperCase() + word.substring(1); + }) + .join(" "); +} diff --git a/client/index.html b/client/index.html index 3e650edd..85e66cfa 100644 --- a/client/index.html +++ b/client/index.html @@ -26,7 +26,6 @@ - @@ -35,6 +34,19 @@ -
+
+ + +
diff --git a/client/index.js b/client/index.js index 2166b1f1..6158bbaa 100644 --- a/client/index.js +++ b/client/index.js @@ -2,13 +2,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import Router from './router'; +import { Config } from "./model/" + import './assets/css/reset.scss'; window.addEventListener("DOMContentLoaded", () => { const className = 'ontouchstart' in window ? 'touch-yes' : 'touch-no'; document.body.classList.add(className); - ReactDOM.render(, document.getElementById('main')); + Config.refresh().then(() => { + ReactDOM.render(, document.getElementById('main')); + }); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/assets/worker/cache.js').catch(function(error) { diff --git a/client/model/admin.js b/client/model/admin.js new file mode 100644 index 00000000..37d7f37f --- /dev/null +++ b/client/model/admin.js @@ -0,0 +1,10 @@ +import { http_post, http_get } from '../helpers'; + +export const Admin = { + login: function(password = ""){ + return http_post("/admin/api/session", {password: password}); + }, + isAdmin: function(){ + return http_get("/admin/api/session").then((res) => res.result); + } +}; diff --git a/client/model/config.js b/client/model/config.js new file mode 100644 index 00000000..d31581ef --- /dev/null +++ b/client/model/config.js @@ -0,0 +1,61 @@ +import { http_get, http_post, http_delete, debounce } from '../helpers/'; + +class ConfigModel { + constructor(){} + + all(){ + return http_get("/admin/api/config").then((d) => d.result); + } + + save(config, debounced = true, fn_ok, fn_err){ + let url = "/admin/api/config"; + + if(debounced){ + if(!this.debounced_post){ + this.debounced_post = debounce((url, config) => { + return http_post(url, config).then(this.refresh).then((a) => { + if(typeof fn_ok === "function") return fn_ok(); + return Promise.resolve(a) + }).catch((err) => { + if(typeof fn_err === "function") return fn_err(); + return Promise.reject(err) + }); + }, 1000); + } + return this.debounced_post(url, config) + } + return http_post(url, config).then(this.refresh).then((a) => { + if(typeof fn_ok === "function") return fn_ok(); + return Promise.resolve(a) + }).catch((err) => { + if(typeof fn_err === "function") return fn_err(); + return Promise.reject(err) + }); + } + + refresh(){ + return http_get("/api/config").then((config) => { + window.CONFIG = config.result; + }); + } +} + +class PluginModel { + constructor(){} + + all(){ + return http_get("/admin/api/plugin").then((r) => r.results); + } +} + +class BackendModel { + constructor(){} + + all(){ + return http_get("/admin/api/backend").then((r) => r.result); + } +} + +export const Plugin = new PluginModel(); +export const Config = new ConfigModel(); +export const Backend = new BackendModel(); diff --git a/client/model/index.js b/client/model/index.js index 49f52d54..086afff4 100644 --- a/client/model/index.js +++ b/client/model/index.js @@ -1,3 +1,6 @@ -export { Files } from './files'; -export { Session } from './session'; -export { Share } from './share'; +export { Files } from "./files"; +export { Session } from "./session"; +export { Share } from "./share"; +export { Config, Plugin, Backend } from "./config"; +export { Log } from "./log"; +export { Admin } from "./admin" diff --git a/client/model/log.js b/client/model/log.js new file mode 100644 index 00000000..7d3d1165 --- /dev/null +++ b/client/model/log.js @@ -0,0 +1,19 @@ +import { http_get } from '../helpers/'; + +class LogManager{ + constructor(){} + + get(maxSize = -1){ + let url = this.url(); + if(maxSize > 0){ + url += "?maxSize="+maxSize + } + return http_get(url, 'text'); + } + + url(){ + return "/admin/api/log" + } +} + +export const Log = new LogManager(); diff --git a/client/pages/adminpage.js b/client/pages/adminpage.js new file mode 100644 index 00000000..fa3c64d0 --- /dev/null +++ b/client/pages/adminpage.js @@ -0,0 +1,113 @@ +import React from 'react'; +import Path from 'path'; +import { Route, Switch, Link, NavLink } from 'react-router-dom'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +import './error.scss'; +import './adminpage.scss'; +import { Icon, LoadingPage } from '../components/'; +import { Config, Admin } from '../model'; +import { notify } from '../helpers/'; +import { HomePage, DashboardPage, ConfigPage, LogPage, PluginPage, SupportPage, SetupPage, LoginPage } from './adminpage/'; + + +function AdminOnly(WrappedComponent){ + return class extends React.Component { + constructor(props){ + super(props); + this.state = { + isAdmin: null + }; + this.admin = () => { + Admin.isAdmin().then((t) => { + this.setState({isAdmin: t}); + }).catch((err) => { + notify.send("Error: " + (err && err.message) , "error"); + }); + }; + } + + componentWillMount(){ + this.timeout = window.setInterval(this.admin.bind(this), 30 * 1000); + this.admin.call(this); + } + + componentWillUnmount(){ + window.clearInterval(this.timeout); + } + + render(){ + if(this.state.isAdmin === true){ + return ( ); + } else if(this.state.isAdmin === false) { + return ( this.admin()} /> ); + } + return ( ); + } + }; +} + +@AdminOnly +export class AdminPage extends React.Component { + constructor(props){ + super(props); + this.state = { + isAdmin: null + }; + } + + render(){ + return ( +
+ +
+ + + + + + + + + + + +
+
+ ); + } +} + +const SideMenu = (props) => { + return ( +
+ + + + +

Admin console

+
    +
  • + + Dashboard + +
  • +
  • + + Configure + +
  • +
  • + + Activity + +
  • +
  • + + Support + +
  • +
+
+ ); +}; diff --git a/client/pages/adminpage.scss b/client/pages/adminpage.scss new file mode 100644 index 00000000..f4779dfa --- /dev/null +++ b/client/pages/adminpage.scss @@ -0,0 +1,165 @@ +.component_page_admin{ + display: flex; + .page_container{ + width: 100%; + background: var(--super-light); + padding-bottom: 150px; + padding-left: 60px; + padding-right: 60px; + @media screen and (max-width: 1000px) { padding-left: 30px; padding-right: 30px; } + @media screen and (max-width: 500px) { padding-left: 10px; padding-right: 10px; } + + box-sizing: border-box; + max-height: 100vh; + + h2{ + font-family: 'Source Code Pro', monospace; + text-shadow: 0 0 2px var(--bg-color); + font-size: 2.8em; + padding: 60px 0 0 0; + margin-bottom: 30px; + margin-top: 0; + @media screen and (max-width: 1000px) { padding: 25px 0 0 0; } + @media screen and (max-width: 500px) { padding: 10px 0 0 0; } + font-weight: 300; + line-height: 1em; + &:after{ + content: "_"; + display: block; + font-size: 0; + border-bottom: 3px solid var(--color); + width: 90px; + margin-top: 10px; + opacity: 0.9; + line-height: 0; + } + } + .sticky h2{ + position: sticky; + background: var(--super-light); + z-index: 2; + top: 0; + } + label{ + > div{ + display: flex; + @media screen and (max-width: 550px) { + display: block; + .nothing{ display: none; } + } + > span{ + display: inline-block; + line-height: 30px; + min-width: 150px; + @media screen and (max-width: 760px) { min-width: 115px } + padding-right: 20px; + margin-top: auto; + margin-bottom: auto; + } + } + } + a{ + color: var(--dark); + border-bottom: 1px dashed var(--dark); + } + pre{ + font-family: 'Source Code Pro', monospace; + background: var(--dark); + padding: 10px; + margin-bottom: 0; + border-radius: 2px; + color: white; + max-width: 100%; + overflow-x: auto; + overflow-y: auto; + } + .component_loader > svg{ + height: 50px; + } + fieldset{ + background: white; + border-color: var(--super-light); + border-radius: 3px; + margin: 15px 0; + } + } +} + + +.component_menu_sidebar{ + height: 100vh; + background: var(--dark); + width: 250px; + border-right: 2px solid var(--color); + padding: 50px 0px 0px 40px; + transition: transform 0.3s ease; + transition-delay: 0.7s; + h2{ + font-family: 'Source Code Pro', monospace; + color: var(--primary); + font-weight: 300; + font-size: 1.5em; + margin: 25px 0 40px 0; + } + ul { + color: var(--light); + list-style-type: none; + padding: 0; + li { + margin: 15px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + a.active, a:hover{ + color: var(--primary); + } + } + } + .header{ + img{ + width: 40px; + height: 40px; + } + img[alt="arrow_left"]{ + position: absolute; + margin-left: -35px; + opacity: 0.7; + padding: 8px; + box-sizing: border-box; + } + } + @media screen and (max-width: 1000px) { padding-left: 30px; width: 200px; } + @media screen and (max-width: 760px) { + padding: 10px; + h2{ + margin: 15px 0 25px 0; + font-size: 1.25em; + padding: 0; + } + } + @media screen and (max-width: 650px) { + width: inherit; + padding-left: 20px; + padding-right: 20px; + h2{ display: none; } + } + @media screen and (max-width: 440px) { + padding-left: 5px; + padding-right: 5px; + ul li{ + width: 50px; + } + } +} + + +.adminpage-appear{ + transition: transform 0.3s ease, opacity 0.5s ease; + opacity: 0; + transform: translateX(5px); +} + +.adminpage-appear.adminpage-appear-active{ + opacity: 1; + transform: translateX(0px); +} diff --git a/client/pages/adminpage/config.js b/client/pages/adminpage/config.js new file mode 100644 index 00000000..1e30a9fc --- /dev/null +++ b/client/pages/adminpage/config.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { FormBuilder } from '../../components/'; +import { Config } from '../../model/'; + +export class ConfigPage extends React.Component { + constructor(props){ + super(props); + this.state = { + form: {} + }; + } + + componentWillMount(){ + Config.all().then((log) => { + this.setState({form: log}); + }); + } + + format(name){ + if(typeof name !== "string"){ + return "N/A"; + } + return name + .split("_") + .map((word) => { + if(word.length < 1){ + return word; + } + return word[0].toUpperCase() + word.substring(1); + }) + .join(" "); + } + + onChange(form){ + form.connections = window.CONFIG.connections + Config.save(form); + this.setState({refresh: Math.random()}); + } + + render(){ + return ( +
+ + + ); + } +} diff --git a/client/pages/adminpage/dashboard.js b/client/pages/adminpage/dashboard.js new file mode 100644 index 00000000..b62b7fd5 --- /dev/null +++ b/client/pages/adminpage/dashboard.js @@ -0,0 +1,197 @@ +import React from 'react'; +import { FormBuilder, Icon, Input } from "../../components/"; +import { Backend, Config } from "../../model/"; +import { FormObjToJSON, notify, format } from "../../helpers/"; + +import "./dashboard.scss"; + +export class DashboardPage extends React.Component { + constructor(props){ + super(props); + this.state = { + backend_enabled: [], + backend_available: [], + config: null + }; + } + + componentWillMount(){ + Promise.all([ + Backend.all(), + Config.all() + ]).then((data) => { + let [backend, config] = data; + this.setState({ + backend_available: backend, + backend_enabled: window.CONFIG.connections.map((conn) => { + return createFormBackend(backend, conn); + }), + config: config + }); + }); + } + + onChange(e){ + this.setState({refresh: Math.random()}); // refresh the screen to refresh the mutation + // that have happenned down the stack + + let json = FormObjToJSON(this.state.config); + json.connections = this.state.backend_enabled.map((backend) => { + let data = FormObjToJSON(backend, (obj, key) => { + if(obj[key].enabled === true){ + obj[key] = obj[key].value || obj[key].default; + } else { + delete obj[key]; + } + }); + const key = Object.keys(data)[0]; + return data[key]; + }); + + // persist config object in the backend + Config.save(json, true, () => { + this.componentWillMount(); + }); + } + + + addBackend(backend_id){ + this.setState({ + backend_enabled: this.state.backend_enabled.concat( + createFormBackend(this.state.backend_available, { + type: backend_id, + label: backend_id.toUpperCase() + }) + ) + }, this.onChange.bind(this)); + } + + removeBackend(n){ + this.setState({ + backend_enabled: this.state.backend_enabled.filter((_, i) => i !== n) + }, this.onChange.bind(this)); + } + + render(){ + const update = (value, struct) => { + struct.enabled = value; + this.setState({refresh: Math.random()}); + if(value === false){ + struct.value = null; + } + return; + }; + + const enable = (struct) => { + if(typeof struct.value === "string"){ + struct.enabled = true; + return true; + } + return !!struct.enabled; + }; + + return ( +
+

Dashboard

+
+ { + Object.keys(this.state.backend_available).map((backend_available, index) => { + return ( +
+
+ {backend_available} + + + + +
+
+ ); + }) + } +
+
+ { + this.state.backend_enabled.map((backend_enable, index) => { + return ( +
+
+ +
+ { + let $checkbox = ( + onChange(update.bind(this, e.target.checked))}/> + ); + if(struct.label === "label"){ + $checkbox = null; + } + return ( + + ); + }} /> +
+ ); + }) + } +
+
+ ); + } +} + + + +function createFormBackend(backend_available, backend_data){ + let template = JSON.parse(JSON.stringify(backend_available[backend_data.type])); + + for(let key in backend_data){ + if(key in template){ + template[key].value = backend_data[key]; + template[key].enabled = true; + } else { + // create a form object if data isn't available in the template + let obj = {}; + obj[key] = { + label: key, + type: "text", + value: null, + default: backend_data[key] + }; + template = Object.assign(obj, template); + } + + if(key === "label"){ + template[key].placeholder = "Name as shown on the login screen."; + template[key].value = backend_data[key]; + template[key].enabled = true; + } else if(key === "type"){ + template[key].enabled = true; + } else if(key === "advanced"){ + template[key].enabled = true; + } + } + + let obj = {}; + obj[backend_data.type] = template; + return obj; +} diff --git a/client/pages/adminpage/dashboard.scss b/client/pages/adminpage/dashboard.scss new file mode 100644 index 00000000..79c9559d --- /dev/null +++ b/client/pages/adminpage/dashboard.scss @@ -0,0 +1,63 @@ +.component_dashboard{ + .box-element { + display: flex; + flex-wrap: wrap; + margin: -5px -5px 20px -5px; + .backend{ + position: relative; + width: 20%; + @media (max-width: 1350px){width: 25%;} + @media (max-width: 900px){width: 33.33%;} + @media (max-width: 750px){width: 50%;} + span{ + cursor: pointer; + position: absolute; + top: 0px; + right: 0px; + font-size: 1.5em; + font-family: monospace; + + display: inline-block; + width: 25px; + line-height: 25px; + + background: var(--bg-color); + border-radius: 50%; + box-shadow: 2px 2px 2px var(--light); + color: var(--color); + text-shadow: none; + } + > div { + box-shadow: 2px 2px 10px var(--emphasis-primary); + margin: 8px; + padding: 30px 0; + text-align: center; + background: var(--primary); + color: white; + text-shadow: 0px 0px 1px var(--color); + font-size: 1.1em; + text-transform: uppercase; + border-radius: 2px; + } + } + } + + form{ + > div{ + position: relative; + > .icons{ + position: absolute; + border-radius: 50%; + padding: 10px; + background: var(--light); + cursor: pointer; + right: -15px; + top: -5px; + box-shadow: rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px; + .component_icon{ + height: 20px; + } + } + } + } +} diff --git a/client/pages/adminpage/home.js b/client/pages/adminpage/home.js new file mode 100644 index 00000000..25870ebc --- /dev/null +++ b/client/pages/adminpage/home.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; + +export class HomePage extends React.Component { + constructor(props){ + super(props); + this.state = { + stage: "loading" + } + } + + render(){ + return ( ); + } +} diff --git a/client/pages/adminpage/index.js b/client/pages/adminpage/index.js new file mode 100644 index 00000000..356b1e35 --- /dev/null +++ b/client/pages/adminpage/index.js @@ -0,0 +1,8 @@ +export { LogPage } from "./logger"; +export { HomePage } from "./home"; +export { ConfigPage } from "./config"; +export { PluginPage } from "./plugin"; +export { SupportPage } from "./support"; +export { DashboardPage } from "./dashboard"; +export { SetupPage } from "./setup"; +export { LoginPage } from "./loginpage"; diff --git a/client/pages/adminpage/logger.js b/client/pages/adminpage/logger.js new file mode 100644 index 00000000..f4c9c641 --- /dev/null +++ b/client/pages/adminpage/logger.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { FormBuilder, Loader, Button, Icon } from '../../components/'; +import { Config, Log } from '../../model/'; +import { FormObjToJSON, notify } from '../../helpers/'; + +import "./logger.scss"; + +export class LogPage extends React.Component { + constructor(props){ + super(props); + this.state = { + form: {}, + loading: false, + log: "", + config: {} + }; + } + + componentWillMount(){ + Config.all().then((config) => { + this.setState({ + form: {"":{"params":config["log"]}}, + config: FormObjToJSON(config) + }); + }); + Log.get(1024*100).then((log) => { // get only the last 100kb of log + this.setState({log: log}, () => { + this.refs.$log.scrollTop = this.refs.$log.scrollHeight; + }); + }); + } + + onChange(r){ + this.state.config["log"] = r[""].params; + this.state.config["connections"] = window.CONFIG.connections; + this.setState({loading: true}, () => { + Config.save(this.state.config, false, () => { + this.setState({loading: false}); + }, () => { + notify.send("Error while saving config", "error"); + this.setState({loading: false}); + }); + }); + } + + render(){ + const filename = () => { + let tmp = "access_"; + tmp += new Date().toISOString().substring(0,10).replace(/-/g, ""); + tmp += ".log"; + } + return ( +
+

Logging { this.state.loading === true ? : null}

+
+ +
+ +
+                {
+                    this.state.log === "" ?  : this.state.log + "\n\n\n\n\n"
+                }
+              
+
+ +
+
+ ); + } +} diff --git a/client/pages/adminpage/logger.scss b/client/pages/adminpage/logger.scss new file mode 100644 index 00000000..7b632fd2 --- /dev/null +++ b/client/pages/adminpage/logger.scss @@ -0,0 +1,9 @@ +.component_logpage{ + button{ + width: inherit; + float: right; + margin-top: 5px; + padding-left: 20px; + padding-right: 20px; + } +} diff --git a/client/pages/adminpage/loginpage.js b/client/pages/adminpage/loginpage.js new file mode 100644 index 00000000..7dee6691 --- /dev/null +++ b/client/pages/adminpage/loginpage.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { Redirect } from 'react-router'; + +import { Input, Button, Container, Icon, Loader } from '../../components/'; +import { Config, Admin } from '../../model/'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +export class LoginPage extends React.Component { + constructor(props){ + super(props); + this.state = { + loading: false, + error: null + }; + } + + componentDidMount(){ + this.refs.$input.ref.focus(); + } + + authenticate(e){ + e.preventDefault(); + this.setState({loading: true}); + Admin.login(this.refs.$input.ref.value) + .then(() => this.props.reload()) + .catch(() => { + this.refs.$input.ref.value = ""; + this.setState({ + loading: false, + error: true + }, () => { + window.setTimeout(() => { + this.setState({error: false}); + }, 500); + }); + }); + } + + render(){ + const marginTop = () => { return {marginTop: parseInt(window.innerHeight / 3)+'px'};}; + + return ( + +
+ + +
+
+ ); + } +} diff --git a/client/pages/adminpage/plugin.js b/client/pages/adminpage/plugin.js new file mode 100644 index 00000000..97266a44 --- /dev/null +++ b/client/pages/adminpage/plugin.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { Plugin } from '../../model/'; + +import './plugin.scss'; + +const PluginBox = (props) => { + return ( +
+
{props.name}
+
{props.description}
+
+ ); +}; + +export class PluginPage extends React.Component { + constructor(props){ + super(props); + this.state = { + plugins: [] + }; + } + + componentWillMount(){ + Plugin.all().then((list) => this.setState({plugins: list})); + } + + render(){ + return ( +
+

Plugins

+
+ { + this.state.plugins.map((plugin, index) => { + return ( ); + }) + } +
+
+ ); + } +} diff --git a/client/pages/adminpage/plugin.scss b/client/pages/adminpage/plugin.scss new file mode 100644 index 00000000..09de140e --- /dev/null +++ b/client/pages/adminpage/plugin.scss @@ -0,0 +1,21 @@ +.component_pluginbox{ + background: white; + padding: 10px; + box-shadow: 2px 2px 2px var(--bg-color); + margin-bottom: 10px; + .title{ + color: var(--dark); + font-size: 1.2em; + margin-bottom: 5px; + } +} + +.component_plugin{ + input[type="file"]{ + background: white; + padding: 20px; + border: 2px solid var(--bg-color); + width: 100%; + box-sizing: border-box; + } +} diff --git a/client/pages/adminpage/setup.js b/client/pages/adminpage/setup.js new file mode 100644 index 00000000..3ef958ca --- /dev/null +++ b/client/pages/adminpage/setup.js @@ -0,0 +1,113 @@ +import React from 'react'; + +import { Input, Button, Container, Icon } from '../../components/'; +import { Config, Admin } from '../../model/'; +import { notify, FormObjToJSON, alert, prompt, bcrypt_password } from '../../helpers'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +import "./setup.scss"; + +export class SetupPage extends React.Component { + constructor(props){ + super(props); + this.state = { + stage: 0, + password: "", + enable_telemetry: false, + creating_password: false + }; + } + + createPassword(e){ + this.setState({creating_password: true}); + e.preventDefault(); + Config.all().then((config) => { + this.setState({enable_telemetry: config.log.telemetry.value}, () => { + if(this.state.enable_telemetry === true) return; + this.unlisten = this.props.history.listen((location, action) => { + alert.now(( +
+

+ Help making this software better by sending crash reports and anonymous usage statistics +

+
+ +
+
+ ), () => this.unlisten()); + }); + }); + + bcrypt_password(this.state.password) + .then((hash) => { + config.auth.admin.value = hash; + config = FormObjToJSON(config); + config.connections = window.CONFIG.connections; + Config.save(config, false) + .then(() => Admin.login(this.state.password)) + .then(() => this.setState({stage: 1, creating_password: false})) + .catch((err) => { + notify.send(err && err.message, "error"); + this.setState({creating_password: false}); + }); + }) + .catch((err) => { + notify.send("Hash error: " + JSON.stringify(err), "error"); + this.setState({creating_password: false}); + }); + }); + } + + enableLog(value){ + Config.all().then((config) => { + config.log.telemetry.value = value; + config = FormObjToJSON(config); + config.connections = window.CONFIG.connections; + Config.save(config, false); + }); + } + + start(e){ + e.preventDefault(); + this.props.history.push("/"); + } + + renderStage(stage){ + if(stage === 0){ + return ( +
+

You made it chief! { this.state.creating_password === true ? : null}

+

+ Let's start by protecting the admin area with a password: +

+
+ this.setState({password: e.target.value})} autoComplete="new-password"/> + +
+