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_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE';
export const LOGOUT = 'LOGOUT';
export function authenticating() {
return {
type: AUTH_REQUEST
type: AUTH_REQUEST,
};
}
export function authenticate(userData) {
return {
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) {
return (dispatch, getState) => {
const state = getState();
@ -32,6 +39,17 @@ export function loginUser(credentials) {
dispatch(authenticating());
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) {
window.localStorage.setItem(this.storageKey, JSON.stringify(userData));
}
logout() {
window.localStorage.removeItem(this.storageKey);
}
}
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) {
const collectionModel = new Collection(collection);
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 Button from 'react-toolbox/lib/button';
import Authenticator from '../../lib/netlify-auth';
import { Icon } from '../../components/UI';
import styles from './AuthenticationPage.css';
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: React.PropTypes.func.isRequired
onLogin: React.PropTypes.func.isRequired,
};
state = {};
handleLogin = e => {
handleLogin = (e) => {
e.preventDefault();
let auth;
if (document.location.host.split(':')[0] === 'localhost') {
@ -29,9 +32,17 @@ export default class AuthenticationPage extends React.Component {
render() {
const { loginError } = this.state;
return <div>
{loginError && <p>{loginError}</p>}
<p><a href="#" onClick={this.handleLogin}>Login with GitHub</a></p>
</div>;
return (
<section className={styles.root}>
{loginError && <p>{loginError}</p>}
<Button
className={styles.button}
raised
onClick={this.handleLogin}
>
<Icon type="github" /> Login with GitHub
</Button>
</section>
);
}
}

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import expect from 'expect';
import Immutable from 'immutable';
import { authenticating, authenticate, authError } from '../../actions/auth';
import { authenticating, authenticate, authError, logout } from '../../actions/auth';
import auth from '../auth';
describe('auth', () => {
@ -33,8 +32,14 @@ describe('auth', () => {
auth(undefined, authError(new Error('Bad credentials')))
).toEqual(
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 { 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) => {
switch (action.type) {
@ -9,6 +9,8 @@ const auth = (state = null, action) => {
return Immutable.fromJS({ user: action.payload });
case AUTH_FAILURE:
return Immutable.Map({ error: action.payload.toString() });
case LOGOUT:
return state.remove('user');
default:
return state;
}