Implement login

This commit is contained in:
Linus Miller 2016-11-10 16:48:56 +01:00
parent 01ed5b6109
commit 738fc96359
19 changed files with 533 additions and 8 deletions

View File

@ -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";

View File

@ -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
//

33
client/actions/ui.js Normal file
View File

@ -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,
}
}

45
client/actions/user.js Normal file
View File

@ -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));
};
}

View File

@ -12,4 +12,4 @@ if (Notification.permission !== 'granted') {
render((
<Layout />
), null, document.getElementById('app'));
), document.body);

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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 (
<div class={classes}>
<label className="placeholder">{placeholder}</label>
<input
disabled={disabled}
onBlur={this.onBlur}
onChange={this.onChange}
onFocus={this.onFocus}
onInput={this.onChange}
placeholder={placeholder}
ref={(input) => { this.input = input; }}
type={type}
value={state.value}
/>
<label class="icon" />
{state.error && <label class="error">{state.error}</label>}
</div>
);
}
}

View File

@ -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 () => (
<div id="app">
<main>
<h1>Pomodoro Time!</h1>
<User />
<Timer type="pomodoro" time={25 * 60 * 1000} />
<Timer type="break" time={5 * 60 * 1000} />
<LoginForm />
</main>
</div>
);

View File

@ -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 ? (
<form
className={this.state.className.join(' ')}
onSubmit={this.onSubmit}
>
<button type="button" onClick={() => store.dispatch(hideLoginModal())}>Close</button>
<h1>Login</h1>
<Input ref={this.register('email')} type="text" name="email" placeholder="Email" />
<Input ref={this.register('password')} type="password" name="password" placeholder="Password" />
<button>Login</button>
</form>
) : null;
}
}

View File

@ -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',

View File

@ -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 &&
<div>
<div>Logged in as {user.email}</div>
<button onClick={() => store.dispatch(logout())}>Logout</button>
</div> ||
<div>
<button onClick={() => store.dispatch(showLoginModal())}>Login</button>
</div>);
}
}

View File

@ -1,6 +1,4 @@
import Layout from './components/Layout.jsx';
export default ({ css, js }) => (
export default ({ INITIAL_STATE, css, js }) => (
'<!doctype html>' + (
<html lang="en">
<head>
@ -16,8 +14,9 @@ export default ({ css, js }) => (
</head>
<body>
<Layout />
<script>
var INITIAL_STATE = {INITIAL_STATE};
</script>
<script src={`/js/app${js ? js.suffix : ''}.js`} />
</body>
</html>

9
client/reducers/root.js Normal file
View File

@ -0,0 +1,9 @@
import { combineReducers } from 'redux';
import user from './user';
import ui from './ui';
export default combineReducers({
user,
ui,
});

24
client/reducers/ui.js Normal file
View File

@ -0,0 +1,24 @@
import {
SHOW_LOGIN_MODAL,
HIDE_LOGIN_MODAL,
} from '../actions/ui';
const defaultState = {
showLoginModal: false,
};
export default (state = defaultState, action) => {
switch (action.type) {
case SHOW_LOGIN_MODAL:
return Object.assign({}, state, {
showLoginModal: true,
});
case HIDE_LOGIN_MODAL:
return Object.assign({}, state, {
showLoginModal: false,
});
default:
return state;
}
};

13
client/reducers/user.js Normal file
View File

@ -0,0 +1,13 @@
import { LOGIN_SUCCESS, LOGOUT } from '../actions/user';
export default (state = null, action) => {
switch (action.type) {
case LOGIN_SUCCESS:
return action.payload;
case LOGOUT:
return null;
default:
return state;
}
};

16
client/store.js Normal file
View File

@ -0,0 +1,16 @@
import thunkMiddleware from 'redux-thunk';
import createLogger from 'redux-logger';
import { createStore, applyMiddleware } from 'redux';
const loggerMiddleware = createLogger();
import rootReducer from './reducers/root';
export default createStore(
rootReducer,
INITIAL_STATE,
applyMiddleware(
thunkMiddleware,
loggerMiddleware
)
);

View File

@ -26,6 +26,7 @@
"express": "^4.14.0",
"express-service-errors": "github:thecodebureau/express-service-errors",
"express-session": "^1.14.2",
"get-value": "^2.0.6",
"hyperscript-jsx": "0.0.2",
"jsx-node": "^0.1.0",
"lodash": "^4.16.6",
@ -40,7 +41,13 @@
"passport": "^0.3.2",
"passport-local": "^1.0.0",
"preact": "^6.4.0",
"require-dir": "^0.3.1"
"redux": "^3.6.0",
"redux-logger": "^2.7.4",
"redux-thunk": "^2.1.0",
"require-dir": "^0.3.1",
"set-value": "^0.4.0",
"spineless": "^0.1.2",
"validator": "^6.1.0"
},
"devDependencies": {
"autoprefixer": "^6.5.1",

View File

@ -1,5 +1,6 @@
'use strict';
const bootstrap = require('midwest/factories/bootstrap');
const masterTemplate = require('../../build/master.jsx');
const router = new (require('express')).Router();
@ -7,7 +8,9 @@ const router = new (require('express')).Router();
router.get('/', (req, res, next) => {
res.template = masterTemplate;
res.locals.user = req.user;
next();
});
}, bootstrap());
module.exports = router;