diff --git a/.env.sample b/.env.sample
index db8c5a02..8a79a217 100644
--- a/.env.sample
+++ b/.env.sample
@@ -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
diff --git a/.gitignore b/.gitignore
index 2885433a..f00129f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
dist
node_modules/*
.env
+.log
npm-debug.log
stats.json
.DS_Store
diff --git a/app/components/Auth.js b/app/components/Auth.js
index 6a9d3f86..8952f87c 100644
--- a/app/components/Auth.js
+++ b/app/components/Auth.js
@@ -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 ;
+ }
+
// 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 {children};
}
- stores.auth.logout();
+ auth.logout();
return null;
-};
+});
-export default Auth;
+export default inject('auth')(Auth);
diff --git a/app/components/Input/Input.js b/app/components/Input/Input.js
index 05e6cf8f..0e2ed868 100644
--- a/app/components/Input/Input.js
+++ b/app/components/Input/Input.js
@@ -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 (
-
+
@@ -83,7 +83,7 @@ class Slack extends React.Component {
{collection.name}
diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js
index 3379b2cb..490cfee4 100644
--- a/app/scenes/Settings/Tokens.js
+++ b/app/scenes/Settings/Tokens.js
@@ -45,8 +45,9 @@ class Tokens extends React.Component {
API Tokens
- You can create unlimited personal API tokens to hack on your wiki.
- Learn more in the API documentation.
+ 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{' '}
+ developer documentation.
{hasApiKeys && (
diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js
index b3ca7a81..e0aed52a 100644
--- a/app/scenes/Settings/components/ImageUpload.js
+++ b/app/scenes/Settings/components/ImageUpload.js
@@ -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 {
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 {
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 {
};
renderCropping() {
+ const { submitText } = this.props;
+
return (
-
+
{
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 {
/>
{this.isUploading && }
- Crop avatar
+ {submitText}
@@ -89,19 +103,20 @@ class DropToImport extends React.Component {
render() {
if (this.isCropping) {
return this.renderCropping();
- } else {
- return (
-
- {this.props.children}
-
- );
}
+
+ return (
+
+ {this.props.children}
+
+ );
}
}
diff --git a/app/scenes/Settings/components/SlackButton.js b/app/scenes/Settings/components/SlackButton.js
index 241e6ed6..cffdd565 100644
--- a/app/scenes/Settings/components/SlackButton.js
+++ b/app/scenes/Settings/components/SlackButton.js
@@ -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 (
} neutral>
diff --git a/app/scenes/Settings/index.js b/app/scenes/Settings/index.js
index c0492da1..c1e3920e 100644
--- a/app/scenes/Settings/index.js
+++ b/app/scenes/Settings/index.js
@@ -1,3 +1,3 @@
// @flow
-import Settings from './Settings';
-export default Settings;
+import Profile from './Profile';
+export default Profile;
diff --git a/app/scenes/SlackAuth/SlackAuth.js b/app/scenes/SlackAuth/SlackAuth.js
deleted file mode 100644
index dd942d88..00000000
--- a/app/scenes/SlackAuth/SlackAuth.js
+++ /dev/null
@@ -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 {
- @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 ;
- return null;
- }
-}
-
-export default inject('auth')(SlackAuth);
diff --git a/app/scenes/SlackAuth/index.js b/app/scenes/SlackAuth/index.js
deleted file mode 100644
index cf1b93d0..00000000
--- a/app/scenes/SlackAuth/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-import SlackAuth from './SlackAuth';
-export default SlackAuth;
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index 29b2f0e8..52621025 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -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 {
diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js
index 50b95445..d23f610e 100644
--- a/app/stores/CollectionsStore.js
+++ b/app/stores/CollectionsStore.js
@@ -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 }) => {
diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js
index 08ce8004..63452b5a 100644
--- a/app/stores/DocumentsStore.js
+++ b/app/stores/DocumentsStore.js
@@ -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 }) => {
diff --git a/app/stores/ErrorsStore.js b/app/stores/ErrorsStore.js
deleted file mode 100644
index eac503e7..00000000
--- a/app/stores/ErrorsStore.js
+++ /dev/null
@@ -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;
diff --git a/app/stores/ErrorsStore.test.js b/app/stores/ErrorsStore.test.js
deleted file mode 100644
index daa72a01..00000000
--- a/app/stores/ErrorsStore.test.js
+++ /dev/null
@@ -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');
- });
-});
diff --git a/app/stores/IntegrationsStore.js b/app/stores/IntegrationsStore.js
index 02a1fd22..39e065cf 100644
--- a/app/stores/IntegrationsStore.js
+++ b/app/stores/IntegrationsStore.js
@@ -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);
diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js
index 109ee725..c5477a11 100644
--- a/app/stores/UiStore.js
+++ b/app/stores/UiStore.js
@@ -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;
diff --git a/app/stores/UiStore.test.js b/app/stores/UiStore.test.js
new file mode 100644
index 00000000..5eeb0930
--- /dev/null
+++ b/app/stores/UiStore.test.js
@@ -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');
+ });
+});
diff --git a/app/stores/index.js b/app/stores/index.js
index 68d49ca9..7065b456 100644
--- a/app/stores/index.js
+++ b/app/stores/index.js
@@ -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(),
};
diff --git a/app/types/index.js b/app/types/index.js
index 2ef769b5..8b629d96 100644
--- a/app/types/index.js
+++ b/app/types/index.js
@@ -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 = {
diff --git a/index.js b/index.js
index a04608b6..78a3c4db 100644
--- a/index.js
+++ b/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`'
);
}
diff --git a/package.json b/package.json
index aac8a19c..d9e38157 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/server/api/__snapshots__/auth.test.js.snap b/server/api/__snapshots__/auth.test.js.snap
deleted file mode 100644
index 2be2eba7..00000000
--- a/server/api/__snapshots__/auth.test.js.snap
+++ /dev/null
@@ -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,
-}
-`;
diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap
index 81d7533b..40a7610d 100644
--- a/server/api/__snapshots__/user.test.js.snap
+++ b/server/api/__snapshots__/user.test.js.snap
@@ -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",
},
diff --git a/server/api/apiKeys.js b/server/api/apiKeys.js
index 2452030b..3086300f 100644
--- a/server/api/apiKeys.js
+++ b/server/api/apiKeys.js
@@ -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';
diff --git a/server/api/auth.js b/server/api/auth.js
index e520cb7d..5b92a450 100644
--- a/server/api/auth.js
+++ b/server/api/auth.js
@@ -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;
diff --git a/server/api/auth.test.js b/server/api/auth.test.js
deleted file mode 100644
index 202e5ee9..00000000
--- a/server/api/auth.test.js
+++ /dev/null
@@ -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();
- });
-});
diff --git a/server/api/collections.js b/server/api/collections.js
index 86b44764..85ed0b0b 100644
--- a/server/api/collections.js
+++ b/server/api/collections.js
@@ -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';
diff --git a/server/api/documents.js b/server/api/documents.js
index 5cf4a3b2..0813df7e 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -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';
diff --git a/server/api/hooks.js b/server/api/hooks.js
index c59017d5..4c3eb354 100644
--- a/server/api/hooks.js
+++ b/server/api/hooks.js
@@ -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,
},
});
diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js
index b0283533..3fc9b36f 100644
--- a/server/api/hooks.test.js
+++ b/server/api/hooks.test.js
@@ -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',
},
});
diff --git a/server/api/index.js b/server/api/index.js
index 26e9e1dc..8088c313 100644
--- a/server/api/index.js
+++ b/server/api/index.js
@@ -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());
diff --git a/server/api/integrations.js b/server/api/integrations.js
index 56998aeb..53b96447 100644
--- a/server/api/integrations.js
+++ b/server/api/integrations.js
@@ -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';
diff --git a/server/api/middlewares/apiWrapper.js b/server/api/middlewares/apiWrapper.js
index d4f8ba33..e980e190 100644
--- a/server/api/middlewares/apiWrapper.js
+++ b/server/api/middlewares/apiWrapper.js
@@ -4,7 +4,7 @@ import { type Context } from 'koa';
export default function apiWrapper() {
return async function apiWrapperMiddleware(
ctx: Context,
- next: () => Promise
+ next: () => Promise<*>
) {
await next();
diff --git a/server/middlewares/cache.js b/server/api/middlewares/cache.js
similarity index 80%
rename from server/middlewares/cache.js
rename to server/api/middlewares/cache.js
index 22068f62..7caf9ddd 100644
--- a/server/middlewares/cache.js
+++ b/server/api/middlewares/cache.js
@@ -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) => {
diff --git a/server/api/middlewares/errorHandling.js b/server/api/middlewares/errorHandling.js
new file mode 100644
index 00000000..5acd500a
--- /dev/null
+++ b/server/api/middlewares/errorHandling.js
@@ -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,
+ };
+ }
+ };
+}
diff --git a/server/middlewares/methodOverride.js b/server/api/middlewares/methodOverride.js
similarity index 93%
rename from server/middlewares/methodOverride.js
rename to server/api/middlewares/methodOverride.js
index 030b6d66..7872dfc1 100644
--- a/server/middlewares/methodOverride.js
+++ b/server/api/middlewares/methodOverride.js
@@ -5,7 +5,7 @@ import { type Context } from 'koa';
export default function methodOverride() {
return async function methodOverrideMiddleware(
ctx: Context,
- next: () => Promise
+ next: () => Promise<*>
) {
if (ctx.method === 'POST') {
// $FlowFixMe
diff --git a/server/api/middlewares/pagination.js b/server/api/middlewares/pagination.js
index 456d7930..f63daf1a 100644
--- a/server/api/middlewares/pagination.js
+++ b/server/api/middlewares/pagination.js
@@ -6,7 +6,7 @@ import { type Context } from 'koa';
export default function pagination(options?: Object) {
return async function paginationMiddleware(
ctx: Context,
- next: () => Promise
+ next: () => Promise<*>
) {
const opts = {
defaultLimit: 15,
diff --git a/server/api/shares.js b/server/api/shares.js
index ce2fb9e1..677b0222 100644
--- a/server/api/shares.js
+++ b/server/api/shares.js
@@ -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';
diff --git a/server/api/team.js b/server/api/team.js
index d51cba7b..d04c729c 100644
--- a/server/api/team.js
+++ b/server/api/team.js
@@ -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;
diff --git a/server/api/team.test.js b/server/api/team.test.js
index 19fb0b0b..3a266cc3 100644
--- a/server/api/team.test.js
+++ b/server/api/team.test.js
@@ -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);
+ });
+});
diff --git a/server/api/user.js b/server/api/user.js
index b8a1dbf8..0ab1d9a7 100644
--- a/server/api/user.js
+++ b/server/api/user.js
@@ -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 => {
diff --git a/server/api/user.test.js b/server/api/user.test.js
index a7b18e2a..f714eac6 100644
--- a/server/api/user.test.js
+++ b/server/api/user.test.js
@@ -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' },
});
diff --git a/server/api/views.js b/server/api/views.js
index 7e59794a..0d73387f 100644
--- a/server/api/views.js
+++ b/server/api/views.js
@@ -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';
diff --git a/server/auth/google.js b/server/auth/google.js
new file mode 100644
index 00000000..09145818
--- /dev/null
+++ b/server/auth/google.js
@@ -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;
diff --git a/server/auth/index.js b/server/auth/index.js
new file mode 100644
index 00000000..ed416e76
--- /dev/null
+++ b/server/auth/index.js
@@ -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;
diff --git a/server/auth/slack.js b/server/auth/slack.js
new file mode 100644
index 00000000..115c0349
--- /dev/null
+++ b/server/auth/slack.js
@@ -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;
diff --git a/server/index.js b/server/index.js
index 6ce3afa0..d547d637 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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));
diff --git a/server/api/middlewares/authentication.js b/server/middlewares/authentication.js
similarity index 92%
rename from server/api/middlewares/authentication.js
rename to server/middlewares/authentication.js
index 3cd33ab6..11f3717c 100644
--- a/server/api/middlewares/authentication.js
+++ b/server/middlewares/authentication.js
@@ -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
- ) {
+ return async function authMiddleware(ctx: Context, next: () => Promise<*>) {
let token;
const authorizationHeader = ctx.request.get('authorization');
diff --git a/server/api/middlewares/authentication.test.js b/server/middlewares/authentication.test.js
similarity index 96%
rename from server/api/middlewares/authentication.test.js
rename to server/middlewares/authentication.test.js
index 2adb3066..751b5523 100644
--- a/server/api/middlewares/authentication.test.js
+++ b/server/middlewares/authentication.test.js
@@ -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';
diff --git a/server/api/middlewares/validation.js b/server/middlewares/validation.js
similarity index 80%
rename from server/api/middlewares/validation.js
rename to server/middlewares/validation.js
index 5ac0ef36..30780850 100644
--- a/server/api/middlewares/validation.js
+++ b/server/middlewares/validation.js
@@ -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);
diff --git a/server/migrations/20180528233909-google-auth.js b/server/migrations/20180528233909-google-auth.js
new file mode 100644
index 00000000..c68f8520
--- /dev/null
+++ b/server/migrations/20180528233909-google-auth.js
@@ -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']);
+ }
+}
\ No newline at end of file
diff --git a/server/migrations/20180528233910-rename-serviceid.js b/server/migrations/20180528233910-rename-serviceid.js
new file mode 100644
index 00000000..ce0a2aad
--- /dev/null
+++ b/server/migrations/20180528233910-rename-serviceid.js
@@ -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');
+ }
+}
\ No newline at end of file
diff --git a/server/models/Authentication.js b/server/models/Authentication.js
index 85a07abe..e4a479dd 100644
--- a/server/models/Authentication.js
+++ b/server/models/Authentication.js
@@ -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'),
});
diff --git a/server/models/Integration.js b/server/models/Integration.js
index 99b9aa32..9251ba7c 100644
--- a/server/models/Integration.js
+++ b/server/models/Integration.js
@@ -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),
});
diff --git a/server/models/Team.js b/server/models/Team.js
index 598af8d7..808e85b5 100644
--- a/server/models/Team.js
+++ b/server/models/Team.js
@@ -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;
diff --git a/server/models/User.js b/server/models/User.js
index 878757e4..117b3340 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -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));
diff --git a/server/pages/Home.js b/server/pages/Home.js
index 5aee066b..43511c56 100644
--- a/server/pages/Home.js
+++ b/server/pages/Home.js
@@ -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 (
@@ -23,8 +30,20 @@ function Home() {
logs, brainstorming, & more…
-
+
+ {props.notice === 'google-hd' && (
+
+ Sorry, Google sign in cannot be used with a personal email. Please
+ try signing in with your company Google account.
+
+ )}
+ {props.notice === 'auth-error' && (
+
+ Authentication failed - we were unable to sign you in at this
+ time. Please try again.
+
+ )}
@@ -90,10 +109,10 @@ function Home() {
@@ -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),
diff --git a/server/pages/components/SigninButtons.js b/server/pages/components/SigninButtons.js
new file mode 100644
index 00000000..acd84f0d
--- /dev/null
+++ b/server/pages/components/SigninButtons.js
@@ -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 (
+
+ {slackSigninEnabled && (
+
+
+
+ {lastSignedIn === 'slack' && 'You signed in with Slack previously'}
+
+
+ )}
+
+ {googleSigninEnabled && (
+
+
+
+ {lastSignedIn === 'google' &&
+ 'You signed in with Google previously'}
+
+
+ )}
+
+ );
+};
+
+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;
diff --git a/server/pages/components/SignupButton.js b/server/pages/components/SignupButton.js
deleted file mode 100644
index 155243c8..00000000
--- a/server/pages/components/SignupButton.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-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;
diff --git a/server/policies/collection.js b/server/policies/collection.js
index 29ae2dbf..fb10e6c3 100644
--- a/server/policies/collection.js
+++ b/server/policies/collection.js
@@ -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();
});
diff --git a/server/policies/index.js b/server/policies/index.js
index 2e149240..147ab4fa 100644
--- a/server/policies/index.js
+++ b/server/policies/index.js
@@ -6,5 +6,6 @@ import './document';
import './integration';
import './share';
import './user';
+import './team';
export default policy;
diff --git a/server/policies/team.js b/server/policies/team.js
new file mode 100644
index 00000000..45a91ddb
--- /dev/null
+++ b/server/policies/team.js
@@ -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();
+});
diff --git a/server/presenters/integration.js b/server/presenters/integration.js
index eb0099f3..619d7cd4 100644
--- a/server/presenters/integration.js
+++ b/server/presenters/integration.js
@@ -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,
diff --git a/server/presenters/team.js b/server/presenters/team.js
index d4121b68..863de4a3 100644
--- a/server/presenters/team.js
+++ b/server/presenters/team.js
@@ -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,
};
}
diff --git a/server/presenters/user.js b/server/presenters/user.js
index 6aa12b0f..767e3e7e 100644
--- a/server/presenters/user.js
+++ b/server/presenters/user.js
@@ -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;
diff --git a/server/routes.js b/server/routes.js
index f883118f..bf50d5ab 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -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, ));
router.get('/pricing', ctx => renderpage(ctx, ));
@@ -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, );
+ await renderpage(
+ ctx,
+
+ );
}
});
diff --git a/server/services/slack/index.js b/server/services/slack/index.js
index d40f10b0..b2e5fd21 100644
--- a/server/services/slack/index.js
+++ b/server/services/slack/index.js
@@ -14,8 +14,8 @@ const Slack = {
const integration = await Integration.findOne({
where: {
teamId: document.teamId,
- serviceId: 'slack',
collectionId: document.atlasId,
+ service: 'slack',
type: 'post',
},
});
diff --git a/server/slack.js b/server/slack.js
index 2d06d26a..d33ebf48 100644
--- a/server/slack.js
+++ b/server/slack.js
@@ -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,
diff --git a/server/test/factories.js b/server/test/factories.js
index b7620908..0d8b4ebe 100644
--- a/server/test/factories.js
+++ b/server/test/factories.js
@@ -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,
});
}
diff --git a/server/test/support.js b/server/test/support.js
index d448b44b..826c8ffa 100644
--- a/server/test/support.js
+++ b/server/test/support.js
@@ -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',
diff --git a/server/utils/s3.js b/server/utils/s3.js
index c8d0c08e..ffe4867c 100644
--- a/server/utils/s3.js
+++ b/server/utils/s3.js
@@ -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') {
diff --git a/shared/components/GoogleLogo.js b/shared/components/GoogleLogo.js
new file mode 100644
index 00000000..85ffe7ab
--- /dev/null
+++ b/shared/components/GoogleLogo.js
@@ -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 (
+
+ );
+}
+
+export default GoogleLogo;
diff --git a/shared/styles/constants.js b/shared/styles/constants.js
index 303d6bee..3615b073 100644
--- a/shared/styles/constants.js
+++ b/shared/styles/constants.js
@@ -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 */
diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js
index 826a8327..ff05000d 100644
--- a/shared/utils/routeHelpers.js
+++ b/shared/utils/routeHelpers.js
@@ -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 {
diff --git a/webpack.config.js b/webpack.config.js
index 0c7bc699..cc67d1a7 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -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
diff --git a/yarn.lock b/yarn.lock
index 902dcb13..cb46e1b4 100644
--- a/yarn.lock
+++ b/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"