@ -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
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
dist
|
||||
node_modules/*
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -112,7 +112,7 @@ class Layout extends React.Component<Props> {
|
||||
</Content>
|
||||
</Flex>
|
||||
<Modals ui={ui} />
|
||||
<Toasts />
|
||||
<Toasts ui={ui} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
18
app/index.js
18
app/index.js
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import ErrorAuth from './ErrorAuth';
|
||||
export default ErrorAuth;
|
161
app/scenes/Settings/Details.js
Normal file
161
app/scenes/Settings/Details.js
Normal 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);
|
@ -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);
|
@ -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);
|
@ -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} />
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import Settings from './Settings';
|
||||
export default Settings;
|
||||
import Profile from './Profile';
|
||||
export default Profile;
|
||||
|
@ -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);
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import SlackAuth from './SlackAuth';
|
||||
export default SlackAuth;
|
@ -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 {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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;
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
@ -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;
|
||||
|
28
app/stores/UiStore.test.js
Normal file
28
app/stores/UiStore.test.js
Normal 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');
|
||||
});
|
||||
});
|
@ -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(),
|
||||
};
|
||||
|
||||
|
@ -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 = {
|
||||
|
2
index.js
2
index.js
@ -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`'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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,
|
||||
}
|
||||
`;
|
@ -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",
|
||||
},
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
@ -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());
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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) => {
|
46
server/api/middlewares/errorHandling.js
Normal file
46
server/api/middlewares/errorHandling.js
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
@ -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
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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 => {
|
||||
|
@ -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' },
|
||||
});
|
||||
|
@ -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
101
server/auth/google.js
Normal 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
20
server/auth/index.js
Normal 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
148
server/auth/slack.js
Normal 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;
|
@ -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));
|
||||
|
||||
|
@ -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');
|
@ -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';
|
||||
|
@ -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);
|
27
server/migrations/20180528233909-google-auth.js
Normal file
27
server/migrations/20180528233909-google-auth.js
Normal 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']);
|
||||
}
|
||||
}
|
10
server/migrations/20180528233910-rename-serviceid.js
Normal file
10
server/migrations/20180528233910-rename-serviceid.js
Normal 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');
|
||||
}
|
||||
}
|
@ -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'),
|
||||
});
|
||||
|
@ -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),
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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),
|
||||
|
72
server/pages/components/SigninButtons.js
Normal file
72
server/pages/components/SigninButtons.js
Normal 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>
|
||||
)}
|
||||
|
||||
{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;
|
@ -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;
|
@ -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();
|
||||
});
|
||||
|
@ -6,5 +6,6 @@ import './document';
|
||||
import './integration';
|
||||
import './share';
|
||||
import './user';
|
||||
import './team';
|
||||
|
||||
export default policy;
|
||||
|
14
server/policies/team.js
Normal file
14
server/policies/team.js
Normal 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();
|
||||
});
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -14,8 +14,8 @@ const Slack = {
|
||||
const integration = await Integration.findOne({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
serviceId: 'slack',
|
||||
collectionId: document.atlasId,
|
||||
service: 'slack',
|
||||
type: 'post',
|
||||
},
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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') {
|
||||
|
27
shared/components/GoogleLogo.js
Normal file
27
shared/components/GoogleLogo.js
Normal 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;
|
@ -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 */
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
72
yarn.lock
72
yarn.lock
@ -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"
|
||||
|
Reference in New Issue
Block a user