From 738fc96359edbec9e50399f7de4efc5aac33e8f7 Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Thu, 10 Nov 2016 16:48:56 +0100 Subject: [PATCH] Implement login --- assets/less/public.less | 27 +++++++++ assets/less/variables.less | 45 ++++++++++++++ client/actions/ui.js | 33 +++++++++++ client/actions/user.js | 45 ++++++++++++++ client/app.jsx | 2 +- client/components/Form.jsx | 67 +++++++++++++++++++++ client/components/FormElement.jsx | 98 +++++++++++++++++++++++++++++++ client/components/Input.jsx | 38 ++++++++++++ client/components/Layout.jsx | 4 ++ client/components/LoginForm.jsx | 67 +++++++++++++++++++++ client/components/Timer.jsx | 1 + client/components/User.jsx | 29 +++++++++ client/master.jsx | 9 ++- client/reducers/root.js | 9 +++ client/reducers/ui.js | 24 ++++++++ client/reducers/user.js | 13 ++++ client/store.js | 16 +++++ package.json | 9 ++- server/routers/index.js | 5 +- 19 files changed, 533 insertions(+), 8 deletions(-) create mode 100644 assets/less/variables.less create mode 100644 client/actions/ui.js create mode 100644 client/actions/user.js create mode 100644 client/components/Form.jsx create mode 100644 client/components/FormElement.jsx create mode 100644 client/components/Input.jsx create mode 100644 client/components/LoginForm.jsx create mode 100644 client/components/User.jsx create mode 100644 client/reducers/root.js create mode 100644 client/reducers/ui.js create mode 100644 client/reducers/user.js create mode 100644 client/store.js diff --git a/assets/less/public.less b/assets/less/public.less index bc30409..9a5623e 100644 --- a/assets/less/public.less +++ b/assets/less/public.less @@ -1,6 +1,11 @@ +@import "themes/form/foundation"; +@import "themes/form/labels/linus"; +@import "themes/form/validation/linus"; + h1 { text-align: center; } + .timer { display: flex; flex-direction: column; @@ -10,3 +15,25 @@ h1 { font-weight: bold; } } + +.modal { + padding: 40px; + border: 1px solid black; + h1:first-child { + margin-top: 0; + } +} +.container { + display: flex; +} + +.login-form { + z-index: 9999; + position: absolute; + top: 50%; + left: 50%; + background: white; + transform: translateX(-50%) translateY(-50%); +} + +@import "variables"; diff --git a/assets/less/variables.less b/assets/less/variables.less new file mode 100644 index 0000000..1348a31 --- /dev/null +++ b/assets/less/variables.less @@ -0,0 +1,45 @@ +@base-font-size: 16px; + +@default-float: left; +@opposite-direction: right; +// @text-direction: left; + + +@base-color: #671637; +@color-1: #00002d; +@color-2: #0066c6; +@color-3: #0066c6; +@color-message: #333; +@color-error: @base-color; +@color-success: @color-1; + + +// This needs to be a px value since rem and em-calc use it. It is translated +// to a percante unit for html and body font-sizing like so: strip-unit(@base-font-size) / 16 * 100%; +@base-line-height: 1.6; +@base-line-height-computed: @base-line-height * 1rem; + +@body-font-color: #fff; +@body-font-family: 'Open Sans', sans-serif; +@body-font-weight: 300; +@body-font-style: normal; +@body-bg: @color-1; + +@font-smoothing: subpixel-antialiased; + +@input-padding: 14px 12px; +@input-font-weight: 300; + +// Colors +// @form-valid-color: @base-color; +// @form-invalid-color: #ffae00; +@form-valid-color: @color-success; +@form-invalid-color: @color-error; +@form-icon-width: 20px; +@form-icon-height: 20px; + + +// +// Custom +// + diff --git a/client/actions/ui.js b/client/actions/ui.js new file mode 100644 index 0000000..37f0517 --- /dev/null +++ b/client/actions/ui.js @@ -0,0 +1,33 @@ +export const SHOW_LOGIN_MODAL = 'SHOW_LOGIN_MODAL'; + +export function showLoginModal() { + return { + type: SHOW_LOGIN_MODAL, + }; +} + +export const HIDE_LOGIN_MODAL = 'HIDE_LOGIN_MODAL'; + +export function hideLoginModal() { + return { + type: HIDE_LOGIN_MODAL, + }; +} + +export const SHOW_FLAG_FORM = 'SHOW_FLAG_FORM'; + +export function showFlagForm(id) { + return { + type: SHOW_FLAG_FORM, + id: id, + } +} + +export const HIDE_FLAG_FORM = 'HIDE_FLAG_FORM'; + +export function hideFlagForm(id) { + return { + type: HIDE_FLAG_FORM, + id: id, + } +} diff --git a/client/actions/user.js b/client/actions/user.js new file mode 100644 index 0000000..bdea5b9 --- /dev/null +++ b/client/actions/user.js @@ -0,0 +1,45 @@ +import { hideLoginModal } from './ui'; + +export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; + +export const LOGIN_FAILURE = 'LOGIN_FAILURE'; + +export function login(credentials) { + return (dispatch) => { + fetch('/auth/local', { + method: 'POST', + body: JSON.stringify(credentials), + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .then((res) => res.json()) + .then((json) => { + dispatch(hideLoginModal()); + dispatch({ + type: LOGIN_SUCCESS, + payload: json, + }); + }) + .catch((err) => dispatch({ + type: LOGIN_FAILURE, + err, + })); + }; +} + +export const LOGOUT = 'LOGOUT'; + +export function logout() { + return (dispatch) => { + fetch('/auth/logout', { + credentials: 'same-origin', + }) + .then(() => dispatch({ + type: LOGOUT, + })) + .catch((err) => console.error('error logging out', err)); + }; +} diff --git a/client/app.jsx b/client/app.jsx index a8e237f..427942f 100644 --- a/client/app.jsx +++ b/client/app.jsx @@ -12,4 +12,4 @@ if (Notification.permission !== 'granted') { render(( -), null, document.getElementById('app')); +), document.body); diff --git a/client/components/Form.jsx b/client/components/Form.jsx new file mode 100644 index 0000000..0a5973b --- /dev/null +++ b/client/components/Form.jsx @@ -0,0 +1,67 @@ +import { h, Component } from 'preact'; + +// modules > lodas +import set from 'set-value'; +import { forEach, bindAll } from 'lowline'; + +// import Input from '../components/Input.jsx' + +export default class Form extends Component { + constructor(props) { + super(props); + + bindAll(this, ['register', 'validate']); + + this.inputs = {}; + } + + toJSON() { + const attrs = {}; + + // eslint-disable-next-line + for (let name in this.inputs) { + const input = this.inputs[name]; + + const value = input.getValue(); + + if (value) set(attrs, name, value); + } + + return attrs; + } + + validate() { + let errorCount = 0; + + // eslint-disable-next-line + for (let name in this.inputs) { + const input = this.inputs[name]; + + if (!input.isValid()) { + if (errorCount === 0) { + input.focus(); + } + + errorCount += 1; + } + } + + return !errorCount; + } + + register(name) { + return (input) => { + if (input) { + input.validation = this.validation[input.props.name]; + + this.inputs[name] = input; + } else { + delete this.inputs[name]; + } + }; + } + + reset() { + forEach(this.inputs, (input) => input.reset()); + } +} diff --git a/client/components/FormElement.jsx b/client/components/FormElement.jsx new file mode 100644 index 0000000..fd5594e --- /dev/null +++ b/client/components/FormElement.jsx @@ -0,0 +1,98 @@ +import { h, Component } from 'preact'; + +import { bindAll, startCase } from 'lowline'; + +export default class Input extends Component { + constructor(props) { + super(props); + + bindAll(this, ['reset', 'validate', 'onChange', 'onBlur', 'onFocus']); + + this.state = { + value: this.props.value, + }; + } + + + onChange(e) { + e.preventDefault(); + + if (document.activeElement !== e.target && !e.target.value) return; + + this.setValue(e.target.value); + } + + onBlur(e) { + this.setState({ + focus: false, + }); + + this.setValue(e.target.value); + } + + onFocus() { + this.setState({ + focus: true, + touched: true, + }); + } + + focus() { + this.input.focus(); + } + + isValid() { + this.setValue(this.input.value); + + return !this.state.error; + } + + getValue() { + return this.state.value; + } + + reset() { + this.setState({ + value: this.props.value, + }); + } + + setValue(value) { + value = value || undefined; + + const result = this.validate(value); + + this.setState({ + error: result !== true ? typeof result === 'string' && result || 'Error!' : false, + value, + }); + } + + validate(value) { + const validation = this.validation; + + if (!validation) { + return true; + } + + if (validation.required) { + if (value == null || value === '') { + return typeof validation.required === 'string' ? validation.required : 'Required'; + } + } else if (value == null || value === '') { + return true; + } + + if (!validation.tests) { + return true; + } + + for (let i = 0; i < validation.tests.length; i += 1) { + const [fnc, msg] = validation.tests[i]; + + if (!fnc(value)) return msg; + } + + return true; + } +} diff --git a/client/components/Input.jsx b/client/components/Input.jsx new file mode 100644 index 0000000..4b68d7d --- /dev/null +++ b/client/components/Input.jsx @@ -0,0 +1,38 @@ +import { h, Component } from 'preact'; + +import { omit } from 'lowline'; + +import FormElement from './FormElement.jsx'; + +export default class Input extends FormElement { + render({ type = 'text', disabled, placeholder }, state = {}) { + const classes = Object.assign({ + 'field-container': true, + empty: !state.value, + filled: state.value, + focus: state.focus, + invalid: state.error, + touched: state.touched, + valid: state.value && !state.error, + }); + + return ( +
+ + { this.input = input; }} + type={type} + value={state.value} + /> +
+ ); + } +} diff --git a/client/components/Layout.jsx b/client/components/Layout.jsx index bdb88f0..91032cc 100644 --- a/client/components/Layout.jsx +++ b/client/components/Layout.jsx @@ -1,13 +1,17 @@ import { h } from 'preact'; import Timer from './Timer.jsx'; +import LoginForm from './LoginForm.jsx'; +import User from './User.jsx'; export default () => (

Pomodoro Time!

+ +
); diff --git a/client/components/LoginForm.jsx b/client/components/LoginForm.jsx new file mode 100644 index 0000000..9492e9b --- /dev/null +++ b/client/components/LoginForm.jsx @@ -0,0 +1,67 @@ +import { h, Component } from 'preact'; +import isEmail from 'validator/lib/isEmail'; + +import store from '../store'; + +import Form from './Form.jsx'; +import Input from './Input.jsx'; + +import { login } from '../actions/user'; +import { hideLoginModal } from '../actions/ui'; + +export default class LoginForm extends Form { + constructor() { + super(); + + this.validation = { + email: { + required: true, + tests: [ + [isEmail, 'Not a valid email'], + ], + }, + password: { + required: true, + }, + }; + + this.state = { + className: ['login-form', 'modal', 'enter', 'animate'], + }; + + this.onSubmit = this.onSubmit.bind(this); + } + + componentWillMount() { + this.unsubscribe = store.subscribe(() => this.forceUpdate()); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + onSubmit(e) { + e.preventDefault(); + + if (!this.validate()) return; + + store.dispatch(login(this.toJSON())); + } + + render() { + const { ui } = store.getState(); + + return ui.showLoginModal ? ( +
+ +

Login

+ + + +
+ ) : null; + } +} diff --git a/client/components/Timer.jsx b/client/components/Timer.jsx index 17d798f..b0773c3 100644 --- a/client/components/Timer.jsx +++ b/client/components/Timer.jsx @@ -85,6 +85,7 @@ export default class Timer extends Component { fetch('/api/pomodoros', { method: 'POST', body: JSON.stringify(data), + credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', diff --git a/client/components/User.jsx b/client/components/User.jsx new file mode 100644 index 0000000..9917f7a --- /dev/null +++ b/client/components/User.jsx @@ -0,0 +1,29 @@ +import { h, Component } from 'preact'; + +import store from '../store'; + +import { logout } from '../actions/user'; +import { showLoginModal } from '../actions/ui'; + +export default class User extends Component { + componentWillMount() { + this.unsubscribe = store.subscribe(() => this.forceUpdate()); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + render() { + const { user } = store.getState(); + + return (user && +
+
Logged in as {user.email}
+ +
|| +
+ +
); + } +} diff --git a/client/master.jsx b/client/master.jsx index eac0c3a..768051f 100644 --- a/client/master.jsx +++ b/client/master.jsx @@ -1,6 +1,4 @@ -import Layout from './components/Layout.jsx'; - -export default ({ css, js }) => ( +export default ({ INITIAL_STATE, css, js }) => ( '' + ( @@ -16,8 +14,9 @@ export default ({ css, js }) => ( - - +