Login workflow (#137)

* Use collection label instead of name on the CollectionPage

* Added Avatar and logout menu item

* [feat](login) Added userpic with a logout action in the dropdown.

- Display logged in user in the AppHeader
- Implemented logout action and store + tests
- Better styles for GitHub sign in screen

Closes #100

* Better styles for the AppHeader
This commit is contained in:
Andrey Okonetchnikov 2016-11-01 14:35:20 +01:00 committed by Cássio Souza
parent 1c4751f479
commit 4d696f2253
9 changed files with 129 additions and 22 deletions

View File

@ -3,17 +3,18 @@ import { currentBackend } from '../backends/backend';
export const AUTH_REQUEST = 'AUTH_REQUEST'; export const AUTH_REQUEST = 'AUTH_REQUEST';
export const AUTH_SUCCESS = 'AUTH_SUCCESS'; export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE'; export const AUTH_FAILURE = 'AUTH_FAILURE';
export const LOGOUT = 'LOGOUT';
export function authenticating() { export function authenticating() {
return { return {
type: AUTH_REQUEST type: AUTH_REQUEST,
}; };
} }
export function authenticate(userData) { export function authenticate(userData) {
return { return {
type: AUTH_SUCCESS, type: AUTH_SUCCESS,
payload: userData payload: userData,
}; };
} }
@ -25,6 +26,12 @@ export function authError(error) {
}; };
} }
export function logout() {
return {
type: LOGOUT,
};
}
export function loginUser(credentials) { export function loginUser(credentials) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
@ -32,6 +39,17 @@ export function loginUser(credentials) {
dispatch(authenticating()); dispatch(authenticating());
return backend.authenticate(credentials) return backend.authenticate(credentials)
.then((user) => dispatch(authenticate(user))); .then((user) => {
dispatch(authenticate(user));
});
};
}
export function logoutUser() {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
backend.logout();
dispatch(logout());
}; };
} }

View File

@ -16,6 +16,10 @@ class LocalStorageAuthStore {
store(userData) { store(userData) {
window.localStorage.setItem(this.storageKey, JSON.stringify(userData)); window.localStorage.setItem(this.storageKey, JSON.stringify(userData));
} }
logout() {
window.localStorage.removeItem(this.storageKey);
}
} }
const slugFormatter = (template, entryData) => { const slugFormatter = (template, entryData) => {
@ -67,6 +71,14 @@ class Backend {
}); });
} }
logout() {
if (this.authStore) {
this.authStore.logout();
} else {
throw new Error('User isn\'t authenticated.');
}
}
listEntries(collection) { listEntries(collection) {
const collectionModel = new Collection(collection); const collectionModel = new Collection(collection);
const listMethod = this.implementation[collectionModel.listMethod()]; const listMethod = this.implementation[collectionModel.listMethod()];

View File

@ -0,0 +1,11 @@
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.button {
padding: .25em 1em;
height: auto;
}

View File

@ -1,14 +1,17 @@
import React from 'react'; import React from 'react';
import Button from 'react-toolbox/lib/button';
import Authenticator from '../../lib/netlify-auth'; import Authenticator from '../../lib/netlify-auth';
import { Icon } from '../../components/UI';
import styles from './AuthenticationPage.css';
export default class AuthenticationPage extends React.Component { export default class AuthenticationPage extends React.Component {
static propTypes = { static propTypes = {
onLogin: React.PropTypes.func.isRequired onLogin: React.PropTypes.func.isRequired,
}; };
state = {}; state = {};
handleLogin = e => { handleLogin = (e) => {
e.preventDefault(); e.preventDefault();
let auth; let auth;
if (document.location.host.split(':')[0] === 'localhost') { if (document.location.host.split(':')[0] === 'localhost') {
@ -29,9 +32,17 @@ export default class AuthenticationPage extends React.Component {
render() { render() {
const { loginError } = this.state; const { loginError } = this.state;
return <div> return (
<section className={styles.root}>
{loginError && <p>{loginError}</p>} {loginError && <p>{loginError}</p>}
<p><a href="#" onClick={this.handleLogin}>Login with GitHub</a></p> <Button
</div>; className={styles.button}
raised
onClick={this.handleLogin}
>
<Icon type="github" /> Login with GitHub
</Button>
</section>
);
} }
} }

View File

@ -9,3 +9,11 @@
.appBar { .appBar {
background-color: var(--backgroundColor); background-color: var(--backgroundColor);
} }
.icon {
/* stylelint-disable */
/* Cascade is evil :( */
color: var(--foregroundColor) !important;
/* stylelint-enable */
}

View File

@ -2,24 +2,29 @@ import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { IndexLink } from 'react-router'; import { IndexLink } from 'react-router';
import { Menu, MenuItem } from 'react-toolbox'; 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 AppBar from 'react-toolbox/lib/app_bar';
import FontIcon from 'react-toolbox/lib/font_icon';
import FindBar from '../FindBar/FindBar'; import FindBar from '../FindBar/FindBar';
import styles from './AppHeader.css'; import styles from './AppHeader.css';
export default class AppHeader extends React.Component { export default class AppHeader extends React.Component {
static propTypes = { static propTypes = {
user: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired, collections: ImmutablePropTypes.orderedMap.isRequired,
commands: PropTypes.array.isRequired, // eslint-disable-line commands: PropTypes.array.isRequired, // eslint-disable-line
defaultCommands: PropTypes.array.isRequired, // eslint-disable-line defaultCommands: PropTypes.array.isRequired, // eslint-disable-line
runCommand: PropTypes.func.isRequired, runCommand: PropTypes.func.isRequired,
toggleNavDrawer: PropTypes.func.isRequired, toggleNavDrawer: PropTypes.func.isRequired,
onCreateEntryClick: PropTypes.func.isRequired, onCreateEntryClick: PropTypes.func.isRequired,
onLogoutClick: PropTypes.func.isRequired,
}; };
state = { state = {
createMenuActive: false, createMenuActive: false,
userMenuActive: false,
}; };
handleCreatePostClick = (collectionName) => { handleCreatePostClick = (collectionName) => {
@ -41,27 +46,53 @@ export default class AppHeader extends React.Component {
}); });
}; };
handleRightIconClick = () => {
this.setState({
userMenuActive: !this.state.userMenuActive,
});
};
render() { render() {
const { const {
user,
collections, collections,
commands, commands,
defaultCommands, defaultCommands,
runCommand, runCommand,
toggleNavDrawer, toggleNavDrawer,
onLogoutClick,
} = this.props; } = this.props;
const { createMenuActive } = this.state;
const {
createMenuActive,
userMenuActive,
} = this.state;
return ( return (
<AppBar <AppBar
fixed fixed
theme={styles} theme={styles}
leftIcon="menu" leftIcon="menu"
rightIcon="create" rightIcon={
<div>
<Avatar
title={user.get('name')}
image={user.get('avatar_url')}
/>
<Menu
active={userMenuActive}
position="topRight"
onHide={this.handleRightIconClick}
>
<MenuItem onClick={onLogoutClick}>Log out</MenuItem>
</Menu>
</div>
}
onLeftIconClick={toggleNavDrawer} onLeftIconClick={toggleNavDrawer}
onRightIconClick={this.handleCreateButtonClick} onRightIconClick={this.handleRightIconClick}
> >
<IndexLink to="/"> <IndexLink to="/">
Dashboard <FontIcon value="home" />
</IndexLink> </IndexLink>
<FindBar <FindBar
@ -69,9 +100,11 @@ export default class AppHeader extends React.Component {
defaultCommands={defaultCommands} defaultCommands={defaultCommands}
runCommand={runCommand} runCommand={runCommand}
/> />
<Menu <IconMenu
theme={styles}
active={createMenuActive} active={createMenuActive}
position="topRight" icon="create"
onClick={this.handleCreateButtonClick}
onHide={this.handleCreateMenuHide} onHide={this.handleCreateMenuHide}
> >
{ {
@ -84,7 +117,7 @@ export default class AppHeader extends React.Component {
/> />
) )
} }
</Menu> </IconMenu>
</AppBar> </AppBar>
); );
} }

View File

@ -8,7 +8,7 @@ import { Link } from 'react-toolbox/lib/link';
import { Notifs } from 'redux-notifications'; import { Notifs } from 'redux-notifications';
import TopBarProgress from 'react-topbar-progress-indicator'; import TopBarProgress from 'react-topbar-progress-indicator';
import { loadConfig } from '../actions/config'; import { loadConfig } from '../actions/config';
import { loginUser } from '../actions/auth'; import { loginUser, logoutUser } from '../actions/auth';
import { currentBackend } from '../backends/backend'; import { currentBackend } from '../backends/backend';
import { import {
SHOW_COLLECTION, SHOW_COLLECTION,
@ -40,6 +40,7 @@ class App extends React.Component {
config: ImmutablePropTypes.map, config: ImmutablePropTypes.map,
collections: ImmutablePropTypes.orderedMap, collections: ImmutablePropTypes.orderedMap,
createNewEntryInCollection: PropTypes.func.isRequired, createNewEntryInCollection: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
navigateToCollection: PropTypes.func.isRequired, navigateToCollection: PropTypes.func.isRequired,
user: ImmutablePropTypes.map, user: ImmutablePropTypes.map,
@ -138,6 +139,7 @@ class App extends React.Component {
runCommand, runCommand,
navigateToCollection, navigateToCollection,
createNewEntryInCollection, createNewEntryInCollection,
logoutUser,
isFetching, isFetching,
} = this.props; } = this.props;
@ -189,11 +191,13 @@ class App extends React.Component {
</nav> </nav>
</NavDrawer> </NavDrawer>
<AppHeader <AppHeader
user={user}
collections={collections} collections={collections}
commands={commands} commands={commands}
defaultCommands={defaultCommands} defaultCommands={defaultCommands}
runCommand={runCommand} runCommand={runCommand}
onCreateEntryClick={createNewEntryInCollection} onCreateEntryClick={createNewEntryInCollection}
onLogoutClick={logoutUser}
toggleNavDrawer={this.toggleNavDrawer} toggleNavDrawer={this.toggleNavDrawer}
/> />
<Panel scrollY> <Panel scrollY>
@ -226,6 +230,9 @@ function mapDispatchToProps(dispatch) {
createNewEntryInCollection: (collectionName) => { createNewEntryInCollection: (collectionName) => {
dispatch(createNewEntryInCollection(collectionName)); dispatch(createNewEntryInCollection(collectionName));
}, },
logoutUser: () => {
dispatch(logoutUser());
},
}; };
} }

View File

@ -1,6 +1,5 @@
import expect from 'expect';
import Immutable from 'immutable'; import Immutable from 'immutable';
import { authenticating, authenticate, authError } from '../../actions/auth'; import { authenticating, authenticate, authError, logout } from '../../actions/auth';
import auth from '../auth'; import auth from '../auth';
describe('auth', () => { describe('auth', () => {
@ -33,8 +32,14 @@ describe('auth', () => {
auth(undefined, authError(new Error('Bad credentials'))) auth(undefined, authError(new Error('Bad credentials')))
).toEqual( ).toEqual(
Immutable.Map({ Immutable.Map({
error: 'Error: Bad credentials' error: 'Error: Bad credentials',
}) })
); );
}); });
it('should handle logout', () => {
const initialState = Immutable.fromJS({ user: { email: 'joe@example.com' } });
const newState = auth(initialState, logout());
expect(newState.get('user')).toBeUndefined();
});
}); });

View File

@ -1,5 +1,5 @@
import Immutable from 'immutable'; import Immutable from 'immutable';
import { AUTH_REQUEST, AUTH_SUCCESS, AUTH_FAILURE } from '../actions/auth'; import { AUTH_REQUEST, AUTH_SUCCESS, AUTH_FAILURE, LOGOUT } from '../actions/auth';
const auth = (state = null, action) => { const auth = (state = null, action) => {
switch (action.type) { switch (action.type) {
@ -9,6 +9,8 @@ const auth = (state = null, action) => {
return Immutable.fromJS({ user: action.payload }); return Immutable.fromJS({ user: action.payload });
case AUTH_FAILURE: case AUTH_FAILURE:
return Immutable.Map({ error: action.payload.toString() }); return Immutable.Map({ error: action.payload.toString() });
case LOGOUT:
return state.remove('user');
default: default:
return state; return state;
} }