Separated user and auth stores

This commit is contained in:
Jori Lallo
2017-05-29 19:08:03 -07:00
parent 98a5283e27
commit 0d87d6abf5
12 changed files with 191 additions and 143 deletions

View File

@ -13,6 +13,7 @@ import { textColor, headerHeight } from 'styles/constants.scss';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import LoadingIndicator from 'components/LoadingIndicator'; import LoadingIndicator from 'components/LoadingIndicator';
import UserStore from 'stores/UserStore'; import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
type Props = { type Props = {
history: Object, history: Object,
@ -21,6 +22,7 @@ type Props = {
title?: ?React.Element<any>, title?: ?React.Element<any>,
loading?: boolean, loading?: boolean,
user: UserStore, user: UserStore,
auth: AuthStore,
search: ?boolean, search: ?boolean,
notifications?: React.Element<any>, notifications?: React.Element<any>,
}; };
@ -34,22 +36,22 @@ type Props = {
@keydown(['/', 't']) @keydown(['/', 't'])
search() { search() {
if (!this.props.user) return; if (this.props.auth.isAuthenticated)
_.defer(() => this.props.history.push('/search')); _.defer(() => this.props.history.push('/search'));
} }
@keydown(['d']) @keydown(['d'])
dashboard() { dashboard() {
if (!this.props.user) return; if (this.props.auth.isAuthenticated)
_.defer(() => this.props.history.push('/')); _.defer(() => this.props.history.push('/'));
} }
render() { handleLogout = () => {
const user = this.props.user; this.props.auth.logout(() => this.props.history.push('/'));
};
const handleLogout = () => { render() {
user.logout(() => this.props.history.push('/')); const { auth, user } = this.props;
};
return ( return (
<Container column auto> <Container column auto>
@ -79,7 +81,8 @@ type Props = {
<Flex align="center"> <Flex align="center">
{this.props.actions} {this.props.actions}
</Flex> </Flex>
{user.user && {auth.authenticated &&
user &&
<Flex> <Flex>
{this.props.search && {this.props.search &&
<Flex> <Flex>
@ -101,7 +104,7 @@ type Props = {
<MenuLink to="/developers"> <MenuLink to="/developers">
<MenuItem>API</MenuItem> <MenuItem>API</MenuItem>
</MenuLink> </MenuLink>
<MenuItem onClick={handleLogout}>Logout</MenuItem> <MenuItem onClick={this.handleLogout}>Logout</MenuItem>
</DropdownMenu> </DropdownMenu>
</Flex>} </Flex>}
</Flex> </Flex>
@ -182,4 +185,4 @@ const Content = styled(Flex)`
overflow: scroll; overflow: scroll;
`; `;
export default withRouter(inject('user')(Layout)); export default withRouter(inject('user', 'auth')(Layout));

View File

@ -1,12 +1,12 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import UserStore from 'stores/UserStore'; import AuthStore from 'stores/AuthStore';
type Props = { type Props = {
children: React.Element<any>, children: React.Element<any>,
scopes?: Array<string>, scopes?: Array<string>,
user: UserStore, auth: AuthStore,
redirectUri: string, redirectUri: string,
}; };
@ -28,7 +28,7 @@ type Props = {
client_id: SLACK_KEY, client_id: SLACK_KEY,
scope: this.props.scopes ? this.props.scopes.join(' ') : '', scope: this.props.scopes ? this.props.scopes.join(' ') : '',
redirect_uri: this.props.redirectUri || SLACK_REDIRECT_URI, redirect_uri: this.props.redirectUri || SLACK_REDIRECT_URI,
state: this.props.user.getOauthState(), state: this.props.auth.getOauthState(),
}; };
const urlParams = Object.keys(params) const urlParams = Object.keys(params)
@ -45,4 +45,4 @@ type Props = {
} }
} }
export default inject('user')(SlackAuthLink); export default inject('auth')(SlackAuthLink);

View File

@ -45,15 +45,17 @@ type AuthProps = {
}; };
const Auth = ({ children }: AuthProps) => { const Auth = ({ children }: AuthProps) => {
if (stores.user.authenticated && stores.user.team) { if (stores.auth.authenticated && stores.auth.team) {
// Only initialize stores once. Kept in global scope // Only initialize stores once. Kept in global scope
// because otherwise they will get overriden on route // because otherwise they will get overriden on route
// change // change
if (!authenticatedStores) { if (!authenticatedStores) {
// Stores for authenticated user // Stores for authenticated user
const user = stores.auth.getUserStore();
authenticatedStores = { authenticatedStores = {
user,
collections: new CollectionsStore({ collections: new CollectionsStore({
teamId: stores.user.team.id, teamId: user.team.id,
}), }),
}; };

View File

@ -2,22 +2,23 @@
import React from 'react'; import React from 'react';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import { Flex } from 'reflexbox'; import { Flex } from 'reflexbox';
import styled from 'styled-components';
import AuthStore from 'stores/AuthStore';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent'; import CenteredContent from 'components/CenteredContent';
import SlackAuthLink from 'components/SlackAuthLink'; import SlackAuthLink from 'components/SlackAuthLink';
import Alert from 'components/Alert'; import Alert from 'components/Alert';
import styles from './Home.scss'; type Props = {
auth: AuthStore,
location: Object,
};
@inject('user') @observer class Home extends React.Component {
@observer props: Props;
export default class Home extends React.Component {
static propTypes = {
user: React.PropTypes.object.isRequired,
location: React.PropTypes.object.isRequired,
};
get notifications(): React.Element<any>[] { get notifications(): React.Element<any>[] {
const notifications = []; const notifications = [];
@ -40,17 +41,17 @@ export default class Home extends React.Component {
return ( return (
<Flex auto> <Flex auto>
<Layout notifications={this.notifications}> <Layout notifications={this.notifications}>
{this.props.user.authenticated && <Redirect to="/dashboard" />} {this.props.auth.authenticated && <Redirect to="/dashboard" />}
<CenteredContent> <CenteredContent>
{showLandingPageCopy && {showLandingPageCopy &&
<div className={styles.intro}> <div>
<h1 className={styles.title}>Simple, fast, markdown.</h1> <Title>Simple, fast, markdown.</Title>
<p className={styles.copy}> <Copy>
We're building a modern wiki for engineering teams. We're building a modern wiki for engineering teams.
</p> </Copy>
</div>} </div>}
<div className={styles.action}> <div>
<SlackAuthLink redirectUri={`${BASE_URL}/auth/slack`}> <SlackAuthLink redirectUri={`${BASE_URL}/auth/slack`}>
<img <img
alt="Sign in with Slack" alt="Sign in with Slack"
@ -67,3 +68,15 @@ export default class Home extends React.Component {
); );
} }
} }
const Title = styled.h1`
font-size: 36px;
margin-bottom: 24px;
}`;
const Copy = styled.p`
font-size: 20px;
margin-bottom: 36px;
}`;
export default inject('auth')(Home);

View File

@ -1,9 +0,0 @@
.title {
font-size: 36px;
margin-bottom: 24px;
}
.copy {
font-size: 20px;
margin-bottom: 36px;
}

View File

@ -5,19 +5,20 @@ import queryString from 'query-string';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { client } from 'utils/ApiClient'; import { client } from 'utils/ApiClient';
import UserStore from 'stores/UserStore'; import AuthStore from 'stores/AuthStore';
type Props = { type Props = {
user: UserStore, auth: AuthStore,
location: Object, location: Object,
}; };
@inject('user') type State = {
@observer redirectTo: string,
class SlackAuth extends React.Component { };
props: Props;
state: { redirectTo?: string }; @observer class SlackAuth extends React.Component {
props: Props;
state: State;
state = {}; state = {};
// $FlowIssue Flow doesn't like async lifecycle components https://github.com/facebook/flow/issues/1803 // $FlowIssue Flow doesn't like async lifecycle components https://github.com/facebook/flow/issues/1803
@ -47,7 +48,7 @@ class SlackAuth extends React.Component {
const redirectTo = sessionStorage.getItem('redirectTo'); const redirectTo = sessionStorage.getItem('redirectTo');
sessionStorage.removeItem('redirectTo'); sessionStorage.removeItem('redirectTo');
const { success } = await this.props.user.authWithSlack(code, state); const { success } = await this.props.auth.authWithSlack(code, state);
success success
? this.setState({ redirectTo: redirectTo || '/dashboard' }) ? this.setState({ redirectTo: redirectTo || '/dashboard' })
: this.setState({ redirectTo: '/auth/error' }); : this.setState({ redirectTo: '/auth/error' });
@ -64,4 +65,4 @@ class SlackAuth extends React.Component {
} }
} }
export default SlackAuth; export default inject('auth')(SlackAuth);

View File

@ -0,0 +1,100 @@
// @flow
import { observable, action, computed, autorunAsync } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import UserStore from 'stores/UserStore';
import type { User, Team } from 'types';
const AUTH_STORE = 'AUTH_STORE';
class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable oauthState: string;
@observable isLoading: boolean = false;
/* Computed */
@computed get authenticated(): boolean {
return !!this.token;
}
@computed get asJson(): string {
return JSON.stringify({
user: this.user,
team: this.team,
token: this.token,
oauthState: this.oauthState,
});
}
/* Actions */
@action logout = (cb: Function) => {
this.user = null;
this.token = null;
cb();
};
@action getOauthState = () => {
const state = Math.random().toString(36).substring(7);
this.oauthState = state;
return this.oauthState;
};
@action authWithSlack = async (code: string, state: string) => {
if (state !== this.oauthState) {
return {
success: false,
};
}
let res;
try {
res = await client.post('/auth.slack', { code });
} catch (e) {
return {
success: false,
};
}
invariant(
res && res.data && res.data.user && res.data.team && res.data.accessToken,
'All values should be available'
);
this.user = res.data.user;
this.team = res.data.team;
this.token = res.data.accessToken;
return {
success: true,
};
};
getUserStore(): UserStore {
invariant(
this.user && this.team,
'Tried to create a user store without data'
);
return new UserStore({
user: this.user,
team: this.team,
});
}
constructor() {
// Rehydrate
const data = JSON.parse(localStorage.getItem(AUTH_STORE) || '{}');
this.user = data.user;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
autorunAsync(() => {
localStorage.setItem(AUTH_STORE, this.asJson);
});
}
}
export default AuthStore;

View File

@ -2,6 +2,7 @@
import { observable, action, runInAction, ObservableArray } from 'mobx'; import { observable, action, runInAction, ObservableArray } from 'mobx';
import ApiClient, { client } from 'utils/ApiClient'; import ApiClient, { client } from 'utils/ApiClient';
import _ from 'lodash'; import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores'; import stores from 'stores';
import Collection from 'models/Collection'; import Collection from 'models/Collection';
@ -26,6 +27,7 @@ class CollectionsStore {
const res = await this.client.post('/collections.list', { const res = await this.client.post('/collections.list', {
id: this.teamId, id: this.teamId,
}); });
invariant(res && res.data, 'Collection list not available');
const { data } = res; const { data } = res;
runInAction('CollectionsStore#fetch', () => { runInAction('CollectionsStore#fetch', () => {
this.data.replace( this.data.replace(

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { observable, action, computed } from 'mobx'; import { observable, action, computed, autorunAsync } from 'mobx';
const UI_STORE = 'UI_STORE'; const UI_STORE = 'UI_STORE';
@ -24,8 +24,11 @@ class UiStore {
// Rehydrate // Rehydrate
const data = JSON.parse(localStorage.getItem(UI_STORE) || '{}'); const data = JSON.parse(localStorage.getItem(UI_STORE) || '{}');
this.sidebar = data.sidebar; this.sidebar = data.sidebar;
autorunAsync(() => {
localStorage.setItem(UI_STORE, this.asJson);
});
} }
} }
export default UiStore; export default UiStore;
export { UI_STORE };

View File

@ -1,87 +1,32 @@
// @flow // @flow
import { observable, action, computed } from 'mobx'; import { observable, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import type { User, Team } from 'types'; import type { User, Team } from 'types';
const USER_STORE = 'USER_STORE'; type Options = {
user: User,
team: Team,
};
class UserStore { class UserStore {
@observable user: ?User; @observable user: User;
@observable team: ?Team; @observable team: Team;
@observable token: ?string;
@observable oauthState: string;
@observable isLoading: boolean = false; @observable isLoading: boolean = false;
/* Computed */ /* Computed */
@computed get authenticated(): boolean {
return !!this.token;
}
@computed get asJson(): string { @computed get asJson(): string {
return JSON.stringify({ return JSON.stringify({
user: this.user, user: this.user,
team: this.team, team: this.team,
token: this.token,
oauthState: this.oauthState,
}); });
} }
/* Actions */ constructor(options: Options) {
@action logout = (cb: Function) => {
this.user = null;
this.token = null;
cb();
};
@action getOauthState = () => {
const state = Math.random().toString(36).substring(7);
this.oauthState = state;
return this.oauthState;
};
@action authWithSlack = async (code: string, state: string) => {
if (state !== this.oauthState) {
return {
success: false,
};
}
let res;
try {
res = await client.post('/auth.slack', { code });
} catch (e) {
return {
success: false,
};
}
invariant(
res && res.data && res.data.user && res.data.team && res.data.accessToken,
'All values should be available'
);
this.user = res.data.user;
this.team = res.data.team;
this.token = res.data.accessToken;
return {
success: true,
};
};
constructor() {
// Rehydrate // Rehydrate
const data = JSON.parse(localStorage.getItem(USER_STORE) || '{}'); this.user = options.user;
this.user = data.user; this.team = options.team;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
} }
} }
export default UserStore; export default UserStore;
export { USER_STORE };

View File

@ -1,20 +1,13 @@
// @flow // @flow
import { autorunAsync } from 'mobx'; import AuthStore from './AuthStore';
import UserStore, { USER_STORE } from './UserStore'; import UiStore from './UiStore';
import UiStore, { UI_STORE } from './UiStore';
import ErrorsStore from './ErrorsStore'; import ErrorsStore from './ErrorsStore';
const stores = { const stores = {
user: new UserStore(), user: null, // Including for Layout
auth: new AuthStore(),
ui: new UiStore(), ui: new UiStore(),
errors: new ErrorsStore(), errors: new ErrorsStore(),
}; };
// Persist stores to localStorage
// TODO: move to store constructors
autorunAsync(() => {
localStorage.setItem(USER_STORE, stores.user.asJson);
localStorage.setItem(UI_STORE, stores.ui.asJson);
});
export default stores; export default stores;

View File

@ -1,5 +1,6 @@
// @flow // @flow
import _ from 'lodash'; import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores'; import stores from 'stores';
type Options = { type Options = {
@ -39,9 +40,9 @@ class ApiClient {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}); });
if (stores.user.authenticated) { if (stores.auth.authenticated) {
// $FlowFixMe this is not great, need to refactor invariant(stores.auth.token, 'JWT token not set properly');
headers.set('Authorization', `Bearer ${stores.user.token}`); headers.set('Authorization', `Bearer ${stores.auth.token}`);
} }
// Construct request // Construct request
@ -62,15 +63,10 @@ class ApiClient {
return response; return response;
} }
// // Handle 404 // Handle 401, log out user
// if (response.status === 404) { if (response.status === 401) {
// // return browserHistory.push('/404'); return stores.auth.logout(() => (window.location = '/'));
// } }
// // Handle 401, log out user
// if (response.status === 401) {
// return stores.user.logout();
// }
// Handle failed responses // Handle failed responses
const error = {}; const error = {};
@ -112,5 +108,4 @@ class ApiClient {
export default ApiClient; export default ApiClient;
// In case you don't want to always initiate, just import with `import { client } ...` // In case you don't want to always initiate, just import with `import { client } ...`
const client = new ApiClient(); export const client = new ApiClient();
export { client };