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

View File

@ -1,12 +1,12 @@
// @flow
import React from 'react';
import { observer, inject } from 'mobx-react';
import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
type Props = {
children: React.Element<any>,
scopes?: Array<string>,
user: UserStore,
auth: AuthStore,
redirectUri: string,
};
@ -28,7 +28,7 @@ type Props = {
client_id: SLACK_KEY,
scope: this.props.scopes ? this.props.scopes.join(' ') : '',
redirect_uri: this.props.redirectUri || SLACK_REDIRECT_URI,
state: this.props.user.getOauthState(),
state: this.props.auth.getOauthState(),
};
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) => {
if (stores.user.authenticated && stores.user.team) {
if (stores.auth.authenticated && stores.auth.team) {
// Only initialize stores once. Kept in global scope
// because otherwise they will get overriden on route
// change
if (!authenticatedStores) {
// Stores for authenticated user
const user = stores.auth.getUserStore();
authenticatedStores = {
user,
collections: new CollectionsStore({
teamId: stores.user.team.id,
teamId: user.team.id,
}),
};

View File

@ -2,22 +2,23 @@
import React from 'react';
import { observer, inject } from 'mobx-react';
import { Redirect } from 'react-router';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
import AuthStore from 'stores/AuthStore';
import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import SlackAuthLink from 'components/SlackAuthLink';
import Alert from 'components/Alert';
import styles from './Home.scss';
type Props = {
auth: AuthStore,
location: Object,
};
@inject('user')
@observer
export default class Home extends React.Component {
static propTypes = {
user: React.PropTypes.object.isRequired,
location: React.PropTypes.object.isRequired,
};
@observer class Home extends React.Component {
props: Props;
get notifications(): React.Element<any>[] {
const notifications = [];
@ -40,17 +41,17 @@ export default class Home extends React.Component {
return (
<Flex auto>
<Layout notifications={this.notifications}>
{this.props.user.authenticated && <Redirect to="/dashboard" />}
{this.props.auth.authenticated && <Redirect to="/dashboard" />}
<CenteredContent>
{showLandingPageCopy &&
<div className={styles.intro}>
<h1 className={styles.title}>Simple, fast, markdown.</h1>
<p className={styles.copy}>
<div>
<Title>Simple, fast, markdown.</Title>
<Copy>
We're building a modern wiki for engineering teams.
</p>
</Copy>
</div>}
<div className={styles.action}>
<div>
<SlackAuthLink redirectUri={`${BASE_URL}/auth/slack`}>
<img
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 { client } from 'utils/ApiClient';
import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
type Props = {
user: UserStore,
auth: AuthStore,
location: Object,
};
@inject('user')
@observer
class SlackAuth extends React.Component {
props: Props;
type State = {
redirectTo: string,
};
state: { redirectTo?: string };
@observer class SlackAuth extends React.Component {
props: Props;
state: State;
state = {};
// $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');
sessionStorage.removeItem('redirectTo');
const { success } = await this.props.user.authWithSlack(code, state);
const { success } = await this.props.auth.authWithSlack(code, state);
success
? this.setState({ redirectTo: redirectTo || '/dashboard' })
: 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 ApiClient, { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores';
import Collection from 'models/Collection';
@ -26,6 +27,7 @@ class CollectionsStore {
const res = await this.client.post('/collections.list', {
id: this.teamId,
});
invariant(res && res.data, 'Collection list not available');
const { data } = res;
runInAction('CollectionsStore#fetch', () => {
this.data.replace(

View File

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

View File

@ -1,87 +1,32 @@
// @flow
import { observable, action, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import { observable, computed } from 'mobx';
import type { User, Team } from 'types';
const USER_STORE = 'USER_STORE';
type Options = {
user: User,
team: Team,
};
class UserStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable oauthState: string;
@observable user: User;
@observable team: Team;
@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,
};
};
constructor() {
constructor(options: Options) {
// Rehydrate
const data = JSON.parse(localStorage.getItem(USER_STORE) || '{}');
this.user = data.user;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
this.user = options.user;
this.team = options.team;
}
}
export default UserStore;
export { USER_STORE };

View File

@ -1,20 +1,13 @@
// @flow
import { autorunAsync } from 'mobx';
import UserStore, { USER_STORE } from './UserStore';
import UiStore, { UI_STORE } from './UiStore';
import AuthStore from './AuthStore';
import UiStore from './UiStore';
import ErrorsStore from './ErrorsStore';
const stores = {
user: new UserStore(),
user: null, // Including for Layout
auth: new AuthStore(),
ui: new UiStore(),
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;

View File

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