Merge pull request #659 from outline/google-auth

Google Auth
This commit is contained in:
Tom Moor
2018-06-03 15:29:02 -04:00
committed by GitHub
94 changed files with 1198 additions and 977 deletions

View File

@ -14,10 +14,13 @@ DEPLOYMENT=self
ENABLE_UPDATES=true
DEBUG=sql,cache,presenters,events
# Third party credentials (required)
# Third party signin credentials (at least one is required)
SLACK_KEY=71315967491.XXXXXXXXXX
SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Third party credentials (optional)
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
SLACK_APP_ID=A0XXXXXXX

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
dist
node_modules/*
.env
.log
npm-debug.log
stats.json
.DS_Store

View File

@ -1,29 +1,39 @@
// @flow
import * as React from 'react';
import { Provider } from 'mobx-react';
import { Provider, observer, inject } from 'mobx-react';
import stores from 'stores';
import AuthStore from 'stores/AuthStore';
import ApiKeysStore from 'stores/ApiKeysStore';
import UsersStore from 'stores/UsersStore';
import CollectionsStore from 'stores/CollectionsStore';
import IntegrationsStore from 'stores/IntegrationsStore';
import CacheStore from 'stores/CacheStore';
import LoadingIndicator from 'components/LoadingIndicator';
type Props = {
auth: AuthStore,
children?: React.Node,
};
let authenticatedStores;
const Auth = ({ children }: Props) => {
if (stores.auth.authenticated && stores.auth.team && stores.auth.user) {
const Auth = observer(({ auth, children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
if (!team || !user) {
return <LoadingIndicator />;
}
// Only initialize stores once. Kept in global scope because otherwise they
// will get overridden on route change
if (!authenticatedStores) {
// Stores for authenticated user
const { user, team } = stores.auth;
const cache = new CacheStore(user.id);
authenticatedStores = {
integrations: new IntegrationsStore(),
integrations: new IntegrationsStore({
ui: stores.ui,
}),
apiKeys: new ApiKeysStore(),
users: new UsersStore(),
collections: new CollectionsStore({
@ -42,15 +52,14 @@ const Auth = ({ children }: Props) => {
};
}
stores.auth.fetch();
authenticatedStores.collections.fetchPage({ limit: 100 });
}
return <Provider {...authenticatedStores}>{children}</Provider>;
}
stores.auth.logout();
auth.logout();
return null;
};
});
export default Auth;
export default inject('auth')(Auth);

View File

@ -30,7 +30,9 @@ const RealInput = styled.input`
}
`;
const Wrapper = styled.div``;
const Wrapper = styled.div`
max-width: ${props => (props.short ? '350px' : '100%')};
`;
export const Outline = styled(Flex)`
display: flex;
@ -58,18 +60,20 @@ export type Props = {
value?: string,
label?: string,
className?: string,
short?: boolean,
};
export default function Input({
type = 'text',
label,
className,
short,
...rest
}: Props) {
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
return (
<Wrapper className={className}>
<Wrapper className={className} short={short}>
<label>
{label && <LabelText>{label}</LabelText>}
<Outline>

View File

@ -112,7 +112,7 @@ class Layout extends React.Component<Props> {
</Content>
</Flex>
<Modals ui={ui} />
<Toasts />
<Toasts ui={ui} />
</Container>
);
}

View File

@ -7,6 +7,7 @@ import {
CodeIcon,
UserIcon,
LinkIcon,
TeamIcon,
} from 'outline-icons';
import Flex from 'shared/components/Flex';
@ -29,8 +30,8 @@ class SettingsSidebar extends React.Component<Props> {
};
render() {
const { team } = this.props.auth;
if (!team) return;
const { team, user } = this.props.auth;
if (!team || !user) return;
return (
<Sidebar>
@ -54,18 +55,25 @@ class SettingsSidebar extends React.Component<Props> {
</Section>
<Section>
<Header>Team</Header>
<SidebarLink to="/settings/members" icon={<UserIcon />}>
Members
{user.isAdmin && (
<SidebarLink to="/settings/details" icon={<TeamIcon />}>
Details
</SidebarLink>
)}
<SidebarLink to="/settings/people" icon={<UserIcon />}>
People
</SidebarLink>
<SidebarLink to="/settings/shares" icon={<LinkIcon />}>
Share Links
</SidebarLink>
<SidebarLink
to="/settings/integrations/slack"
icon={<SettingsIcon />}
>
Integrations
</SidebarLink>
{user.isAdmin && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SettingsIcon />}
>
Integrations
</SidebarLink>
)}
</Section>
</Scrollable>
</Flex>

View File

@ -1,26 +1,30 @@
// @flow
import * as React from 'react';
import { inject, observer } from 'mobx-react';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { layout } from 'shared/styles/constants';
import Toast from './components/Toast';
import UiStore from '../../stores/UiStore';
type Props = {
ui: UiStore,
};
@observer
class Toasts extends React.Component<*> {
handleClose = index => {
this.props.errors.remove(index);
class Toasts extends React.Component<Props> {
handleClose = (index: number) => {
this.props.ui.removeToast(index);
};
render() {
const { errors } = this.props;
const { ui } = this.props;
return (
<List>
{errors.data.map((error, index) => (
{ui.toasts.map((toast, index) => (
<Toast
key={index}
onRequestClose={this.handleClose.bind(this, index)}
message={error}
toast={toast}
/>
))}
</List>
@ -35,6 +39,7 @@ const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
z-index: 1000;
`;
export default inject('errors')(Toasts);
export default Toasts;

View File

@ -4,12 +4,12 @@ import styled from 'styled-components';
import { darken } from 'polished';
import { color } from 'shared/styles/constants';
import { fadeAndScaleIn } from 'shared/styles/animations';
import type { Toast as TToast } from '../../../types';
type Props = {
onRequestClose: () => void,
closeAfterMs: number,
message: string,
type: 'warning' | 'error' | 'info',
toast: TToast,
};
class Toast extends React.Component<Props> {
@ -17,7 +17,6 @@ class Toast extends React.Component<Props> {
static defaultProps = {
closeAfterMs: 3000,
type: 'warning',
};
componentDidMount() {
@ -32,14 +31,14 @@ class Toast extends React.Component<Props> {
}
render() {
const { type, onRequestClose } = this.props;
const { toast, onRequestClose } = this.props;
const message =
typeof this.props.message === 'string'
? this.props.message
: this.props.message.toString();
typeof toast.message === 'string'
? toast.message
: toast.message.toString();
return (
<Container onClick={onRequestClose} type={type}>
<Container onClick={onRequestClose} type={toast.type}>
<Message>{message}</Message>
</Container>
);

View File

@ -21,12 +21,11 @@ import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import Members from 'scenes/Settings/Members';
import Details from 'scenes/Settings/Details';
import People from 'scenes/Settings/People';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens';
import SlackAuth from 'scenes/SlackAuth';
import ErrorAuth from 'scenes/ErrorAuth';
import Error404 from 'scenes/Error404';
import ErrorBoundary from 'components/ErrorBoundary';
@ -61,14 +60,6 @@ if (element) {
<ScrollToTop>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/auth/slack" component={SlackAuth} />
<Route
exact
path="/auth/slack/commands"
component={SlackAuth}
/>
<Route exact path="/auth/slack/post" component={SlackAuth} />
<Route exact path="/auth/error" component={ErrorAuth} />
<Route exact path="/share/:shareId" component={Document} />
<Auth>
<Layout>
@ -79,9 +70,10 @@ if (element) {
<Route exact path="/settings" component={Settings} />
<Route
exact
path="/settings/members"
component={Members}
path="/settings/details"
component={Details}
/>
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route

View File

@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom';
import { inject } from 'mobx-react';
import { MoreIcon } from 'outline-icons';
import { Share } from 'types';
import type { Share } from 'types';
import CopyToClipboard from 'components/CopyToClipboard';
import SharesStore from 'stores/SharesStore';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
@ -12,7 +12,7 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
type Props = {
label?: React.Node,
onOpen?: () => *,
onClose?: () => *,
onClose: () => *,
history: Object,
shares: SharesStore,
share: Share,

View File

@ -6,13 +6,13 @@ import BaseModel from 'models/BaseModel';
import Document from 'models/Document';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import ErrorsStore from 'stores/ErrorsStore';
import UiStore from 'stores/UiStore';
import type { NavigationNode } from 'types';
class Collection extends BaseModel {
isSaving: boolean = false;
hasPendingChanges: boolean = false;
errors: ErrorsStore;
ui: UiStore;
data: Object;
createdAt: string;
@ -79,7 +79,7 @@ class Collection extends BaseModel {
this.updateData(data);
});
} catch (e) {
this.errors.add('Collection failed loading');
this.ui.showToast('Collection failed loading');
}
return this;
@ -112,7 +112,7 @@ class Collection extends BaseModel {
this.hasPendingChanges = false;
});
} catch (e) {
this.errors.add('Collection failed saving');
this.ui.showToast('Collection failed saving');
return false;
} finally {
this.isSaving = false;
@ -128,7 +128,7 @@ class Collection extends BaseModel {
this.emit('collections.delete', { id: this.id });
return true;
} catch (e) {
this.errors.add('Collection failed to delete');
this.ui.showToast('Collection failed to delete');
}
return false;
};
@ -143,7 +143,7 @@ class Collection extends BaseModel {
super();
this.updateData(collection);
this.errors = stores.errors;
this.ui = stores.ui;
this.on('documents.delete', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch();

View File

@ -28,22 +28,5 @@ describe('Collection model', () => {
expect(client.post).toHaveBeenCalledWith('/collections.info', { id: 123 });
expect(collection.name).toBe('New collection');
});
test('should report errors', async () => {
client.post = jest.fn(() => Promise.reject())
const collection = new Collection({
id: 123,
});
collection.errors = {
add: jest.fn(),
};
await collection.fetch();
expect(collection.errors.add).toHaveBeenCalledWith(
'Collection failed loading'
);
});
});
});

View File

@ -4,7 +4,7 @@ import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import ErrorsStore from 'stores/ErrorsStore';
import UiStore from 'stores/UiStore';
import parseTitle from '../../shared/utils/parseTitle';
import type { User } from 'types';
@ -16,7 +16,7 @@ type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
class Document extends BaseModel {
isSaving: boolean = false;
hasPendingChanges: boolean = false;
errors: ErrorsStore;
ui: UiStore;
collaborators: User[];
collection: $Shape<Collection>;
@ -107,7 +107,7 @@ class Document extends BaseModel {
this.shareUrl = res.data.url;
} catch (e) {
this.errors.add('Document failed to share');
this.ui.showToast('Document failed to share');
}
};
@ -118,7 +118,7 @@ class Document extends BaseModel {
await client.post('/documents.pin', { id: this.id });
} catch (e) {
this.pinned = false;
this.errors.add('Document failed to pin');
this.ui.showToast('Document failed to pin');
}
};
@ -129,7 +129,7 @@ class Document extends BaseModel {
await client.post('/documents.unpin', { id: this.id });
} catch (e) {
this.pinned = true;
this.errors.add('Document failed to unpin');
this.ui.showToast('Document failed to unpin');
}
};
@ -140,7 +140,7 @@ class Document extends BaseModel {
await client.post('/documents.star', { id: this.id });
} catch (e) {
this.starred = false;
this.errors.add('Document failed star');
this.ui.showToast('Document failed star');
}
};
@ -151,7 +151,7 @@ class Document extends BaseModel {
await client.post('/documents.unstar', { id: this.id });
} catch (e) {
this.starred = false;
this.errors.add('Document failed unstar');
this.ui.showToast('Document failed unstar');
}
};
@ -161,7 +161,7 @@ class Document extends BaseModel {
try {
await client.post('/views.create', { id: this.id });
} catch (e) {
this.errors.add('Document failed to record view');
this.ui.showToast('Document failed to record view');
}
};
@ -175,7 +175,7 @@ class Document extends BaseModel {
this.updateData(data);
});
} catch (e) {
this.errors.add('Document failed loading');
this.ui.showToast('Document failed loading');
}
};
@ -228,7 +228,7 @@ class Document extends BaseModel {
});
}
} catch (e) {
this.errors.add('Document failed to save');
this.ui.showToast('Document failed to save');
} finally {
this.isSaving = false;
}
@ -250,7 +250,7 @@ class Document extends BaseModel {
collectionId: this.collection.id,
});
} catch (e) {
this.errors.add('Error while moving the document');
this.ui.showToast('Error while moving the document');
}
return;
};
@ -265,7 +265,7 @@ class Document extends BaseModel {
});
return true;
} catch (e) {
this.errors.add('Error while deleting the document');
this.ui.showToast('Error while deleting the document');
}
return false;
};
@ -294,7 +294,7 @@ class Document extends BaseModel {
super();
this.updateData(data);
this.errors = stores.errors;
this.ui = stores.ui;
}
}

View File

@ -4,7 +4,7 @@ import { extendObservable, action } from 'mobx';
import BaseModel from 'models/BaseModel';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import ErrorsStore from 'stores/ErrorsStore';
import UiStore from 'stores/UiStore';
type Settings = {
url: string,
@ -15,10 +15,10 @@ type Settings = {
type Events = 'documents.create' | 'collections.create';
class Integration extends BaseModel {
errors: ErrorsStore;
ui: UiStore;
id: string;
serviceId: string;
service: string;
collectionId: string;
events: Events;
settings: Settings;
@ -29,7 +29,7 @@ class Integration extends BaseModel {
await client.post('/integrations.update', { id: this.id, ...data });
extendObservable(this, data);
} catch (e) {
this.errors.add('Integration failed to update');
this.ui.showToast('Integration failed to update');
}
return false;
};
@ -41,7 +41,7 @@ class Integration extends BaseModel {
this.emit('integrations.delete', { id: this.id });
return true;
} catch (e) {
this.errors.add('Integration failed to delete');
this.ui.showToast('Integration failed to delete');
}
return false;
};
@ -50,7 +50,7 @@ class Integration extends BaseModel {
super();
extendObservable(this, data);
this.errors = stores.errors;
this.ui = stores.ui;
}
}

View File

@ -1,23 +0,0 @@
// @flow
import * as React from 'react';
import { Link } from 'react-router-dom';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
class ErrorAuth extends React.Component<*> {
render() {
return (
<CenteredContent>
<PageTitle title="Authentication error" />
<h1>Authentication failed</h1>
<p>
We were unable to log you in. <Link to="/">Please try again.</Link>
</p>
</CenteredContent>
);
}
}
export default ErrorAuth;

View File

@ -1,3 +0,0 @@
// @flow
import ErrorAuth from './ErrorAuth';
export default ErrorAuth;

View File

@ -0,0 +1,161 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import styled from 'styled-components';
import { color, size } from 'shared/styles/constants';
import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
import ImageUpload from './components/ImageUpload';
import Input, { LabelText } from 'components/Input';
import Button from 'components/Button';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import Flex from 'shared/components/Flex';
type Props = {
auth: AuthStore,
ui: UiStore,
};
@observer
class Details extends React.Component<Props> {
timeout: TimeoutID;
form: ?HTMLFormElement;
@observable name: string;
@observable avatarUrl: ?string;
componentDidMount() {
if (this.props.auth.team) {
this.name = this.props.auth.team.name;
}
}
componentWillUnmount() {
clearTimeout(this.timeout);
}
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
await this.props.auth.updateTeam({
name: this.name,
avatarUrl: this.avatarUrl,
});
this.props.ui.showToast('Details saved', 'success');
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
handleAvatarUpload = (avatarUrl: string) => {
this.avatarUrl = avatarUrl;
};
handleAvatarError = (error: ?string) => {
this.props.ui.showToast(error || 'Unable to upload new avatar');
};
get isValid() {
return this.form && this.form.checkValidity();
}
render() {
const { team, isSaving } = this.props.auth;
if (!team) return null;
const avatarUrl = this.avatarUrl || team.avatarUrl;
return (
<CenteredContent>
<PageTitle title="Details" />
<h1>Details</h1>
{team.slackConnected && (
<HelpText>
This team is connected to a <strong>Slack</strong> team. Your
colleagues can join by signing in with their Slack account details.
</HelpText>
)}
{team.googleConnected && (
<HelpText>
This team is connected to a <strong>Google</strong> domain. Your
colleagues can join by signing in with their Google account.
</HelpText>
)}
<ProfilePicture column>
<LabelText>Logo</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
submitText="Crop logo"
borderRadius={0}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
<Input
label="Name"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? 'Saving…' : 'Save'}
</Button>
</form>
</CenteredContent>
);
}
}
const ProfilePicture = styled(Flex)`
margin-bottom: ${size.huge};
`;
const avatarStyles = `
width: 80px;
height: 80px;
border-radius: 8px;
`;
const AvatarContainer = styled(Flex)`
${avatarStyles};
position: relative;
box-shadow: 0 0 0 1px #dae1e9;
background: ${color.white};
div div {
${avatarStyles};
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
cursor: pointer;
transition: all 250ms;
}
&:hover div {
opacity: 1;
background: rgba(0, 0, 0, 0.75);
color: ${color.white};
}
`;
const Avatar = styled.img`
${avatarStyles};
`;
export default inject('auth', 'ui')(Details);

View File

@ -7,6 +7,7 @@ import AuthStore from 'stores/AuthStore';
import UsersStore from 'stores/UsersStore';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import UserListItem from './components/UserListItem';
import List from 'components/List';
@ -16,7 +17,7 @@ type Props = {
};
@observer
class Members extends React.Component<Props> {
class People extends React.Component<Props> {
componentDidMount() {
this.props.users.fetchPage({ limit: 100 });
}
@ -28,8 +29,13 @@ class Members extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title="Members" />
<h1>Members</h1>
<PageTitle title="People" />
<h1>People</h1>
<HelpText>
Everyone that has signed in to your Outline appears here. It's
possible that there are other people who have access but haven't
signed in yet.
</HelpText>
<List>
{users.data.map(user => (
@ -45,4 +51,4 @@ class Members extends React.Component<Props> {
}
}
export default inject('auth', 'users')(Members);
export default inject('auth', 'users')(People);

View File

@ -1,14 +1,12 @@
// @flow
import * as React from 'react';
import { observable, runInAction } from 'mobx';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import invariant from 'invariant';
import styled from 'styled-components';
import { color, size } from 'shared/styles/constants';
import { client } from 'utils/ApiClient';
import AuthStore from 'stores/AuthStore';
import ErrorsStore from 'stores/ErrorsStore';
import UiStore from 'stores/UiStore';
import ImageUpload from './components/ImageUpload';
import Input, { LabelText } from 'components/Input';
import Button from 'components/Button';
@ -18,17 +16,16 @@ import Flex from 'shared/components/Flex';
type Props = {
auth: AuthStore,
errors: ErrorsStore,
ui: UiStore,
};
@observer
class Settings extends React.Component<Props> {
class Profile extends React.Component<Props> {
timeout: TimeoutID;
form: ?HTMLFormElement;
@observable name: string;
@observable avatarUrl: ?string;
@observable isUpdated: boolean;
@observable isSaving: boolean;
componentDidMount() {
if (this.props.auth.user) {
@ -42,25 +39,12 @@ class Settings extends React.Component<Props> {
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isSaving = true;
try {
const res = await client.post(`/user.update`, {
name: this.name,
avatarUrl: this.avatarUrl,
});
invariant(res && res.data, 'User response not available');
const { data } = res;
runInAction('Settings#handleSubmit', () => {
this.props.auth.user = data;
this.isUpdated = true;
this.timeout = setTimeout(() => (this.isUpdated = false), 2500);
});
} catch (e) {
this.props.errors.add('Failed to update user');
} finally {
this.isSaving = false;
}
await this.props.auth.updateUser({
name: this.name,
avatarUrl: this.avatarUrl,
});
this.props.ui.showToast('Profile saved', 'success');
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@ -72,11 +56,15 @@ class Settings extends React.Component<Props> {
};
handleAvatarError = (error: ?string) => {
this.props.errors.add(error || 'Unable to upload new avatar');
this.props.ui.showToast(error || 'Unable to upload new avatar');
};
get isValid() {
return this.form && this.form.checkValidity();
}
render() {
const { user } = this.props.auth;
const { user, isSaving } = this.props.auth;
if (!user) return null;
const avatarUrl = this.avatarUrl || user.avatarUrl;
@ -85,7 +73,7 @@ class Settings extends React.Component<Props> {
<PageTitle title="Profile" />
<h1>Profile</h1>
<ProfilePicture column>
<LabelText>Profile picture</LabelText>
<LabelText>Picture</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
@ -93,45 +81,35 @@ class Settings extends React.Component<Props> {
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload new image
Upload
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit}>
<StyledInput
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
<Input
label="Name"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
<Button type="submit" disabled={this.isSaving || !this.name}>
Save
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? 'Saving…' : 'Save'}
</Button>
<SuccessMessage visible={this.isUpdated}>
Profile updated!
</SuccessMessage>
</form>
</CenteredContent>
);
}
}
const SuccessMessage = styled.span`
margin-left: ${size.large};
color: ${color.slate};
opacity: ${props => (props.visible ? 1 : 0)};
transition: opacity 0.25s;
`;
const ProfilePicture = styled(Flex)`
margin-bottom: ${size.huge};
`;
const avatarStyles = `
width: 150px;
height: 150px;
width: 80px;
height: 80px;
border-radius: 50%;
`;
@ -162,8 +140,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
const StyledInput = styled(Input)`
max-width: 350px;
`;
export default inject('auth', 'errors')(Settings);
export default inject('auth', 'ui')(Profile);

View File

@ -7,6 +7,7 @@ import ShareListItem from './components/ShareListItem';
import List from 'components/List';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
type Props = {
shares: SharesStore,
@ -25,6 +26,12 @@ class Shares extends React.Component<Props> {
<CenteredContent>
<PageTitle title="Share Links" />
<h1>Share Links</h1>
<HelpText>
Documents that have been shared appear below. Anyone that has the link
can access a read-only version of the document until the link has been
revoked.
</HelpText>
<List>
{shares.orderedData.map(share => (
<ShareListItem key={share.id} share={share} />

View File

@ -47,7 +47,7 @@ class Slack extends React.Component<Props> {
) : (
<SlackButton
scopes={['commands', 'links:read', 'links:write']}
redirectUri={`${BASE_URL}/auth/slack/commands`}
redirectUri={`${BASE_URL}/auth/slack.commands`}
/>
)}
</p>
@ -83,7 +83,7 @@ class Slack extends React.Component<Props> {
<strong>{collection.name}</strong>
<SlackButton
scopes={['incoming-webhook']}
redirectUri={`${BASE_URL}/auth/slack/post`}
redirectUri={`${BASE_URL}/auth/slack.post`}
state={collection.id}
label="Connect"
/>

View File

@ -45,8 +45,9 @@ class Tokens extends React.Component<Props> {
<h1>API Tokens</h1>
<HelpText>
You can create unlimited personal API tokens to hack on your wiki.
Learn more in the <Link to="/developers">API documentation</Link>.
You can create an unlimited amount of personal API tokens to hack on
Outline. For more details about the API take a look at the{' '}
<Link to="/developers">developer documentation</Link>.
</HelpText>
{hasApiKeys && (

View File

@ -16,6 +16,8 @@ type Props = {
children?: React.Node,
onSuccess: string => *,
onError: string => *,
submitText: string,
borderRadius: number,
};
@observer
@ -26,6 +28,11 @@ class DropToImport extends React.Component<Props> {
file: File;
avatarEditorRef: AvatarEditor;
static defaultProps = {
submitText: 'Crop Picture',
borderRadius: 150,
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
@ -38,13 +45,18 @@ class DropToImport extends React.Component<Props> {
const asset = await uploadFile(imageBlob, { name: this.file.name });
this.props.onSuccess(asset.url);
} catch (err) {
this.props.onError('Unable to upload image');
this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
};
handleZoom = (event: SyntheticDragEvent<*>) => {
let target = event.target;
if (target instanceof HTMLInputElement) {
@ -53,8 +65,10 @@ class DropToImport extends React.Component<Props> {
};
renderCropping() {
const { submitText } = this.props;
return (
<Modal isOpen title="">
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
<AvatarEditorContainer>
<AvatarEditor
@ -63,7 +77,7 @@ class DropToImport extends React.Component<Props> {
width={250}
height={250}
border={25}
borderRadius={150}
borderRadius={this.props.borderRadius}
color={[255, 255, 255, 0.6]} // RGBA
scale={this.zoom}
rotate={0}
@ -79,7 +93,7 @@ class DropToImport extends React.Component<Props> {
/>
{this.isUploading && <LoadingIndicator />}
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
Crop avatar
{submitText}
</CropButton>
</Flex>
</Modal>
@ -89,19 +103,20 @@ class DropToImport extends React.Component<Props> {
render() {
if (this.isCropping) {
return this.renderCropping();
} else {
return (
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
style={{}}
disablePreview
{...this.props}
>
{this.props.children}
</Dropzone>
);
}
return (
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
style={{}}
disablePreview
onSuccess={this.props.onSuccess}
onError={this.props.onError}
>
{this.props.children}
</Dropzone>
);
}
}

View File

@ -11,17 +11,13 @@ type Props = {
auth: AuthStore,
scopes?: string[],
redirectUri?: string,
state?: string,
state: string,
label?: string,
};
function SlackButton({ auth, state, label, scopes, redirectUri }: Props) {
const handleClick = () =>
(window.location.href = slackAuth(
state ? auth.saveOauthState(state) : auth.genOauthState(),
scopes,
redirectUri
));
(window.location.href = slackAuth(state, scopes, redirectUri));
return (
<Button onClick={handleClick} icon={<SpacedSlackLogo size={24} />} neutral>

View File

@ -1,3 +1,3 @@
// @flow
import Settings from './Settings';
export default Settings;
import Profile from './Profile';
export default Profile;

View File

@ -1,80 +0,0 @@
// @flow
import * as React from 'react';
import { Redirect } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import queryString from 'query-string';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import { client } from 'utils/ApiClient';
import { slackAuth } from 'shared/utils/routeHelpers';
import AuthStore from 'stores/AuthStore';
type Props = {
auth: AuthStore,
location: Location,
};
@observer
class SlackAuth extends React.Component<Props> {
@observable redirectTo: string;
componentDidMount() {
this.redirect();
}
async redirect() {
const { error, code, state } = queryString.parse(
this.props.location.search
);
if (error) {
if (error === 'access_denied') {
// User selected "Deny" access on Slack OAuth
this.redirectTo = '/dashboard';
} else {
this.redirectTo = '/auth/error';
}
} else if (code) {
if (this.props.location.pathname === '/auth/slack/commands') {
// incoming webhooks from Slack
try {
await client.post('/auth.slackCommands', { code });
this.redirectTo = '/settings/integrations/slack';
} catch (e) {
this.redirectTo = '/auth/error';
}
} else if (this.props.location.pathname === '/auth/slack/post') {
// outgoing webhooks to Slack
try {
await client.post('/auth.slackPost', {
code,
collectionId: this.props.auth.oauthState,
});
this.redirectTo = '/settings/integrations/slack';
} catch (e) {
this.redirectTo = '/auth/error';
}
} else {
// Slack authentication
const redirectTo = sessionStorage.getItem('redirectTo');
sessionStorage.removeItem('redirectTo');
const { success } = await this.props.auth.authWithSlack(code, state);
success
? (this.redirectTo = redirectTo || '/dashboard')
: (this.redirectTo = '/auth/error');
}
} else {
// signing in
window.location.href = slackAuth(this.props.auth.genOauthState());
}
}
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} />;
return null;
}
}
export default inject('auth')(SlackAuth);

View File

@ -1,3 +0,0 @@
// @flow
import SlackAuth from './SlackAuth';
export default SlackAuth;

View File

@ -12,7 +12,7 @@ class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable oauthState: string;
@observable isSaving: boolean = false;
@observable isLoading: boolean = false;
@observable isSuspended: boolean = false;
@observable suspendedContactEmail: ?string;
@ -29,8 +29,6 @@ class AuthStore {
return JSON.stringify({
user: this.user,
team: this.team,
token: this.token,
oauthState: this.oauthState,
});
}
@ -52,65 +50,48 @@ class AuthStore {
}
};
@action
updateUser = async (params: { name: string, avatarUrl: ?string }) => {
this.isSaving = true;
try {
const res = await client.post(`/user.update`, params);
invariant(res && res.data, 'User response not available');
runInAction('AuthStore#updateUser', () => {
this.user = res.data;
});
} finally {
this.isSaving = false;
}
};
@action
updateTeam = async (params: { name: string, avatarUrl: ?string }) => {
this.isSaving = true;
try {
const res = await client.post(`/team.update`, params);
invariant(res && res.data, 'Team response not available');
runInAction('AuthStore#updateTeam', () => {
this.team = res.data;
});
} finally {
this.isSaving = false;
}
};
@action
logout = async () => {
this.user = null;
this.token = null;
Cookie.remove('loggedIn', { path: '/' });
Cookie.remove('accessToken', { path: '/' });
await localForage.clear();
window.location.href = BASE_URL;
};
@action
genOauthState = () => {
const state = Math.random()
.toString(36)
.substring(7);
this.oauthState = state;
return this.oauthState;
};
@action
saveOauthState = (state: string) => {
this.oauthState = state;
return this.oauthState;
};
@action
authWithSlack = async (code: string, state: string) => {
// in the case of direct install from the Slack app store the state is
// created on the server and set as a cookie
const serverState = Cookie.get('state');
if (state !== this.oauthState && state !== serverState) {
return {
success: false,
};
}
let res;
try {
res = await client.post('/auth.slack', { code });
} catch (e) {
return {
success: false,
};
}
// State can only ever be used once so now's the time to remove it.
Cookie.remove('state', { path: '/' });
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,
};
// add a timestamp to force reload from server
window.location.href = `${BASE_URL}?done=${new Date().getTime()}`;
};
constructor() {
@ -123,8 +104,12 @@ class AuthStore {
}
this.user = data.user;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
// load token from state for backwards compatability with
// sessions created pre-google auth
this.token = Cookie.get('accessToken') || data.token;
if (this.token) setImmediate(() => this.fetch());
autorun(() => {
try {

View File

@ -4,9 +4,7 @@ import { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores';
import BaseStore from './BaseStore';
import ErrorsStore from './ErrorsStore';
import UiStore from './UiStore';
import Collection from 'models/Collection';
import naturalSort from 'shared/utils/naturalSort';
@ -32,7 +30,6 @@ class CollectionsStore extends BaseStore {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
errors: ErrorsStore;
ui: UiStore;
@computed
@ -106,7 +103,7 @@ class CollectionsStore extends BaseStore {
});
return res;
} catch (e) {
this.errors.add('Failed to load collections');
this.ui.showToast('Failed to load collections');
} finally {
this.isFetching = false;
}
@ -134,7 +131,7 @@ class CollectionsStore extends BaseStore {
return collection;
} catch (e) {
this.errors.add('Something went wrong');
this.ui.showToast('Something went wrong');
} finally {
this.isFetching = false;
}
@ -156,7 +153,6 @@ class CollectionsStore extends BaseStore {
constructor(options: Options) {
super();
this.errors = stores.errors;
this.ui = options.ui;
this.on('collections.delete', (data: { id: string }) => {

View File

@ -6,7 +6,6 @@ import invariant from 'invariant';
import BaseStore from 'stores/BaseStore';
import Document from 'models/Document';
import ErrorsStore from 'stores/ErrorsStore';
import UiStore from 'stores/UiStore';
import type { PaginationParams } from 'types';
@ -14,7 +13,6 @@ export const DEFAULT_PAGINATION_LIMIT = 25;
type Options = {
ui: UiStore,
errors: ErrorsStore,
};
type FetchOptions = {
@ -29,7 +27,6 @@ class DocumentsStore extends BaseStore {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
errors: ErrorsStore;
ui: UiStore;
/* Computed */
@ -114,7 +111,7 @@ class DocumentsStore extends BaseStore {
});
return data;
} catch (e) {
this.errors.add('Failed to load documents');
this.ui.showToast('Failed to load documents');
} finally {
this.isFetching = false;
}
@ -200,7 +197,7 @@ class DocumentsStore extends BaseStore {
return document;
} catch (e) {
this.errors.add('Failed to load document');
this.ui.showToast('Failed to load document');
} finally {
this.isFetching = false;
}
@ -230,7 +227,6 @@ class DocumentsStore extends BaseStore {
constructor(options: Options) {
super();
this.errors = options.errors;
this.ui = options.ui;
this.on('documents.delete', (data: { id: string }) => {

View File

@ -1,20 +0,0 @@
// @flow
import { observable, action } from 'mobx';
class ErrorsStore {
@observable data = observable.array([]);
/* Actions */
@action
add = (message: string): void => {
this.data.push(message);
};
@action
remove = (index: number): void => {
this.data.splice(index, 1);
};
}
export default ErrorsStore;

View File

@ -1,27 +0,0 @@
/* eslint-disable */
import ErrorsStore from './ErrorsStore';
// Actions
describe('ErrorsStore', () => {
let store;
beforeEach(() => {
store = new ErrorsStore();
});
test('#add should add errors', () => {
expect(store.data.length).toBe(0);
store.add('first error');
store.add('second error');
expect(store.data.length).toBe(2);
});
test('#remove should remove errors', () => {
store.add('first error');
store.add('second error');
expect(store.data.length).toBe(2);
store.remove(0);
expect(store.data.length).toBe(1);
expect(store.data[0]).toBe('second error');
});
});

View File

@ -3,8 +3,7 @@ import { observable, computed, action, runInAction, ObservableMap } from 'mobx';
import { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
import stores from './';
import ErrorsStore from './ErrorsStore';
import UiStore from './UiStore';
import BaseStore from './BaseStore';
import Integration from 'models/Integration';
@ -15,7 +14,7 @@ class IntegrationsStore extends BaseStore {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
errors: ErrorsStore;
ui: UiStore;
@computed
get orderedData(): Integration[] {
@ -24,7 +23,7 @@ class IntegrationsStore extends BaseStore {
@computed
get slackIntegrations(): Integration[] {
return _.filter(this.orderedData, { serviceId: 'slack' });
return _.filter(this.orderedData, { service: 'slack' });
}
@action
@ -43,7 +42,7 @@ class IntegrationsStore extends BaseStore {
});
return res;
} catch (e) {
this.errors.add('Failed to load integrations');
this.ui.showToast('Failed to load integrations');
} finally {
this.isFetching = false;
}
@ -63,9 +62,9 @@ class IntegrationsStore extends BaseStore {
return this.data.get(id);
};
constructor() {
constructor(options: { ui: UiStore }) {
super();
this.errors = stores.errors;
this.ui = options.ui;
this.on('integrations.delete', (data: { id: string }) => {
this.remove(data.id);

View File

@ -2,6 +2,7 @@
import { observable, action } from 'mobx';
import Document from 'models/Document';
import Collection from 'models/Collection';
import type { Toast } from '../types';
class UiStore {
@observable activeModalName: ?string;
@ -11,6 +12,7 @@ class UiStore {
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable mobileSidebarVisible: boolean = false;
@observable toasts: Toast[] = observable.array([]);
/* Actions */
@action
@ -79,6 +81,19 @@ class UiStore {
hideMobileSidebar() {
this.mobileSidebarVisible = false;
}
@action
showToast = (
message: string,
type?: 'warning' | 'error' | 'info' | 'success' = 'warning'
): void => {
this.toasts.push({ message, type });
};
@action
removeToast = (index: number): void => {
this.toasts.splice(index, 1);
};
}
export default UiStore;

View File

@ -0,0 +1,28 @@
/* eslint-disable */
import stores from '.';
// Actions
describe('UiStore', () => {
let store;
beforeEach(() => {
store = stores.ui;
});
test('#add should add errors', () => {
expect(store.toasts.length).toBe(0);
store.showToast('first error');
store.showToast('second error');
expect(store.toasts.length).toBe(2);
});
test('#remove should remove errors', () => {
store.toasts = [];
store.showToast('first error');
store.showToast('second error');
expect(store.toasts.length).toBe(2);
store.removeToast(0);
expect(store.toasts.length).toBe(1);
expect(store.toasts[0].message).toBe('second error');
});
});

View File

@ -1,18 +1,15 @@
// @flow
import AuthStore from './AuthStore';
import UiStore from './UiStore';
import ErrorsStore from './ErrorsStore';
import DocumentsStore from './DocumentsStore';
import SharesStore from './SharesStore';
const ui = new UiStore();
const errors = new ErrorsStore();
const stores = {
user: null, // Including for Layout
auth: new AuthStore(),
ui,
errors,
documents: new DocumentsStore({ ui, errors }),
documents: new DocumentsStore({ ui }),
shares: new SharesStore(),
};

View File

@ -9,6 +9,11 @@ export type User = {
isSuspended?: boolean,
};
export type Toast = {
message: string,
type: 'warning' | 'error' | 'info' | 'success',
};
export type Share = {
id: string,
url: string,
@ -23,6 +28,8 @@ export type Team = {
id: string,
name: string,
avatarUrl: string,
slackConnected: boolean,
googleConnected: boolean,
};
export type NavigationNode = {

View File

@ -8,7 +8,7 @@ if (process.env.NODE_ENV === 'production') {
} else if (process.env.NODE_ENV === 'development') {
console.log(
'\n\x1b[33m%s\x1b[0m',
'Running Outline in development mode with React hot reloading. To run Outline in production mode, use `yarn start`'
'Running Outline in development mode with hot reloading. To run Outline in production mode, use `yarn start`'
);
}

View File

@ -99,6 +99,7 @@
"file-loader": "^1.1.6",
"flow-typed": "^2.4.0",
"fs-extra": "^4.0.2",
"google-auth-library": "^1.5.0",
"history": "3.0.0",
"html-webpack-plugin": "2.17.0",
"http-errors": "1.4.0",
@ -132,7 +133,7 @@
"nodemailer": "^4.4.0",
"normalize.css": "^7.0.0",
"normalizr": "2.0.1",
"outline-icons": "^1.1.0",
"outline-icons": "^1.2.0",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
@ -168,11 +169,11 @@
"styled-components-breakpoint": "^1.0.1",
"styled-components-grid": "^1.0.0-preview.15",
"styled-normalize": "^2.2.1",
"uglifyjs-webpack-plugin": "1.2.5",
"url-loader": "^0.6.2",
"uuid": "2.0.2",
"validator": "5.2.0",
"webpack": "3.10.0",
"uglifyjs-webpack-plugin": "1.2.5",
"webpack-manifest-plugin": "^1.3.2"
},
"devDependencies": {

View File

@ -1,82 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#auth.login should login with email 1`] = `
Object {
"avatarUrl": "http://example.com/avatar.png",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",
}
`;
exports[`#auth.login should login with username 1`] = `
Object {
"avatarUrl": "http://example.com/avatar.png",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",
}
`;
exports[`#auth.login should require either username or email 1`] = `
Object {
"error": "validation_error",
"message": "username/email is required",
"ok": false,
"status": 400,
}
`;
exports[`#auth.login should require password 1`] = `
Object {
"error": "validation_error",
"message": "username/email is required",
"ok": false,
"status": 400,
}
`;
exports[`#auth.login should validate password 1`] = `
Object {
"error": "validation_error",
"message": "username/email is required",
"ok": false,
"status": 400,
}
`;
exports[`#auth.signup should require params 1`] = `
Object {
"error": "validation_error",
"message": "name is required",
"ok": false,
"status": 400,
}
`;
exports[`#auth.signup should require unique email 1`] = `
Object {
"error": "user_exists_with_email",
"message": "User already exists with this email",
"ok": false,
"status": 400,
}
`;
exports[`#auth.signup should require unique username 1`] = `
Object {
"error": "user_exists_with_username",
"message": "User already exists with this username",
"ok": false,
"status": 400,
}
`;
exports[`#auth.signup should require valid email 1`] = `
Object {
"error": "validation_error",
"message": "email is invalid",
"ok": false,
"status": 400,
}
`;

View File

@ -153,7 +153,10 @@ exports[`#user.update should update user profile information 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"name": "New name",
"username": "user1",
},

View File

@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentApiKey } from '../presenters';
import { ApiKey } from '../models';

View File

@ -1,9 +1,8 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentUser, presentTeam } from '../presenters';
import { Authentication, Integration, User, Team } from '../models';
import * as Slack from '../slack';
import { Team } from '../models';
const router = new Router();
@ -19,126 +18,4 @@ router.post('auth.info', auth(), async ctx => {
};
});
router.post('auth.slack', async ctx => {
const { code } = ctx.body;
ctx.assertPresent(code, 'code is required');
const data = await Slack.oauthAccess(code);
let user = await User.findOne({ where: { slackId: data.user.id } });
let team = await Team.findOne({ where: { slackId: data.team.id } });
const isFirstUser = !team;
if (team) {
team.name = data.team.name;
team.slackData = data.team;
await team.save();
} else {
team = await Team.create({
name: data.team.name,
slackId: data.team.id,
slackData: data.team,
});
}
if (user) {
user.slackAccessToken = data.access_token;
user.slackData = data.user;
await user.save();
} else {
user = await User.create({
slackId: data.user.id,
name: data.user.name,
email: data.user.email,
teamId: team.id,
isAdmin: isFirstUser,
slackData: data.user,
slackAccessToken: data.access_token,
});
// Set initial avatar
await user.updateAvatar();
await user.save();
}
if (isFirstUser) {
await team.createFirstCollection(user.id);
}
// Signal to backend that the user is logged in.
// This is only used to signal SSR rendering, not
// used for auth.
ctx.cookies.set('loggedIn', 'true', {
httpOnly: false,
expires: new Date('2100'),
});
ctx.body = {
data: {
user: await presentUser(ctx, user),
team: await presentTeam(ctx, team),
accessToken: user.getJwtToken(),
},
};
});
router.post('auth.slackCommands', auth(), async ctx => {
const { code } = ctx.body;
ctx.assertPresent(code, 'code is required');
const user = ctx.state.user;
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
const authentication = await Authentication.create({
serviceId,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
serviceId,
type: 'command',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
});
});
router.post('auth.slackPost', auth(), async ctx => {
const { code, collectionId } = ctx.body;
ctx.assertPresent(code, 'code is required');
const user = ctx.state.user;
const endpoint = `${process.env.URL || ''}/auth/slack/post`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
const authentication = await Authentication.create({
serviceId,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
serviceId,
type: 'post',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
});
export default router;

View File

@ -1,164 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { flushdb, seed } from '../test/support';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe.skip('#auth.signup', async () => {
it('should signup a new user', async () => {
const welcomeEmailMock = jest.fn();
jest.doMock('../mailer', () => {
return {
welcome: welcomeEmailMock,
};
});
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'new.user@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toBeTruthy();
expect(welcomeEmailMock).toBeCalledWith('new.user@example.com');
});
it('should require params', async () => {
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require valid email', async () => {
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require unique email', async () => {
await seed();
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'user1@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require unique username', async () => {
await seed();
const res = await server.post('/api/auth.signup', {
body: {
username: 'user1',
name: 'Test User',
email: 'userone@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
});
describe.skip('#auth.login', () => {
test('should login with email', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
username: 'user1@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toMatchSnapshot();
});
test('should login with username', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
username: 'user1',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toMatchSnapshot();
});
test('should validate password', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
email: 'user1@example.com',
password: 'bad_password',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
test('should require either username or email', async () => {
const res = await server.post('/api/auth.login', {
body: {
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
test('should require password', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
email: 'user1@example.com',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
});

View File

@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentCollection } from '../presenters';
import { Collection } from '../models';

View File

@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import Sequelize from 'sequelize';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentDocument, presentRevision } from '../presenters';
import { Document, Collection, Share, Star, View, Revision } from '../models';

View File

@ -14,11 +14,13 @@ router.post('hooks.unfurl', async ctx => {
throw new AuthenticationError('Invalid token');
// TODO: Everything from here onwards will get moved to an async job
const user = await User.find({ where: { slackId: event.user } });
const user = await User.find({
where: { service: 'slack', serviceId: event.user },
});
if (!user) return;
const auth = await Authentication.find({
where: { serviceId: 'slack', teamId: user.teamId },
where: { service: 'slack', teamId: user.teamId },
});
if (!auth) return;
@ -55,7 +57,8 @@ router.post('hooks.slack', async ctx => {
const user = await User.find({
where: {
slackId: user_id,
service: 'slack',
serviceId: user_id,
},
});

View File

@ -18,7 +18,7 @@ describe('#hooks.unfurl', async () => {
it('should return documents', async () => {
const { user, document } = await seed();
await Authentication.create({
serviceId: 'slack',
service: 'slack',
userId: user.id,
teamId: user.teamId,
token: '',
@ -32,7 +32,7 @@ describe('#hooks.unfurl', async () => {
event: {
type: 'link_shared',
channel: 'Cxxxxxx',
user: user.slackId,
user: user.serviceId,
message_ts: '123456789.9875',
links: [
{
@ -55,7 +55,7 @@ describe('#hooks.slack', async () => {
const res = await server.post('/api/hooks.slack', {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.slackId,
user_id: user.serviceId,
text: 'dsfkndfskndsfkn',
},
});
@ -70,7 +70,7 @@ describe('#hooks.slack', async () => {
const res = await server.post('/api/hooks.slack', {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.slackId,
user_id: user.serviceId,
text: document.title,
},
});
@ -98,7 +98,7 @@ describe('#hooks.slack', async () => {
const res = await server.post('/api/hooks.slack', {
body: {
token: 'wrong-verification-token',
user_id: user.slackId,
user_id: user.serviceId,
text: 'Welcome',
},
});

View File

@ -2,8 +2,6 @@
import bodyParser from 'koa-bodyparser';
import Koa from 'koa';
import Router from 'koa-router';
import Sequelize from 'sequelize';
import _ from 'lodash';
import auth from './auth';
import user from './user';
@ -16,58 +14,24 @@ import shares from './shares';
import team from './team';
import integrations from './integrations';
import validation from './middlewares/validation';
import methodOverride from '../middlewares/methodOverride';
import cache from '../middlewares/cache';
import errorHandling from './middlewares/errorHandling';
import validation from '../middlewares/validation';
import methodOverride from './middlewares/methodOverride';
import cache from './middlewares/cache';
import apiWrapper from './middlewares/apiWrapper';
const api = new Koa();
const router = new Router();
// API error handler
api.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
let message = err.message || err.name;
let error;
if (err instanceof Sequelize.ValidationError) {
// super basic form error handling
ctx.status = 400;
if (err.errors && err.errors[0]) {
message = `${err.errors[0].message} (${err.errors[0].path})`;
}
}
if (message.match('Authorization error')) {
ctx.status = 403;
error = 'authorization_error';
}
if (ctx.status === 500) {
message = 'Internal Server Error';
error = 'internal_server_error';
ctx.app.emit('error', err, ctx);
}
ctx.body = {
ok: false,
error: _.snakeCase(err.id || error),
status: err.status,
message,
data: err.errorData ? err.errorData : undefined,
};
}
});
// middlewares
api.use(errorHandling());
api.use(bodyParser());
api.use(methodOverride());
api.use(cache());
api.use(validation());
api.use(apiWrapper());
// routes
router.use('/', auth.routes());
router.use('/', user.routes());
router.use('/', collections.routes());

View File

@ -2,7 +2,7 @@
import Router from 'koa-router';
import Integration from '../models/Integration';
import pagination from './middlewares/pagination';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentIntegration } from '../presenters';
import policy from '../policies';

View File

@ -4,7 +4,7 @@ import { type Context } from 'koa';
export default function apiWrapper() {
return async function apiWrapperMiddleware(
ctx: Context,
next: () => Promise<void>
next: () => Promise<*>
) {
await next();

View File

@ -1,10 +1,11 @@
// @flow
import debug from 'debug';
import { type Context } from 'koa';
const debugCache = debug('cache');
export default function cache() {
return async function cacheMiddleware(ctx: Object, next: Function) {
return async function cacheMiddleware(ctx: Context, next: () => Promise<*>) {
ctx.cache = {};
ctx.cache.set = async (id, value) => {

View File

@ -0,0 +1,46 @@
// @flow
import Sequelize from 'sequelize';
import { snakeCase } from 'lodash';
import { type Context } from 'koa';
export default function errorHandling() {
return async function errorHandlingMiddleware(
ctx: Context,
next: () => Promise<*>
) {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
let message = err.message || err.name;
let error;
if (err instanceof Sequelize.ValidationError) {
// super basic form error handling
ctx.status = 400;
if (err.errors && err.errors[0]) {
message = `${err.errors[0].message} (${err.errors[0].path})`;
}
}
if (message.match('Authorization error')) {
ctx.status = 403;
error = 'authorization_error';
}
if (ctx.status === 500) {
message = 'Internal Server Error';
error = 'internal_server_error';
ctx.app.emit('error', err, ctx);
}
ctx.body = {
ok: false,
error: snakeCase(err.id || error),
status: err.status,
message,
data: err.errorData ? err.errorData : undefined,
};
}
};
}

View File

@ -5,7 +5,7 @@ import { type Context } from 'koa';
export default function methodOverride() {
return async function methodOverrideMiddleware(
ctx: Context,
next: () => Promise<void>
next: () => Promise<*>
) {
if (ctx.method === 'POST') {
// $FlowFixMe

View File

@ -6,7 +6,7 @@ import { type Context } from 'koa';
export default function pagination(options?: Object) {
return async function paginationMiddleware(
ctx: Context,
next: () => Promise<void>
next: () => Promise<*>
) {
const opts = {
defaultLimit: 15,

View File

@ -1,6 +1,6 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentShare } from '../presenters';
import { Document, User, Share } from '../models';

View File

@ -1,13 +1,33 @@
// @flow
import Router from 'koa-router';
import { User } from '../models';
import { User, Team } from '../models';
import { publicS3Endpoint } from '../utils/s3';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentUser } from '../presenters';
import { presentUser, presentTeam } from '../presenters';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
router.post('team.update', auth(), async ctx => {
const { name, avatarUrl } = ctx.body;
const endpoint = publicS3Endpoint();
const user = ctx.state.user;
const team = await Team.findById(user.teamId);
authorize(user, 'update', team);
if (name) team.name = name;
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
team.avatarUrl = avatarUrl;
}
await team.save();
ctx.body = { data: await presentTeam(ctx, team) };
});
router.post('team.users', auth(), pagination(), async ctx => {
const user = ctx.state.user;

View File

@ -34,3 +34,30 @@ describe('#team.users', async () => {
expect(body).toMatchSnapshot();
});
});
describe('#team.update', async () => {
it('should update team details', async () => {
const { admin } = await seed();
const res = await server.post('/api/team.update', {
body: { token: admin.getJwtToken(), name: 'New name' },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual('New name');
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/team.update', {
body: { token: user.getJwtToken(), name: 'New name' },
});
expect(res.status).toEqual(403);
});
it('should require authentication', async () => {
await seed();
const res = await server.post('/api/team.update');
expect(res.status).toEqual(401);
});
});

View File

@ -4,7 +4,7 @@ import Router from 'koa-router';
import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3';
import { ValidationError } from '../errors';
import { Event, User, Team } from '../models';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentUser } from '../presenters';
import policy from '../policies';
@ -21,14 +21,13 @@ router.post('user.update', auth(), async ctx => {
const endpoint = publicS3Endpoint();
if (name) user.name = name;
if (
avatarUrl &&
avatarUrl.startsWith(`${endpoint}/uploads/${ctx.state.user.id}`)
)
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
user.avatarUrl = avatarUrl;
}
await user.save();
ctx.body = { data: await presentUser(ctx, user) };
ctx.body = { data: await presentUser(ctx, user, { includeDetails: true }) };
});
router.post('user.s3Upload', auth(), async ctx => {

View File

@ -1,8 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { User } from '../models';
import { flushdb, seed } from '../test/support';
@ -13,13 +11,7 @@ afterAll(server.close);
describe('#user.info', async () => {
it('should return known user', async () => {
await seed();
const user = await User.findOne({
where: {
email: 'user1@example.com',
},
});
const { user } = await seed();
const res = await server.post('/api/user.info', {
body: { token: user.getJwtToken() },
});
@ -41,13 +33,7 @@ describe('#user.info', async () => {
describe('#user.update', async () => {
it('should update user profile information', async () => {
await seed();
const user = await User.findOne({
where: {
email: 'user1@example.com',
},
});
const { user } = await seed();
const res = await server.post('/api/user.update', {
body: { token: user.getJwtToken(), name: 'New name' },
});

View File

@ -1,6 +1,6 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentView } from '../presenters';
import { View, Document } from '../models';
import policy from '../policies';

101
server/auth/google.js Normal file
View File

@ -0,0 +1,101 @@
// @flow
import crypto from 'crypto';
import Router from 'koa-router';
import addMonths from 'date-fns/add_months';
import { capitalize } from 'lodash';
import { OAuth2Client } from 'google-auth-library';
import { User, Team } from '../models';
const router = new Router();
const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.URL}/auth/google.callback`
);
// start the oauth process and redirect user to Google
router.get('google', async ctx => {
// Generate the url that will be used for the consent dialog.
const authorizeUrl = client.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
],
prompt: 'consent',
});
ctx.redirect(authorizeUrl);
});
// signin callback from Google
router.get('google.callback', async ctx => {
const { code } = ctx.request.query;
ctx.assertPresent(code, 'code is required');
const response = await client.getToken(code);
client.setCredentials(response.tokens);
const profile = await client.request({
url: 'https://www.googleapis.com/oauth2/v1/userinfo',
});
if (!profile.data.hd) {
ctx.redirect('/?notice=google-hd');
return;
}
const googleId = profile.data.hd;
const teamName = capitalize(profile.data.hd.split('.')[0]);
// attempt to get logo from Clearbit API. If one doesn't exist then
// fall back to using tiley to generate a placeholder logo
const hash = crypto.createHash('sha256');
hash.update(googleId);
const hashedGoogleId = hash.digest('hex');
const cbUrl = `https://logo.clearbit.com/${profile.data.hd}`;
const tileyUrl = `https://tiley.herokuapp.com/avatar/${hashedGoogleId}/${
teamName[0]
}.png`;
const cbResponse = await fetch(cbUrl);
const avatarUrl = cbResponse.status === 200 ? cbUrl : tileyUrl;
const [team, isFirstUser] = await Team.findOrCreate({
where: {
googleId,
},
defaults: {
name: teamName,
avatarUrl,
},
});
const [user] = await User.findOrCreate({
where: {
service: 'google',
serviceId: profile.data.id,
teamId: team.id,
},
defaults: {
name: profile.data.name,
email: profile.data.email,
isAdmin: isFirstUser,
avatarUrl: profile.data.picture,
},
});
if (isFirstUser) {
await team.createFirstCollection(user.id);
}
ctx.cookies.set('lastSignedIn', 'google', {
httpOnly: false,
expires: new Date('2100'),
});
ctx.cookies.set('accessToken', user.getJwtToken(), {
httpOnly: false,
expires: addMonths(new Date(), 1),
});
ctx.redirect('/');
});
export default router;

20
server/auth/index.js Normal file
View File

@ -0,0 +1,20 @@
// @flow
import bodyParser from 'koa-bodyparser';
import Koa from 'koa';
import Router from 'koa-router';
import validation from '../middlewares/validation';
import slack from './slack';
import google from './google';
const auth = new Koa();
const router = new Router();
router.use('/', slack.routes());
router.use('/', google.routes());
auth.use(bodyParser());
auth.use(validation());
auth.use(router.routes());
export default auth;

148
server/auth/slack.js Normal file
View File

@ -0,0 +1,148 @@
// @flow
import Router from 'koa-router';
import addHours from 'date-fns/add_hours';
import addMonths from 'date-fns/add_months';
import { slackAuth } from '../../shared/utils/routeHelpers';
import { Authentication, Integration, User, Team } from '../models';
import * as Slack from '../slack';
const router = new Router();
// start the oauth process and redirect user to Slack
router.get('slack', async ctx => {
const state = Math.random()
.toString(36)
.substring(7);
ctx.cookies.set('state', state, {
httpOnly: false,
expires: addHours(new Date(), 1),
});
ctx.redirect(slackAuth(state));
});
// signin callback from Slack
router.get('slack.callback', async ctx => {
const { code, error, state } = ctx.request.query;
ctx.assertPresent(code || error, 'code is required');
ctx.assertPresent(state, 'state is required');
if (state !== ctx.cookies.get('state') || error) {
ctx.redirect('/?notice=auth-error');
return;
}
const data = await Slack.oauthAccess(code);
const [team, isFirstUser] = await Team.findOrCreate({
where: {
slackId: data.team.id,
},
defaults: {
name: data.team.name,
avatarUrl: data.team.image_88,
},
});
const [user] = await User.findOrCreate({
where: {
service: 'slack',
serviceId: data.user.id,
teamId: team.id,
},
defaults: {
name: data.user.name,
email: data.user.email,
isAdmin: isFirstUser,
avatarUrl: data.user.image_192,
},
});
if (isFirstUser) {
await team.createFirstCollection(user.id);
}
ctx.cookies.set('lastSignedIn', 'slack', {
httpOnly: false,
expires: new Date('2100'),
});
ctx.cookies.set('accessToken', user.getJwtToken(), {
httpOnly: false,
expires: addMonths(new Date(), 1),
});
ctx.redirect('/');
});
router.get('slack.commands', async ctx => {
const { code } = ctx.request.query;
ctx.assertPresent(code, 'code is required');
const endpoint = `${process.env.URL || ''}/auth/slack.commands`;
const data = await Slack.oauthAccess(code, endpoint);
const user = await User.find({
service: 'slack',
serviceId: data.user_id,
});
const authentication = await Authentication.create({
service: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
service: 'slack',
type: 'command',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
});
ctx.redirect('/settings/integrations/slack');
});
router.get('slack.post', async ctx => {
const { code, state } = ctx.request.query;
ctx.assertPresent(code, 'code is required');
const collectionId = state;
ctx.assertUuid(collectionId, 'collectionId must be an uuid');
const endpoint = `${process.env.URL || ''}/auth/slack.post`;
const data = await Slack.oauthAccess(code, endpoint);
const user = await User.find({
service: 'slack',
serviceId: data.user_id,
});
const authentication = await Authentication.create({
service: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
service: 'slack',
type: 'post',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect('/settings/integrations/slack');
});
export default router;

View File

@ -8,6 +8,7 @@ import bugsnag from 'bugsnag';
import onerror from 'koa-onerror';
import updates from './utils/updates';
import auth from './auth';
import api from './api';
import emails from './emails';
import routes from './routes';
@ -79,6 +80,7 @@ if (process.env.NODE_ENV === 'development') {
}
}
app.use(mount('/auth', auth));
app.use(mount('/api', api));
app.use(mount(routes));

View File

@ -1,14 +1,11 @@
// @flow
import JWT from 'jsonwebtoken';
import { type Context } from 'koa';
import { User, ApiKey } from '../../models';
import { AuthenticationError, UserSuspendedError } from '../../errors';
import { User, ApiKey } from '../models';
import { AuthenticationError, UserSuspendedError } from '../errors';
export default function auth(options?: { required?: boolean } = {}) {
return async function authMiddleware(
ctx: Context,
next: () => Promise<void>
) {
return async function authMiddleware(ctx: Context, next: () => Promise<*>) {
let token;
const authorizationHeader = ctx.request.get('authorization');

View File

@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { flushdb, seed } from '../../test/support';
import { buildUser } from '../../test/factories';
import { ApiKey } from '../../models';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';
import { ApiKey } from '../models';
import randomstring from 'randomstring';
import auth from './authentication';

View File

@ -1,10 +1,11 @@
// @flow
import validator from 'validator';
import { ParamRequiredError, ValidationError } from '../../errors';
import { validateColorHex } from '../../../shared/utils/color';
import { type Context } from 'koa';
import { ParamRequiredError, ValidationError } from '../errors';
import { validateColorHex } from '../../shared/utils/color';
export default function validation() {
return function validationMiddleware(ctx: Object, next: Function) {
return function validationMiddleware(ctx: Context, next: () => Promise<*>) {
ctx.assertPresent = (value, message) => {
if (value === undefined || value === null || value === '') {
throw new ParamRequiredError(message);

View File

@ -0,0 +1,27 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('teams', 'googleId', {
type: Sequelize.STRING,
allowNull: true,
unique: true
});
await queryInterface.addColumn('teams', 'avatarUrl', {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn('users', 'service', {
type: Sequelize.STRING,
allowNull: true,
defaultValue: 'slack'
});
await queryInterface.renameColumn('users', 'slackId', 'serviceId');
await queryInterface.addIndex('teams', ['googleId']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('teams', 'googleId');
await queryInterface.removeColumn('teams', 'avatarUrl');
await queryInterface.removeColumn('users', 'service');
await queryInterface.renameColumn('users', 'serviceId', 'slackId');
await queryInterface.removeIndex('teams', ['googleId']);
}
}

View File

@ -0,0 +1,10 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.renameColumn('authentications', 'serviceId', 'service');
await queryInterface.renameColumn('integrations', 'serviceId', 'service');
},
down: async (queryInterface, Sequelize) => {
await queryInterface.renameColumn('authentications', 'service', 'serviceId');
await queryInterface.renameColumn('integrations', 'service', 'serviceId');
}
}

View File

@ -7,7 +7,7 @@ const Authentication = sequelize.define('authentication', {
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
serviceId: DataTypes.STRING,
service: DataTypes.STRING,
scopes: DataTypes.ARRAY(DataTypes.STRING),
token: encryptedFields.vault('token'),
});

View File

@ -8,7 +8,7 @@ const Integration = sequelize.define('integration', {
primaryKey: true,
},
type: DataTypes.STRING,
serviceId: DataTypes.STRING,
service: DataTypes.STRING,
settings: DataTypes.JSONB,
events: DataTypes.ARRAY(DataTypes.STRING),
});

View File

@ -1,5 +1,7 @@
// @flow
import uuid from 'uuid';
import { DataTypes, sequelize, Op } from '../sequelize';
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import Collection from './Collection';
import User from './User';
@ -13,6 +15,8 @@ const Team = sequelize.define(
},
name: DataTypes.STRING,
slackId: { type: DataTypes.STRING, allowNull: true },
googleId: { type: DataTypes.STRING, allowNull: true },
avatarUrl: { type: DataTypes.STRING, allowNull: true },
slackData: DataTypes.JSONB,
},
{
@ -31,6 +35,18 @@ Team.associate = models => {
Team.hasMany(models.User, { as: 'users' });
};
const uploadAvatar = async model => {
const endpoint = publicS3Endpoint();
if (model.avatarUrl && !model.avatarUrl.startsWith(endpoint)) {
const newUrl = await uploadToS3FromUrl(
model.avatarUrl,
`avatars/${model.id}/${uuid.v4()}`
);
if (newUrl) model.avatarUrl = newUrl;
}
};
Team.prototype.createFirstCollection = async function(userId) {
return await Collection.create({
name: 'General',
@ -80,4 +96,6 @@ Team.prototype.activateUser = async function(user: User, admin: User) {
});
};
Team.beforeSave(uploadAvatar);
export default Team;

View File

@ -4,7 +4,7 @@ import bcrypt from 'bcrypt';
import uuid from 'uuid';
import JWT from 'jsonwebtoken';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { uploadToS3FromUrl } from '../utils/s3';
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import { sendEmail } from '../mailer';
const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12;
@ -25,7 +25,8 @@ const User = sequelize.define(
passwordDigest: DataTypes.STRING,
isAdmin: DataTypes.BOOLEAN,
slackAccessToken: encryptedFields.vault('slackAccessToken'),
slackId: { type: DataTypes.STRING, allowNull: true, unique: true },
service: { type: DataTypes.STRING, allowNull: true, unique: true },
serviceId: { type: DataTypes.STRING, allowNull: true, unique: true },
slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault('jwtSecret'),
suspendedAt: DataTypes.DATE,
@ -56,9 +57,7 @@ User.associate = models => {
User.prototype.getJwtToken = function() {
return JWT.sign({ id: this.id }, this.jwtSecret);
};
User.prototype.getTeam = async function() {
return this.team;
};
User.prototype.verifyPassword = function(password) {
return new Promise((resolve, reject) => {
if (!this.passwordDigest) {
@ -76,17 +75,23 @@ User.prototype.verifyPassword = function(password) {
});
});
};
User.prototype.updateAvatar = async function() {
this.avatarUrl = await uploadToS3FromUrl(
this.slackData.image_192,
`avatars/${this.id}/${uuid.v4()}`
);
const uploadAvatar = async model => {
const endpoint = publicS3Endpoint();
if (model.avatarUrl && !model.avatarUrl.startsWith(endpoint)) {
const newUrl = await uploadToS3FromUrl(
model.avatarUrl,
`avatars/${model.id}/${uuid.v4()}`
);
if (newUrl) model.avatarUrl = newUrl;
}
};
const setRandomJwtSecret = model => {
model.jwtSecret = crypto.randomBytes(64).toString('hex');
};
const hashPassword = function hashPassword(model) {
const hashPassword = model => {
if (!model.password) {
return null;
}
@ -105,6 +110,7 @@ const hashPassword = function hashPassword(model) {
};
User.beforeCreate(hashPassword);
User.beforeUpdate(hashPassword);
User.beforeSave(uploadAvatar);
User.beforeCreate(setRandomJwtSecret);
User.afterCreate(user => sendEmail('welcome', user.email));

View File

@ -5,11 +5,18 @@ import styled from 'styled-components';
import Grid from 'styled-components-grid';
import breakpoint from 'styled-components-breakpoint';
import Hero from './components/Hero';
import SignupButton from './components/SignupButton';
import SigninButtons from './components/SigninButtons';
import { developers, githubUrl } from '../../shared/utils/routeHelpers';
import { color } from '../../shared/styles/constants';
function Home() {
type Props = {
notice?: 'google-hd' | 'auth-error',
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
};
function Home(props: Props) {
return (
<span>
<Helmet>
@ -23,8 +30,20 @@ function Home() {
logs, brainstorming, & more
</HeroText>
<p>
<SignupButton />
<SigninButtons {...props} />
</p>
{props.notice === 'google-hd' && (
<Notice>
Sorry, Google sign in cannot be used with a personal email. Please
try signing in with your company Google account.
</Notice>
)}
{props.notice === 'auth-error' && (
<Notice>
Authentication failed - we were unable to sign you in at this
time. Please try again.
</Notice>
)}
</Hero>
<Features reverse={{ mobile: true, tablet: false, desktop: false }}>
<Grid.Unit size={{ desktop: 1 / 3, tablet: 1 / 2 }}>
@ -90,10 +109,10 @@ function Home() {
<Footer>
<h2>Create an account</h2>
<p>
On the same page as us? Create a beta account to give Outline a try.
On the same page as us? Create a free account to give Outline a try.
</p>
<FooterCTA>
<SignupButton />
<SigninButtons {...props} />
</FooterCTA>
</Footer>
</Grid>
@ -101,6 +120,13 @@ function Home() {
);
}
const Notice = styled.p`
background: #ffd95c;
color: hsla(46, 100%, 20%, 1);
padding: 10px;
border-radius: 4px;
`;
const Screenshot = styled.img`
width: 100%;
box-shadow: 0 0 80px 0 rgba(124, 124, 124, 0.5),

View File

@ -0,0 +1,72 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import { signin } from '../../../shared/utils/routeHelpers';
import Flex from '../../../shared/components/Flex';
import GoogleLogo from '../../../shared/components/GoogleLogo';
import SlackLogo from '../../../shared/components/SlackLogo';
import { color } from '../../../shared/styles/constants';
type Props = {
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
};
const SigninButtons = ({
lastSignedIn,
slackSigninEnabled,
googleSigninEnabled,
}: Props) => {
return (
<Flex justify="center">
{slackSigninEnabled && (
<Flex column>
<Button href={signin('slack')}>
<SlackLogo />
<Spacer>Sign In with Slack</Spacer>
</Button>
<LastLogin>
{lastSignedIn === 'slack' && 'You signed in with Slack previously'}
</LastLogin>
</Flex>
)}
&nbsp;
{googleSigninEnabled && (
<Flex column>
<Button href={signin('google')}>
<GoogleLogo />
<Spacer>Sign In with Google</Spacer>
</Button>
<LastLogin>
{lastSignedIn === 'google' &&
'You signed in with Google previously'}
</LastLogin>
</Flex>
)}
</Flex>
);
};
const Spacer = styled.span`
padding-left: 10px;
`;
const Button = styled.a`
display: inline-flex;
align-items: center;
padding: 10px 20px;
color: ${color.white};
background: ${color.black};
border-radius: 4px;
font-weight: 600;
height: 56px;
`;
const LastLogin = styled.p`
font-size: 12px;
color: ${color.slate};
padding-top: 4px;
`;
export default SigninButtons;

View File

@ -1,31 +0,0 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import { signin } from '../../../shared/utils/routeHelpers';
import SlackLogo from '../../../shared/components/SlackLogo';
import { color } from '../../../shared/styles/constants';
const SlackSignin = () => {
return (
<Button href={signin()}>
<SlackLogo />
<Spacer>Sign In with Slack</Spacer>
</Button>
);
};
const Spacer = styled.span`
padding-left: 10px;
`;
const Button = styled.a`
display: inline-flex;
align-items: center;
padding: 10px 20px;
color: ${color.white};
background: ${color.black};
border-radius: 4px;
font-weight: 600;
`;
export default SlackSignin;

View File

@ -17,5 +17,6 @@ allow(
allow(User, 'delete', Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (user.id === collection.creatorId) return true;
if (!user.isAdmin) throw new AdminRequiredError();
if (user.isAdmin) return true;
throw new AdminRequiredError();
});

View File

@ -6,5 +6,6 @@ import './document';
import './integration';
import './share';
import './user';
import './team';
export default policy;

14
server/policies/team.js Normal file
View File

@ -0,0 +1,14 @@
// @flow
import policy from './policy';
import { Team, User } from '../models';
import { AdminRequiredError } from '../errors';
const { allow } = policy;
allow(User, 'read', Team, (user, team) => team && user.teamId === team.id);
allow(User, 'update', Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (user.isAdmin) return true;
throw new AdminRequiredError();
});

View File

@ -7,9 +7,9 @@ function present(ctx: Object, integration: Integration) {
type: integration.type,
userId: integration.userId,
teamId: integration.teamId,
serviceId: integration.serviceId,
collectionId: integration.collectionId,
authenticationId: integration.authenticationId,
service: integration.service,
events: integration.events,
settings: integration.settings,
createdAt: integration.createdAt,

View File

@ -9,6 +9,8 @@ function present(ctx: Object, team: Team) {
name: team.name,
avatarUrl:
team.avatarUrl || (team.slackData ? team.slackData.image_88 : null),
slackConnected: !!team.slackId,
googleConnected: !!team.googleId,
};
}

View File

@ -19,8 +19,6 @@ export default (
user: User,
options: Options = {}
): UserPresentation => {
ctx.cache.set(user.id, user);
const userData = {};
userData.id = user.id;
userData.username = user.username;

View File

@ -7,7 +7,6 @@ import sendfile from 'koa-sendfile';
import serve from 'koa-static';
import subdomainRedirect from './middlewares/subdomainRedirect';
import renderpage from './utils/renderpage';
import { slackAuth } from '../shared/utils/routeHelpers';
import { robotsResponse } from './utils/robots';
import { NotFoundError } from './errors';
@ -48,19 +47,6 @@ if (process.env.NODE_ENV === 'production') {
});
}
// slack direct install
router.get('/auth/slack/install', async ctx => {
const state = Math.random()
.toString(36)
.substring(7);
ctx.cookies.set('state', state, {
httpOnly: false,
expires: new Date('2100'),
});
ctx.redirect(slackAuth(state));
});
// static pages
router.get('/about', ctx => renderpage(ctx, <About />));
router.get('/pricing', ctx => renderpage(ctx, <Pricing />));
@ -76,10 +62,21 @@ router.get('/changelog', async ctx => {
// home page
router.get('/', async ctx => {
if (ctx.cookies.get('loggedIn')) {
const lastSignedIn = ctx.cookies.get('lastSignedIn');
const accessToken = ctx.cookies.get('accessToken');
if (accessToken) {
await renderapp(ctx);
} else {
await renderpage(ctx, <Home />);
await renderpage(
ctx,
<Home
notice={ctx.request.query.notice}
lastSignedIn={lastSignedIn}
googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID}
slackSigninEnabled={!!process.env.SLACK_KEY}
/>
);
}
});

View File

@ -14,8 +14,8 @@ const Slack = {
const integration = await Integration.findOne({
where: {
teamId: document.teamId,
serviceId: 'slack',
collectionId: document.atlasId,
service: 'slack',
type: 'post',
},
});

View File

@ -44,7 +44,7 @@ export async function request(endpoint: string, body: Object) {
export async function oauthAccess(
code: string,
redirect_uri: string = `${process.env.URL || ''}/auth/slack`
redirect_uri: string = `${process.env.URL || ''}/auth/slack.callback`
) {
return request('oauth.access', {
client_id: process.env.SLACK_KEY,

View File

@ -40,7 +40,8 @@ export async function buildUser(overrides: Object = {}) {
username: `user${count}`,
name: `User ${count}`,
password: 'test123!',
slackId: uuid.v4(),
service: 'slack',
serviceId: uuid.v4(),
...overrides,
});
}

View File

@ -30,7 +30,8 @@ const seed = async () => {
name: 'User 1',
password: 'test123!',
teamId: team.id,
slackId: 'U2399UF2P',
service: 'slack',
serviceId: 'U2399UF2P',
slackData: {
id: 'U2399UF2P',
image_192: 'http://example.com/avatar.png',
@ -45,7 +46,8 @@ const seed = async () => {
password: 'test123!',
teamId: team.id,
isAdmin: true,
slackId: 'U2399UF1P',
service: 'slack',
serviceId: 'U2399UF1P',
slackData: {
id: 'U2399UF1P',
image_192: 'http://example.com/avatar.png',

View File

@ -37,15 +37,18 @@ export const signPolicy = (policy: any) => {
return signature;
};
export const publicS3Endpoint = () => {
export const publicS3Endpoint = (isServerUpload?: boolean) => {
// lose trailing slash if there is one and convert fake-s3 url to localhost
// for access outside of docker containers in local development
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
const host = process.env.AWS_S3_UPLOAD_BUCKET_URL.replace(
's3:',
'localhost:'
).replace(/\/$/, '');
return `${host}/${process.env.AWS_S3_UPLOAD_BUCKET_NAME}`;
return `${host}/${isServerUpload && isDocker ? 's3/' : ''}${
process.env.AWS_S3_UPLOAD_BUCKET_NAME
}`;
};
export const uploadToS3FromUrl = async (url: string, key: string) => {
@ -68,11 +71,12 @@ export const uploadToS3FromUrl = async (url: string, key: string) => {
Key: key,
ContentType: res.headers['content-type'],
ContentLength: res.headers['content-length'],
ServerSideEncryption: 'AES256',
Body: buffer,
})
.promise();
const endpoint = publicS3Endpoint();
const endpoint = publicS3Endpoint(true);
return `${endpoint}/${key}`;
} catch (err) {
if (process.env.NODE_ENV === 'production') {

View File

@ -0,0 +1,27 @@
// @flow
import * as React from 'react';
type Props = {
size?: number,
fill?: string,
className?: string,
};
function GoogleLogo({ size = 34, fill = '#FFF', className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g>
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
</g>
</svg>
);
}
export default GoogleLogo;

View File

@ -47,9 +47,9 @@ export const color = {
/* Brand */
primary: '#1AB6FF',
danger: '#D0021B',
warning: '#f08a24' /* replace */,
success: '#43AC6A' /* replace */,
info: '#a0d3e8' /* replace */,
warning: '#f08a24',
success: '#1AB6FF',
info: '#a0d3e8',
offline: '#000000',
/* Dark Grays */

View File

@ -8,7 +8,7 @@ export function slackAuth(
'identity.avatar',
'identity.team',
],
redirectUri: string = `${process.env.URL}/auth/slack`
redirectUri: string = `${process.env.URL}/auth/slack.callback`
): string {
const baseUrl = 'https://slack.com/oauth/authorize';
const params = {
@ -57,8 +57,8 @@ export function changelog(): string {
return '/changelog';
}
export function signin(): string {
return '/auth/slack';
export function signin(service: string = 'slack'): string {
return `/auth/${service}`;
}
export function about(): string {

View File

@ -34,7 +34,10 @@ module.exports = {
include: [
path.join(__dirname, 'app'),
path.join(__dirname, 'shared'),
]
],
options: {
cacheDirectory: true
}
},
{ test: /\.json$/, loader: 'json-loader' },
// inline base64 URLs for <=8k images, direct URLs for the rest

View File

@ -490,6 +490,13 @@ aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
axios@^0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102"
dependencies:
follow-redirects "^1.3.0"
is-buffer "^1.1.5"
axobject-query@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
@ -3441,7 +3448,7 @@ express-session@~1.11.3:
uid-safe "~2.0.0"
utils-merge "1.0.0"
extend@^3.0.0, extend@~3.0.0, extend@~3.0.1:
extend@^3.0.0, extend@^3.0.1, extend@~3.0.0, extend@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
@ -3739,6 +3746,12 @@ flush-write-stream@^1.0.0:
inherits "^2.0.1"
readable-stream "^2.0.4"
follow-redirects@^1.3.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.0.tgz#234f49cf770b7f35b40e790f636ceba0c3a0ab77"
dependencies:
debug "^3.1.0"
for-in@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@ -3927,6 +3940,14 @@ gaze@^0.5.1:
dependencies:
globule "~0.1.0"
gcp-metadata@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-0.6.3.tgz#4550c08859c528b370459bd77a7187ea0bdbc4ab"
dependencies:
axios "^0.18.0"
extend "^3.0.1"
retry-axios "0.3.2"
generic-pool@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff"
@ -4099,6 +4120,18 @@ good-listener@^1.2.2:
dependencies:
delegate "^3.1.2"
google-auth-library@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-1.5.0.tgz#d9068f8bad9017224a4c41abcdcb6cf6a704e83b"
dependencies:
axios "^0.18.0"
gcp-metadata "^0.6.3"
gtoken "^2.3.0"
jws "^3.1.4"
lodash.isstring "^4.0.1"
lru-cache "^4.1.2"
retry-axios "^0.3.2"
google-closure-compiler-js@^20170423.0.0:
version "20170423.0.0"
resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20170423.0.0.tgz#e9e8b40dadfdf0e64044c9479b5d26d228778fbc"
@ -4107,6 +4140,13 @@ google-closure-compiler-js@^20170423.0.0:
vinyl "^2.0.1"
webpack-core "^0.6.8"
google-p12-pem@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-1.0.2.tgz#c8a3843504012283a0dbffc7430b7c753ecd4b07"
dependencies:
node-forge "^0.7.4"
pify "^3.0.0"
got@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca"
@ -4163,6 +4203,16 @@ growly@^1.2.0, growly@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
gtoken@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-2.3.0.tgz#4e0ffc16432d7041a1b3dbc1d97aac17a5dc964a"
dependencies:
axios "^0.18.0"
google-p12-pem "^1.0.0"
jws "^3.1.4"
mime "^2.2.0"
pify "^3.0.0"
gulp-help@~1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/gulp-help/-/gulp-help-1.6.1.tgz#261db186e18397fef3f6a2c22e9c315bfa88ae0c"
@ -6406,7 +6456,7 @@ lru-cache@^4.0.1:
pseudomap "^1.0.2"
yallist "^2.1.2"
lru-cache@^4.1.1:
lru-cache@^4.1.1, lru-cache@^4.1.2:
version "4.1.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c"
dependencies:
@ -6603,6 +6653,10 @@ mime@^1.4.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
mime@^2.2.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369"
mimic-fn@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
@ -6855,6 +6909,10 @@ node-fetch@^1.0.1, node-fetch@^1.5.1:
encoding "^0.1.11"
is-stream "^1.0.1"
node-forge@^0.7.4:
version "0.7.5"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df"
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@ -7268,9 +7326,9 @@ outline-icons@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e"
outline-icons@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.1.0.tgz#08eb188a97a1aa8970a4dded7841c3d8b96b8577"
outline-icons@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.2.0.tgz#8a0e0e9e9b98336470228837c4933ba10297fcf5"
oy-vey@^0.10.0:
version "0.10.0"
@ -8762,6 +8820,10 @@ retry-as-promised@^2.3.1:
bluebird "^3.4.6"
debug "^2.6.9"
retry-axios@0.3.2, retry-axios@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13"
rich-markdown-editor@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-1.1.2.tgz#c44f14425b5b5f0da3adce8bf389ed6e20b705a4"