From ddd2b82d20485a770f64e1f2801b4062fec5605f Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Mon, 28 May 2018 11:36:37 -0700
Subject: [PATCH 01/22] WIP: Successful Google Auth, broke pretty much
everything else in the process
---
.env.sample | 5 +-
app/components/Auth.js | 8 +-
app/stores/AuthStore.js | 4 +-
package.json | 3 +-
server/api/apiKeys.js | 2 +-
server/api/auth.js | 127 +-------------
server/api/auth.test.js | 164 ------------------
server/api/collections.js | 2 +-
server/api/documents.js | 2 +-
server/api/index.js | 50 +-----
server/api/integrations.js | 2 +-
server/api/middlewares/apiWrapper.js | 2 +-
server/{ => api}/middlewares/cache.js | 3 +-
server/api/middlewares/errorHandling.js | 46 +++++
.../{ => api}/middlewares/methodOverride.js | 2 +-
server/api/middlewares/pagination.js | 2 +-
server/api/shares.js | 2 +-
server/api/team.js | 2 +-
server/api/user.js | 2 +-
server/api/views.js | 2 +-
server/auth/google.js | 86 +++++++++
server/auth/index.js | 20 +++
server/auth/slack.js | 145 ++++++++++++++++
server/index.js | 2 +
.../{api => }/middlewares/authentication.js | 9 +-
.../middlewares/authentication.test.js | 0
server/{api => }/middlewares/validation.js | 7 +-
server/pages/Home.js | 8 +-
server/pages/components/SignupButton.js | 27 ++-
server/presenters/user.js | 2 -
server/routes.js | 22 +--
shared/utils/routeHelpers.js | 4 +-
yarn.lock | 66 ++++++-
33 files changed, 443 insertions(+), 387 deletions(-)
delete mode 100644 server/api/auth.test.js
rename server/{ => api}/middlewares/cache.js (80%)
create mode 100644 server/api/middlewares/errorHandling.js
rename server/{ => api}/middlewares/methodOverride.js (93%)
create mode 100644 server/auth/google.js
create mode 100644 server/auth/index.js
create mode 100644 server/auth/slack.js
rename server/{api => }/middlewares/authentication.js (92%)
rename server/{api => }/middlewares/authentication.test.js (100%)
rename server/{api => }/middlewares/validation.js (80%)
diff --git a/.env.sample b/.env.sample
index db8c5a021..46a6b14dc 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)
+# Slack 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/app/components/Auth.js b/app/components/Auth.js
index 6a9d3f867..2218461d4 100644
--- a/app/components/Auth.js
+++ b/app/components/Auth.js
@@ -15,7 +15,12 @@ type Props = {
let authenticatedStores;
const Auth = ({ children }: Props) => {
- if (stores.auth.authenticated && stores.auth.team && stores.auth.user) {
+ if (stores.auth.authenticated) {
+ if (!stores.auth.team || !stores.auth.user) {
+ stores.auth.fetch();
+ return null;
+ }
+
// Only initialize stores once. Kept in global scope because otherwise they
// will get overridden on route change
if (!authenticatedStores) {
@@ -42,7 +47,6 @@ const Auth = ({ children }: Props) => {
};
}
- stores.auth.fetch();
authenticatedStores.collections.fetchPage({ limit: 100 });
}
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index 29b2f0e84..2468787a8 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -123,7 +123,9 @@ class AuthStore {
}
this.user = data.user;
this.team = data.team;
- this.token = data.token;
+ this.token = Cookie.get('accessToken') || data.token;
+ console.log('TOKEN', this.token);
+
this.oauthState = data.oauthState;
autorun(() => {
diff --git a/package.json b/package.json
index aac8a19c9..40ef2e469 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",
@@ -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/apiKeys.js b/server/api/apiKeys.js
index 2452030bd..3086300fb 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 e520cb7dd..5b92a450a 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 202e5ee9f..000000000
--- 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 86b447644..85ed0b0b4 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 5cf4a3b27..0813df7e7 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/index.js b/server/api/index.js
index 26e9e1dc3..8088c3137 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 56998aebc..53b964477 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 d4f8ba33e..e980e190a 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 22068f621..7caf9dddd 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 000000000..5acd500ab
--- /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 030b6d664..7872dfc14 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 456d7930a..f63daf1aa 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 ce2fb9e11..677b0222f 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 d51cba7b2..d41bac406 100644
--- a/server/api/team.js
+++ b/server/api/team.js
@@ -2,7 +2,7 @@
import Router from 'koa-router';
import { User } from '../models';
-import auth from './middlewares/authentication';
+import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentUser } from '../presenters';
diff --git a/server/api/user.js b/server/api/user.js
index b8a1dbf82..4cadd2d04 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';
diff --git a/server/api/views.js b/server/api/views.js
index 7e59794a9..0d73387fb 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 000000000..330601c81
--- /dev/null
+++ b/server/auth/google.js
@@ -0,0 +1,86 @@
+// @flow
+import Router from 'koa-router';
+import addMonths from 'date-fns/add_months';
+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);
+
+ console.log('Tokens acquired.');
+ console.log(response.tokens);
+
+ const profile = await client.request({
+ url: 'https://www.googleapis.com/oauth2/v1/userinfo',
+ });
+
+ const teamName = profile.data.hd.split('.')[0];
+ const [team, isFirstUser] = await Team.findOrCreate({
+ where: {
+ slackId: profile.data.hd,
+ },
+ defaults: {
+ name: teamName,
+ avatarUrl: `https://logo.clearbit.com/${profile.data.hd}`,
+ },
+ });
+
+ const [user, isFirstSignin] = await User.findOrCreate({
+ where: {
+ slackId: profile.data.id,
+ teamId: team.id,
+ },
+ defaults: {
+ name: profile.data.name,
+ email: profile.data.email,
+ isAdmin: isFirstUser,
+ avatarUrl: profile.data.picture,
+ },
+ });
+
+ if (!isFirstSignin) {
+ await user.save();
+ }
+
+ if (isFirstUser) {
+ await team.createFirstCollection(user.id);
+ }
+
+ ctx.cookies.set('lastLoggedIn', 'google', {
+ httpOnly: false,
+ expires: new Date('2100'),
+ });
+ ctx.cookies.set('accessToken', user.getJwtToken(), {
+ httpOnly: false,
+ expires: addMonths(new Date(), 6),
+ });
+
+ ctx.redirect('/');
+});
+
+export default router;
diff --git a/server/auth/index.js b/server/auth/index.js
new file mode 100644
index 000000000..ed416e76a
--- /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 000000000..dede55316
--- /dev/null
+++ b/server/auth/slack.js
@@ -0,0 +1,145 @@
+// @flow
+import Router from 'koa-router';
+import auth from '../middlewares/authentication';
+import { slackAuth } from '../../shared/utils/routeHelpers';
+import { presentUser, presentTeam } from '../presenters';
+import { Authentication, Integration, User, Team } from '../models';
+import * as Slack from '../slack';
+
+const router = new Router();
+
+router.get('auth.slack', 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));
+});
+
+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/index.js b/server/index.js
index 6ce3afa04..d547d637e 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 3cd33ab67..11f3717c8 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 100%
rename from server/api/middlewares/authentication.test.js
rename to server/middlewares/authentication.test.js
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 5ac0ef360..307808507 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/pages/Home.js b/server/pages/Home.js
index 5aee066be..945a2c952 100644
--- a/server/pages/Home.js
+++ b/server/pages/Home.js
@@ -9,7 +9,11 @@ import SignupButton from './components/SignupButton';
import { developers, githubUrl } from '../../shared/utils/routeHelpers';
import { color } from '../../shared/styles/constants';
-function Home() {
+type Props = {
+ lastLoggedIn: string,
+};
+
+function Home({ lastLoggedIn }: Props) {
return (
@@ -23,7 +27,7 @@ function Home() {
logs, brainstorming, & more…
-
+
diff --git a/server/pages/components/SignupButton.js b/server/pages/components/SignupButton.js
index 155243c8c..5c6c15042 100644
--- a/server/pages/components/SignupButton.js
+++ b/server/pages/components/SignupButton.js
@@ -2,15 +2,32 @@
import * as React from 'react';
import styled from 'styled-components';
import { signin } from '../../../shared/utils/routeHelpers';
+import Flex from '../../../shared/components/Flex';
import SlackLogo from '../../../shared/components/SlackLogo';
import { color } from '../../../shared/styles/constants';
-const SlackSignin = () => {
+type Props = {
+ lastLoggedIn: string,
+};
+
+const SlackSignin = ({ lastLoggedIn }: Props) => {
return (
-
-
- Sign In with Slack
-
+
+
+
+
+ Sign In with Slack
+
+ {lastLoggedIn === 'slack' && 'You signed in with Slack previously'}
+
+
+
+
+ Sign In with Google
+
+ {lastLoggedIn === 'google' && 'You signed in with Google previously'}
+
+
);
};
diff --git a/server/presenters/user.js b/server/presenters/user.js
index 6aa12b0f7..767e3e7eb 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 f883118f7..1c05f47b5 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,14 @@ router.get('/changelog', async ctx => {
// home page
router.get('/', async ctx => {
- if (ctx.cookies.get('loggedIn')) {
+ const lastLoggedIn = ctx.cookies.get('lastLoggedIn');
+ const accessToken = ctx.cookies.get('accessToken');
+ console.log(lastLoggedIn, accessToken);
+
+ if (accessToken) {
await renderapp(ctx);
} else {
- await renderpage(ctx, );
+ await renderpage(ctx, );
}
});
diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js
index 826a83278..a9c7772a9 100644
--- a/shared/utils/routeHelpers.js
+++ b/shared/utils/routeHelpers.js
@@ -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/yarn.lock b/yarn.lock
index 902dcb133..b7175aba8 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"
@@ -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"
From 72d874444ea1d360a013471d50c7b8f923325f77 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Mon, 28 May 2018 20:31:53 -0700
Subject: [PATCH 02/22] DB migrations Google button
---
app/components/Auth.js | 4 ++-
server/api/hooks.js | 7 +++--
server/api/hooks.test.js | 8 +++---
server/auth/google.js | 19 +++++--------
server/auth/slack.js | 7 +++--
.../migrations/20180528233909-google-auth.js | 27 +++++++++++++++++++
server/models/Team.js | 2 ++
server/models/User.js | 3 ++-
server/pages/components/SignupButton.js | 25 ++++++++++++-----
server/test/factories.js | 3 ++-
server/test/support.js | 6 +++--
shared/components/GoogleLogo.js | 27 +++++++++++++++++++
12 files changed, 107 insertions(+), 31 deletions(-)
create mode 100644 server/migrations/20180528233909-google-auth.js
create mode 100644 shared/components/GoogleLogo.js
diff --git a/app/components/Auth.js b/app/components/Auth.js
index 2218461d4..81388c011 100644
--- a/app/components/Auth.js
+++ b/app/components/Auth.js
@@ -16,8 +16,10 @@ let authenticatedStores;
const Auth = ({ children }: Props) => {
if (stores.auth.authenticated) {
+ stores.auth.fetch();
+
+ // TODO: Show loading state
if (!stores.auth.team || !stores.auth.user) {
- stores.auth.fetch();
return null;
}
diff --git a/server/api/hooks.js b/server/api/hooks.js
index c59017d5e..8d9b91747 100644
--- a/server/api/hooks.js
+++ b/server/api/hooks.js
@@ -14,7 +14,9 @@ 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({
@@ -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 b02835335..0d7add05d 100644
--- a/server/api/hooks.test.js
+++ b/server/api/hooks.test.js
@@ -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/auth/google.js b/server/auth/google.js
index 330601c81..1f1c5f7ee 100644
--- a/server/auth/google.js
+++ b/server/auth/google.js
@@ -1,6 +1,7 @@
// @flow
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';
@@ -32,17 +33,14 @@ router.get('google.callback', async ctx => {
const response = await client.getToken(code);
client.setCredentials(response.tokens);
- console.log('Tokens acquired.');
- console.log(response.tokens);
-
const profile = await client.request({
url: 'https://www.googleapis.com/oauth2/v1/userinfo',
});
- const teamName = profile.data.hd.split('.')[0];
+ const teamName = capitalize(profile.data.hd.split('.')[0]);
const [team, isFirstUser] = await Team.findOrCreate({
where: {
- slackId: profile.data.hd,
+ googleId: profile.data.hd,
},
defaults: {
name: teamName,
@@ -50,9 +48,10 @@ router.get('google.callback', async ctx => {
},
});
- const [user, isFirstSignin] = await User.findOrCreate({
+ const [user] = await User.findOrCreate({
where: {
- slackId: profile.data.id,
+ service: 'google',
+ serviceId: profile.data.id,
teamId: team.id,
},
defaults: {
@@ -63,10 +62,6 @@ router.get('google.callback', async ctx => {
},
});
- if (!isFirstSignin) {
- await user.save();
- }
-
if (isFirstUser) {
await team.createFirstCollection(user.id);
}
@@ -77,7 +72,7 @@ router.get('google.callback', async ctx => {
});
ctx.cookies.set('accessToken', user.getJwtToken(), {
httpOnly: false,
- expires: addMonths(new Date(), 6),
+ expires: addMonths(new Date(), 1),
});
ctx.redirect('/');
diff --git a/server/auth/slack.js b/server/auth/slack.js
index dede55316..eb3eb58fc 100644
--- a/server/auth/slack.js
+++ b/server/auth/slack.js
@@ -26,7 +26,9 @@ router.post('auth.slack', async ctx => {
const data = await Slack.oauthAccess(code);
- let user = await User.findOne({ where: { slackId: data.user.id } });
+ let user = await User.findOne({
+ where: { service: 'slack', serviceId: data.user.id },
+ });
let team = await Team.findOne({ where: { slackId: data.team.id } });
const isFirstUser = !team;
@@ -48,7 +50,8 @@ router.post('auth.slack', async ctx => {
await user.save();
} else {
user = await User.create({
- slackId: data.user.id,
+ service: 'slack',
+ serviceId: data.user.id,
name: data.user.name,
email: data.user.email,
teamId: team.id,
diff --git a/server/migrations/20180528233909-google-auth.js b/server/migrations/20180528233909-google-auth.js
new file mode 100644
index 000000000..c68f85205
--- /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/models/Team.js b/server/models/Team.js
index 598af8d7b..1ce90d28b 100644
--- a/server/models/Team.js
+++ b/server/models/Team.js
@@ -13,6 +13,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,
},
{
diff --git a/server/models/User.js b/server/models/User.js
index 878757e4d..4c6e68ce9 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -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,
diff --git a/server/pages/components/SignupButton.js b/server/pages/components/SignupButton.js
index 5c6c15042..3c06a0e88 100644
--- a/server/pages/components/SignupButton.js
+++ b/server/pages/components/SignupButton.js
@@ -3,6 +3,7 @@ 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';
@@ -10,22 +11,27 @@ type Props = {
lastLoggedIn: string,
};
-const SlackSignin = ({ lastLoggedIn }: Props) => {
+const SignupButton = ({ lastLoggedIn }: Props) => {
return (
-
+
Sign In with Slack
- {lastLoggedIn === 'slack' && 'You signed in with Slack previously'}
+
+ {lastLoggedIn === 'slack' && 'You signed in with Slack previously'}
+
-
+
+
Sign In with Google
- {lastLoggedIn === 'google' && 'You signed in with Google previously'}
+
+ {lastLoggedIn === 'google' && 'You signed in with Google previously'}
+
);
@@ -43,6 +49,13 @@ const Button = styled.a`
background: ${color.black};
border-radius: 4px;
font-weight: 600;
+ height: 56px;
`;
-export default SlackSignin;
+const LastLogin = styled.p`
+ font-size: 12px;
+ color: ${color.slate};
+ padding-top: 4px;
+`;
+
+export default SignupButton;
diff --git a/server/test/factories.js b/server/test/factories.js
index b76209087..0d8b4ebeb 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 d448b44b9..826c8ffab 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/shared/components/GoogleLogo.js b/shared/components/GoogleLogo.js
new file mode 100644
index 000000000..85ffe7aba
--- /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;
From 25aa1f288bc62040a6909c8f77bdcae13d499790 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Mon, 28 May 2018 21:14:43 -0700
Subject: [PATCH 03/22] Renames, clear token, show signin options based on env
---
app/stores/AuthStore.js | 8 +--
server/auth/google.js | 2 +-
server/pages/Home.js | 14 +++--
server/pages/components/SigninButtons.js | 72 ++++++++++++++++++++++++
server/pages/components/SignupButton.js | 61 --------------------
server/routes.js | 12 +++-
6 files changed, 92 insertions(+), 77 deletions(-)
create mode 100644 server/pages/components/SigninButtons.js
delete mode 100644 server/pages/components/SignupButton.js
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index 2468787a8..468ab148d 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -29,7 +29,6 @@ class AuthStore {
return JSON.stringify({
user: this.user,
team: this.team,
- token: this.token,
oauthState: this.oauthState,
});
}
@@ -57,7 +56,7 @@ class AuthStore {
this.user = null;
this.token = null;
- Cookie.remove('loggedIn', { path: '/' });
+ Cookie.remove('accessToken', { path: '/' });
await localForage.clear();
window.location.href = BASE_URL;
};
@@ -106,7 +105,6 @@ class AuthStore {
);
this.user = res.data.user;
this.team = res.data.team;
- this.token = res.data.accessToken;
return {
success: true,
@@ -123,10 +121,8 @@ class AuthStore {
}
this.user = data.user;
this.team = data.team;
- this.token = Cookie.get('accessToken') || data.token;
- console.log('TOKEN', this.token);
-
this.oauthState = data.oauthState;
+ this.token = Cookie.get('accessToken') || data.token;
autorun(() => {
try {
diff --git a/server/auth/google.js b/server/auth/google.js
index 1f1c5f7ee..40c6479b7 100644
--- a/server/auth/google.js
+++ b/server/auth/google.js
@@ -66,7 +66,7 @@ router.get('google.callback', async ctx => {
await team.createFirstCollection(user.id);
}
- ctx.cookies.set('lastLoggedIn', 'google', {
+ ctx.cookies.set('lastSignedIn', 'google', {
httpOnly: false,
expires: new Date('2100'),
});
diff --git a/server/pages/Home.js b/server/pages/Home.js
index 945a2c952..a77f17196 100644
--- a/server/pages/Home.js
+++ b/server/pages/Home.js
@@ -5,15 +5,17 @@ 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';
type Props = {
- lastLoggedIn: string,
+ lastSignedIn: string,
+ googleSigninEnabled: boolean,
+ slackSigninEnabled: boolean,
};
-function Home({ lastLoggedIn }: Props) {
+function Home(props: Props) {
return (
@@ -27,7 +29,7 @@ function Home({ lastLoggedIn }: Props) {
logs, brainstorming, & more…
-
+
@@ -94,10 +96,10 @@ function Home({ lastLoggedIn }: Props) {
diff --git a/server/pages/components/SigninButtons.js b/server/pages/components/SigninButtons.js
new file mode 100644
index 000000000..acd84f0d3
--- /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 && (
+
+
+
+ Sign In with Slack
+
+
+ {lastSignedIn === 'slack' && 'You signed in with Slack previously'}
+
+
+ )}
+
+ {googleSigninEnabled && (
+
+
+
+ Sign In with Google
+
+
+ {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 3c06a0e88..000000000
--- a/server/pages/components/SignupButton.js
+++ /dev/null
@@ -1,61 +0,0 @@
-// @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 = {
- lastLoggedIn: string,
-};
-
-const SignupButton = ({ lastLoggedIn }: Props) => {
- return (
-
-
-
-
- Sign In with Slack
-
-
- {lastLoggedIn === 'slack' && 'You signed in with Slack previously'}
-
-
-
-
-
-
- Sign In with Google
-
-
- {lastLoggedIn === '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 SignupButton;
diff --git a/server/routes.js b/server/routes.js
index 1c05f47b5..583209887 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -62,14 +62,20 @@ router.get('/changelog', async ctx => {
// home page
router.get('/', async ctx => {
- const lastLoggedIn = ctx.cookies.get('lastLoggedIn');
+ const lastSignedIn = ctx.cookies.get('lastSignedIn');
const accessToken = ctx.cookies.get('accessToken');
- console.log(lastLoggedIn, accessToken);
if (accessToken) {
await renderapp(ctx);
} else {
- await renderpage(ctx, );
+ await renderpage(
+ ctx,
+
+ );
}
});
From aa9ed09f084fa6572a2146917f59e61fadce8c65 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Mon, 28 May 2018 22:32:36 -0700
Subject: [PATCH 04/22] Prevent signin without hosted domain
---
.env.sample | 2 +-
app/stores/AuthStore.js | 3 +++
index.js | 2 +-
server/auth/google.js | 5 +++++
server/pages/Home.js | 14 ++++++++++++++
server/routes.js | 1 +
6 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/.env.sample b/.env.sample
index 46a6b14dc..8a79a2171 100644
--- a/.env.sample
+++ b/.env.sample
@@ -14,7 +14,7 @@ DEPLOYMENT=self
ENABLE_UPDATES=true
DEBUG=sql,cache,presenters,events
-# Slack signin credentials (at least one is required)
+# Third party signin credentials (at least one is required)
SLACK_KEY=71315967491.XXXXXXXXXX
SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index 468ab148d..c1f10559d 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -122,6 +122,9 @@ class AuthStore {
this.user = data.user;
this.team = data.team;
this.oauthState = data.oauthState;
+
+ // load token from state for backwards compatability with
+ // sessions created pre-google auth
this.token = Cookie.get('accessToken') || data.token;
autorun(() => {
diff --git a/index.js b/index.js
index a04608b66..78a3c4dbc 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/server/auth/google.js b/server/auth/google.js
index 40c6479b7..47b358d4f 100644
--- a/server/auth/google.js
+++ b/server/auth/google.js
@@ -37,6 +37,11 @@ router.get('google.callback', async ctx => {
url: 'https://www.googleapis.com/oauth2/v1/userinfo',
});
+ if (!profile.data.hd) {
+ ctx.redirect('/?notice=google-hd');
+ return;
+ }
+
const teamName = capitalize(profile.data.hd.split('.')[0]);
const [team, isFirstUser] = await Team.findOrCreate({
where: {
diff --git a/server/pages/Home.js b/server/pages/Home.js
index a77f17196..43ff10962 100644
--- a/server/pages/Home.js
+++ b/server/pages/Home.js
@@ -10,6 +10,7 @@ import { developers, githubUrl } from '../../shared/utils/routeHelpers';
import { color } from '../../shared/styles/constants';
type Props = {
+ notice?: 'google-hd',
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
@@ -31,6 +32,12 @@ function Home(props: Props) {
+ {props.notice === 'google-hd' && (
+
+ Sorry, Google sign in cannot be used with a personal email. Please
+ try signing in with your company Google account.
+
+ )}
@@ -107,6 +114,13 @@ function Home(props: Props) {
);
}
+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/routes.js b/server/routes.js
index 583209887..bf50d5ab4 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -71,6 +71,7 @@ router.get('/', async ctx => {
await renderpage(
ctx,
Date: Mon, 28 May 2018 22:58:57 -0700
Subject: [PATCH 05/22] Settings -> Profile
---
app/components/Sidebar/Settings.js | 3 +++
app/scenes/Settings/{Settings.js => Profile.js} | 4 ++--
app/scenes/Settings/index.js | 4 ++--
3 files changed, 7 insertions(+), 4 deletions(-)
rename app/scenes/Settings/{Settings.js => Profile.js} (97%)
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js
index 2f20c6d70..1ba89eae2 100644
--- a/app/components/Sidebar/Settings.js
+++ b/app/components/Sidebar/Settings.js
@@ -54,6 +54,9 @@ class SettingsSidebar extends React.Component {
+ }>
+ Details
+
}>
Members
diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Profile.js
similarity index 97%
rename from app/scenes/Settings/Settings.js
rename to app/scenes/Settings/Profile.js
index aeeedb5f9..80c3b81ea 100644
--- a/app/scenes/Settings/Settings.js
+++ b/app/scenes/Settings/Profile.js
@@ -22,7 +22,7 @@ type Props = {
};
@observer
-class Settings extends React.Component {
+class Profile extends React.Component {
timeout: TimeoutID;
@observable name: string;
@@ -166,4 +166,4 @@ const StyledInput = styled(Input)`
max-width: 350px;
`;
-export default inject('auth', 'errors')(Settings);
+export default inject('auth', 'errors')(Profile);
diff --git a/app/scenes/Settings/index.js b/app/scenes/Settings/index.js
index c0492da1c..c1e3920e0 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;
From 4a7f8d3895cb108167f15786b7f226448f4c8f29 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Mon, 28 May 2018 23:44:56 -0700
Subject: [PATCH 06/22] Move slack auth handling entirely to server
---
app/index.js | 10 ----
app/scenes/ErrorAuth/ErrorAuth.js | 23 --------
app/scenes/ErrorAuth/index.js | 3 -
server/auth/slack.js | 93 ++++++++++++++-----------------
server/pages/Home.js | 8 ++-
server/slack.js | 2 +-
shared/utils/routeHelpers.js | 2 +-
7 files changed, 50 insertions(+), 91 deletions(-)
delete mode 100644 app/scenes/ErrorAuth/ErrorAuth.js
delete mode 100644 app/scenes/ErrorAuth/index.js
diff --git a/app/index.js b/app/index.js
index f90af9838..25f0bd6ac 100644
--- a/app/index.js
+++ b/app/index.js
@@ -25,8 +25,6 @@ import Members from 'scenes/Settings/Members';
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 +59,6 @@ if (element) {
-
-
-
-
diff --git a/app/scenes/ErrorAuth/ErrorAuth.js b/app/scenes/ErrorAuth/ErrorAuth.js
deleted file mode 100644
index 97e19f2e5..000000000
--- a/app/scenes/ErrorAuth/ErrorAuth.js
+++ /dev/null
@@ -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 (
-
-
- Authentication failed
-
-
- We were unable to log you in. Please try again.
-
-
- );
- }
-}
-
-export default ErrorAuth;
diff --git a/app/scenes/ErrorAuth/index.js b/app/scenes/ErrorAuth/index.js
deleted file mode 100644
index 0e961642b..000000000
--- a/app/scenes/ErrorAuth/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-import ErrorAuth from './ErrorAuth';
-export default ErrorAuth;
diff --git a/server/auth/slack.js b/server/auth/slack.js
index eb3eb58fc..ac1eb89fa 100644
--- a/server/auth/slack.js
+++ b/server/auth/slack.js
@@ -1,97 +1,86 @@
// @flow
import Router from 'koa-router';
+import addHours from 'date-fns/add_hours';
+import addMonths from 'date-fns/add_months';
import auth from '../middlewares/authentication';
import { slackAuth } from '../../shared/utils/routeHelpers';
-import { presentUser, presentTeam } from '../presenters';
import { Authentication, Integration, User, Team } from '../models';
import * as Slack from '../slack';
const router = new Router();
-router.get('auth.slack', async ctx => {
+// 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: new Date('2100'),
+ expires: addHours(new Date(), 1),
});
ctx.redirect(slackAuth(state));
});
-router.post('auth.slack', async ctx => {
- const { code } = ctx.body;
- ctx.assertPresent(code, 'code is required');
+// 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);
- let user = await User.findOne({
- where: { service: 'slack', serviceId: 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,
+ const [team, isFirstUser] = await Team.findOrCreate({
+ where: {
slackId: data.team.id,
- slackData: data.team,
- });
- }
+ },
+ defaults: {
+ name: data.team.name,
+ avatarUrl: data.team.image_88,
+ },
+ });
- if (user) {
- user.slackAccessToken = data.access_token;
- user.slackData = data.user;
- await user.save();
- } else {
- user = await User.create({
+ const [user] = await User.findOrCreate({
+ where: {
service: 'slack',
serviceId: data.user.id,
+ teamId: team.id,
+ },
+ defaults: {
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();
- }
+ avatarUrl: data.user.image_192,
+ },
+ });
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', {
+ 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.body = {
- data: {
- user: await presentUser(ctx, user),
- team: await presentTeam(ctx, team),
- accessToken: user.getJwtToken(),
- },
- };
+ ctx.redirect('/');
});
-router.post('auth.slackCommands', auth(), async ctx => {
+router.post('slack.commands', 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 endpoint = `${process.env.URL || ''}/auth/slack.commands`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
@@ -112,12 +101,12 @@ router.post('auth.slackCommands', auth(), async ctx => {
});
});
-router.post('auth.slackPost', auth(), async ctx => {
+router.post('slack.post', 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 endpoint = `${process.env.URL || ''}/auth/slack.post`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
diff --git a/server/pages/Home.js b/server/pages/Home.js
index 43ff10962..43511c561 100644
--- a/server/pages/Home.js
+++ b/server/pages/Home.js
@@ -10,7 +10,7 @@ import { developers, githubUrl } from '../../shared/utils/routeHelpers';
import { color } from '../../shared/styles/constants';
type Props = {
- notice?: 'google-hd',
+ notice?: 'google-hd' | 'auth-error',
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
@@ -38,6 +38,12 @@ function Home(props: Props) {
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.
+
+ )}
diff --git a/server/slack.js b/server/slack.js
index 2d06d26aa..d33ebf485 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/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js
index a9c7772a9..ff05000de 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 = {
From 57aaea60da2ad093e8c9dc69e4492b4ee25b203c Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Tue, 29 May 2018 22:18:11 -0700
Subject: [PATCH 07/22] Fix logout loop
---
app/components/Auth.js | 23 ++++++++++++-----------
app/stores/AuthStore.js | 6 +++++-
2 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/app/components/Auth.js b/app/components/Auth.js
index 81388c011..e677673ab 100644
--- a/app/components/Auth.js
+++ b/app/components/Auth.js
@@ -1,33 +1,34 @@
// @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.fetch();
+const Auth = observer(({ auth, children }: Props) => {
+ if (auth.authenticated) {
+ const { user, team } = auth;
- // TODO: Show loading state
- if (!stores.auth.team || !stores.auth.user) {
- return null;
+ 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(),
@@ -55,8 +56,8 @@ const Auth = ({ children }: Props) => {
return {children} ;
}
- stores.auth.logout();
+ auth.logout();
return null;
-};
+});
-export default Auth;
+export default inject('auth')(Auth);
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index c1f10559d..9cc55d230 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -58,7 +58,9 @@ class AuthStore {
Cookie.remove('accessToken', { path: '/' });
await localForage.clear();
- window.location.href = BASE_URL;
+
+ // add a timestamp to force reload from server
+ window.location.href = `${BASE_URL}?done=${new Date().getTime()}`;
};
@action
@@ -127,6 +129,8 @@ class AuthStore {
// sessions created pre-google auth
this.token = Cookie.get('accessToken') || data.token;
+ if (this.token) setImmediate(() => this.fetch());
+
autorun(() => {
try {
localStorage.setItem(AUTH_STORE, this.asJson);
From 55e145116020f53995bbb0c32ad37a68ff1e9a5a Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Tue, 29 May 2018 23:33:30 -0700
Subject: [PATCH 08/22] Slack commands and post working agagain with new flow
---
app/scenes/Settings/Slack.js | 4 +-
app/scenes/Settings/components/SlackButton.js | 6 +-
app/scenes/SlackAuth/SlackAuth.js | 80 -------------------
app/scenes/SlackAuth/index.js | 3 -
app/stores/AuthStore.js | 53 ------------
server/auth/slack.js | 37 ++++++---
6 files changed, 27 insertions(+), 156 deletions(-)
delete mode 100644 app/scenes/SlackAuth/SlackAuth.js
delete mode 100644 app/scenes/SlackAuth/index.js
diff --git a/app/scenes/Settings/Slack.js b/app/scenes/Settings/Slack.js
index 188972bdf..99a2ce114 100644
--- a/app/scenes/Settings/Slack.js
+++ b/app/scenes/Settings/Slack.js
@@ -47,7 +47,7 @@ class Slack extends React.Component {
) : (
)}
@@ -83,7 +83,7 @@ class Slack extends React.Component {
{collection.name}
diff --git a/app/scenes/Settings/components/SlackButton.js b/app/scenes/Settings/components/SlackButton.js
index 241e6ed60..6923317f1 100644
--- a/app/scenes/Settings/components/SlackButton.js
+++ b/app/scenes/Settings/components/SlackButton.js
@@ -17,11 +17,7 @@ type Props = {
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/SlackAuth/SlackAuth.js b/app/scenes/SlackAuth/SlackAuth.js
deleted file mode 100644
index dd942d88b..000000000
--- 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 cf1b93d0d..000000000
--- 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 9cc55d230..c764e482f 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -12,7 +12,6 @@ class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
- @observable oauthState: string;
@observable isLoading: boolean = false;
@observable isSuspended: boolean = false;
@observable suspendedContactEmail: ?string;
@@ -29,7 +28,6 @@ class AuthStore {
return JSON.stringify({
user: this.user,
team: this.team,
- oauthState: this.oauthState,
});
}
@@ -63,56 +61,6 @@ class AuthStore {
window.location.href = `${BASE_URL}?done=${new Date().getTime()}`;
};
- @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;
-
- return {
- success: true,
- };
- };
-
constructor() {
// Rehydrate
let data = {};
@@ -123,7 +71,6 @@ class AuthStore {
}
this.user = data.user;
this.team = data.team;
- this.oauthState = data.oauthState;
// load token from state for backwards compatability with
// sessions created pre-google auth
diff --git a/server/auth/slack.js b/server/auth/slack.js
index ac1eb89fa..eabd20af2 100644
--- a/server/auth/slack.js
+++ b/server/auth/slack.js
@@ -2,7 +2,6 @@
import Router from 'koa-router';
import addHours from 'date-fns/add_hours';
import addMonths from 'date-fns/add_months';
-import auth from '../middlewares/authentication';
import { slackAuth } from '../../shared/utils/routeHelpers';
import { Authentication, Integration, User, Team } from '../models';
import * as Slack from '../slack';
@@ -75,17 +74,19 @@ router.get('slack.callback', async ctx => {
ctx.redirect('/');
});
-router.post('slack.commands', auth(), async ctx => {
- const { code } = ctx.body;
+router.get('slack.commands', async ctx => {
+ const { code } = ctx.request.query;
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 user = await User.find({
+ service: 'slack',
+ serviceId: data.user_id,
+ });
const authentication = await Authentication.create({
- serviceId,
+ serviceId: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -93,25 +94,33 @@ router.post('slack.commands', auth(), async ctx => {
});
await Integration.create({
- serviceId,
+ serviceId: 'slack',
type: 'command',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
});
+
+ ctx.redirect('/settings/integrations/slack');
});
-router.post('slack.post', auth(), async ctx => {
- const { code, collectionId } = ctx.body;
+router.get('slack.post', async ctx => {
+ const { code, state } = ctx.request.query;
ctx.assertPresent(code, 'code is required');
- const user = ctx.state.user;
+ 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 serviceId = 'slack';
+
+ const user = await User.find({
+ service: 'slack',
+ serviceId: data.user_id,
+ });
const authentication = await Authentication.create({
- serviceId,
+ serviceId: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -119,7 +128,7 @@ router.post('slack.post', auth(), async ctx => {
});
await Integration.create({
- serviceId,
+ serviceId: 'slack',
type: 'post',
userId: user.id,
teamId: user.teamId,
@@ -132,6 +141,8 @@ router.post('slack.post', auth(), async ctx => {
channelId: data.incoming_webhook.channel_id,
},
});
+
+ ctx.redirect('/settings/integrations/slack');
});
export default router;
From f633f63a6115e97527c0dd0be0db1132d1e2c92f Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 11:42:39 -0700
Subject: [PATCH 09/22] Merge ErrorsStore into UiStore
---
app/components/Auth.js | 4 ++-
app/components/Toasts/Toasts.js | 8 ++---
app/models/Collection.js | 12 ++++----
app/models/Collection.test.js | 17 -----------
app/models/Document.js | 26 ++++++++--------
app/models/Integration.js | 10 +++----
app/scenes/Settings/Profile.js | 53 +++++++--------------------------
app/stores/AuthStore.js | 10 +++++++
app/stores/CollectionsStore.js | 7 ++---
app/stores/DocumentsStore.js | 8 ++---
app/stores/ErrorsStore.js | 20 -------------
app/stores/ErrorsStore.test.js | 27 -----------------
app/stores/IntegrationsStore.js | 11 ++++---
app/stores/UiStore.js | 11 +++++++
app/stores/UiStore.test.js | 27 +++++++++++++++++
app/stores/index.js | 5 +---
package.json | 2 +-
webpack.config.js | 5 +++-
18 files changed, 105 insertions(+), 158 deletions(-)
delete mode 100644 app/stores/ErrorsStore.js
delete mode 100644 app/stores/ErrorsStore.test.js
create mode 100644 app/stores/UiStore.test.js
diff --git a/app/components/Auth.js b/app/components/Auth.js
index e677673ab..8952f87c4 100644
--- a/app/components/Auth.js
+++ b/app/components/Auth.js
@@ -31,7 +31,9 @@ const Auth = observer(({ auth, children }: Props) => {
// Stores for authenticated user
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({
diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts/Toasts.js
index 3d7656aa2..2e5ff3334 100644
--- a/app/components/Toasts/Toasts.js
+++ b/app/components/Toasts/Toasts.js
@@ -8,15 +8,15 @@ import Toast from './components/Toast';
@observer
class Toasts extends React.Component<*> {
handleClose = index => {
- this.props.errors.remove(index);
+ this.props.ui.remove(index);
};
render() {
- const { errors } = this.props;
+ const { ui } = this.props;
return (
- {errors.data.map((error, index) => (
+ {ui.toasts.map((error, index) => (
{
if (data.collectionId === this.id) this.fetch();
diff --git a/app/models/Collection.test.js b/app/models/Collection.test.js
index 22d0cdf4b..5af3f6734 100644
--- a/app/models/Collection.test.js
+++ b/app/models/Collection.test.js
@@ -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'
- );
- });
});
});
diff --git a/app/models/Document.js b/app/models/Document.js
index ba17afbe6..d80ddd3d0 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -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;
@@ -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;
}
}
diff --git a/app/models/Integration.js b/app/models/Integration.js
index 415e2f625..b1f67b34e 100644
--- a/app/models/Integration.js
+++ b/app/models/Integration.js
@@ -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,7 +15,7 @@ type Settings = {
type Events = 'documents.create' | 'collections.create';
class Integration extends BaseModel {
- errors: ErrorsStore;
+ ui: UiStore;
id: string;
serviceId: string;
@@ -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;
}
}
diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js
index 80c3b81ea..86a8adbe2 100644
--- a/app/scenes/Settings/Profile.js
+++ b/app/scenes/Settings/Profile.js
@@ -1,14 +1,11 @@
// @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 ImageUpload from './components/ImageUpload';
import Input, { LabelText } from 'components/Input';
import Button from 'components/Button';
@@ -18,7 +15,6 @@ import Flex from 'shared/components/Flex';
type Props = {
auth: AuthStore,
- errors: ErrorsStore,
};
@observer
@@ -27,8 +23,6 @@ class Profile extends React.Component {
@observable name: string;
@observable avatarUrl: ?string;
- @observable isUpdated: boolean;
- @observable isSaving: boolean;
componentDidMount() {
if (this.props.auth.user) {
@@ -42,25 +36,11 @@ class Profile extends React.Component {
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,
+ });
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@@ -72,7 +52,7 @@ class Profile extends React.Component {
};
handleAvatarError = (error: ?string) => {
- this.props.errors.add(error || 'Unable to upload new avatar');
+ this.props.ui.showToast(error || 'Unable to upload new avatar');
};
render() {
@@ -85,7 +65,7 @@ class Profile extends React.Component {
Profile
- Profile picture
+ Picture
{
Save
-
- Profile updated!
-
);
}
}
-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;
- border-radius: 50%;
+ width: 80px;
+ height: 80px;
+ border-radius: 10px;
`;
const AvatarContainer = styled(Flex)`
@@ -166,4 +135,4 @@ const StyledInput = styled(Input)`
max-width: 350px;
`;
-export default inject('auth', 'errors')(Profile);
+export default inject('auth', 'ui')(Profile);
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index c764e482f..b21550699 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -49,6 +49,16 @@ class AuthStore {
}
};
+ @action
+ updateUser = async (params: { name: string, avatarUrl?: string }) => {
+ const res = await client.post(`/user.update`, params);
+ invariant(res && res.data, 'User response not available');
+
+ runInAction('AuthStore#updateUser', () => {
+ this.user = res.data.user;
+ });
+ };
+
@action
logout = async () => {
this.user = null;
diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js
index 50b954455..bc2e84ea1 100644
--- a/app/stores/CollectionsStore.js
+++ b/app/stores/CollectionsStore.js
@@ -6,7 +6,6 @@ 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 +31,6 @@ class CollectionsStore extends BaseStore {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
- errors: ErrorsStore;
ui: UiStore;
@computed
@@ -106,7 +104,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 +132,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 +154,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 08ce80044..63452b5a2 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 eac503e7c..000000000
--- 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 daa72a016..000000000
--- 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 02a1fd223..5bc4a3912 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[] {
@@ -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 109ee725e..7caff9834 100644
--- a/app/stores/UiStore.js
+++ b/app/stores/UiStore.js
@@ -11,6 +11,7 @@ class UiStore {
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable mobileSidebarVisible: boolean = false;
+ @observable toasts: string[] = observable.array([]);
/* Actions */
@action
@@ -79,6 +80,16 @@ class UiStore {
hideMobileSidebar() {
this.mobileSidebarVisible = false;
}
+
+ @action
+ showToast = (message: string): void => {
+ this.toasts.push(message);
+ };
+
+ @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 000000000..899e6670a
--- /dev/null
+++ b/app/stores/UiStore.test.js
@@ -0,0 +1,27 @@
+/* eslint-disable */
+import UiStore from './UiStore';
+
+// Actions
+describe('UiStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new UiStore();
+ });
+
+ test('#add should add errors', () => {
+ expect(store.data.length).toBe(0);
+ store.showToast('first error');
+ store.showToast('second error');
+ expect(store.toasts.length).toBe(2);
+ });
+
+ test('#remove should remove errors', () => {
+ 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]).toBe('second error');
+ });
+});
diff --git a/app/stores/index.js b/app/stores/index.js
index 68d49ca9c..7065b4561 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/package.json b/package.json
index 40ef2e469..7e4dda922 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json > stats.json",
"build": "npm run clean && npm run build:webpack",
"start": "NODE_ENV=production node index.js",
- "dev": "NODE_ENV=development nodemon --watch server index.js",
+ "dev": "NODE_ENV=development node index.js",
"lint": "npm run lint:flow && npm run lint:js",
"lint:js": "eslint app server",
"lint:flow": "flow",
diff --git a/webpack.config.js b/webpack.config.js
index 0c7bc6997..cc67d1a7b 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
From fb7a8f03120d95a0aa7dd7a5c491a4d085ff1438 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 12:07:49 -0700
Subject: [PATCH 10/22] Toast type (success/warning/etc)
---
app/components/Layout/Layout.js | 2 +-
app/components/Toasts/Toasts.js | 19 ++++++++++++-------
app/components/Toasts/components/Toast.js | 15 +++++++--------
app/menus/ShareMenu.js | 2 +-
app/scenes/Settings/Profile.js | 13 ++++++++-----
app/scenes/Settings/components/SlackButton.js | 2 +-
app/stores/AuthStore.js | 19 +++++++++++++------
app/stores/CollectionsStore.js | 1 -
app/stores/UiStore.js | 10 +++++++---
app/types/index.js | 5 +++++
shared/styles/constants.js | 6 +++---
11 files changed, 58 insertions(+), 36 deletions(-)
diff --git a/app/components/Layout/Layout.js b/app/components/Layout/Layout.js
index 17e316ba3..99b7a4ed5 100644
--- a/app/components/Layout/Layout.js
+++ b/app/components/Layout/Layout.js
@@ -112,7 +112,7 @@ class Layout extends React.Component {
-
+
);
}
diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts/Toasts.js
index 2e5ff3334..efe25a437 100644
--- a/app/components/Toasts/Toasts.js
+++ b/app/components/Toasts/Toasts.js
@@ -1,14 +1,18 @@
// @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.ui.remove(index);
+class Toasts extends React.Component {
+ handleClose = (index: number) => {
+ this.props.ui.removeToast(index);
};
render() {
@@ -16,11 +20,11 @@ class Toasts extends React.Component<*> {
return (
- {ui.toasts.map((error, index) => (
+ {ui.toasts.map((toast, index) => (
))}
@@ -35,6 +39,7 @@ const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
+ z-index: 1000;
`;
-export default inject('ui')(Toasts);
+export default Toasts;
diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toasts/components/Toast.js
index 6efc2bd91..04bbc84f6 100644
--- a/app/components/Toasts/components/Toast.js
+++ b/app/components/Toasts/components/Toast.js
@@ -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 {
@@ -17,7 +17,6 @@ class Toast extends React.Component {
static defaultProps = {
closeAfterMs: 3000,
- type: 'warning',
};
componentDidMount() {
@@ -32,14 +31,14 @@ class Toast extends React.Component {
}
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 (
-
+
{message}
);
diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js
index 67c3b18f0..c2708d025 100644
--- a/app/menus/ShareMenu.js
+++ b/app/menus/ShareMenu.js
@@ -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';
diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js
index 86a8adbe2..15682737d 100644
--- a/app/scenes/Settings/Profile.js
+++ b/app/scenes/Settings/Profile.js
@@ -6,6 +6,7 @@ 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';
@@ -15,6 +16,7 @@ import Flex from 'shared/components/Flex';
type Props = {
auth: AuthStore,
+ ui: UiStore,
};
@observer
@@ -41,6 +43,7 @@ class Profile extends React.Component {
name: this.name,
avatarUrl: this.avatarUrl,
});
+ this.props.ui.showToast('Profile saved', 'success');
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@@ -56,7 +59,7 @@ class Profile extends React.Component {
};
render() {
- const { user } = this.props.auth;
+ const { user, isSaving } = this.props.auth;
if (!user) return null;
const avatarUrl = this.avatarUrl || user.avatarUrl;
@@ -73,7 +76,7 @@ class Profile extends React.Component {
>
- Upload new image
+ Upload
@@ -85,8 +88,8 @@ class Profile extends React.Component {
onChange={this.handleNameChange}
required
/>
-
- Save
+
+ {isSaving ? 'Saving…' : 'Save'}
@@ -101,7 +104,7 @@ const ProfilePicture = styled(Flex)`
const avatarStyles = `
width: 80px;
height: 80px;
- border-radius: 10px;
+ border-radius: 50%;
`;
const AvatarContainer = styled(Flex)`
diff --git a/app/scenes/Settings/components/SlackButton.js b/app/scenes/Settings/components/SlackButton.js
index 6923317f1..cffdd5652 100644
--- a/app/scenes/Settings/components/SlackButton.js
+++ b/app/scenes/Settings/components/SlackButton.js
@@ -11,7 +11,7 @@ type Props = {
auth: AuthStore,
scopes?: string[],
redirectUri?: string,
- state?: string,
+ state: string,
label?: string,
};
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index b21550699..16decad9c 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -12,6 +12,7 @@ class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
+ @observable isSaving: boolean = false;
@observable isLoading: boolean = false;
@observable isSuspended: boolean = false;
@observable suspendedContactEmail: ?string;
@@ -50,13 +51,19 @@ class AuthStore {
};
@action
- updateUser = async (params: { name: string, avatarUrl?: string }) => {
- const res = await client.post(`/user.update`, params);
- invariant(res && res.data, 'User response not available');
+ updateUser = async (params: { name: string, avatarUrl: ?string }) => {
+ this.isSaving = true;
- runInAction('AuthStore#updateUser', () => {
- this.user = res.data.user;
- });
+ 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
diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js
index bc2e84ea1..d23f610e1 100644
--- a/app/stores/CollectionsStore.js
+++ b/app/stores/CollectionsStore.js
@@ -4,7 +4,6 @@ import { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
-import stores from 'stores';
import BaseStore from './BaseStore';
import UiStore from './UiStore';
import Collection from 'models/Collection';
diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js
index 7caff9834..c5477a11b 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,7 +12,7 @@ class UiStore {
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable mobileSidebarVisible: boolean = false;
- @observable toasts: string[] = observable.array([]);
+ @observable toasts: Toast[] = observable.array([]);
/* Actions */
@action
@@ -82,8 +83,11 @@ class UiStore {
}
@action
- showToast = (message: string): void => {
- this.toasts.push(message);
+ showToast = (
+ message: string,
+ type?: 'warning' | 'error' | 'info' | 'success' = 'warning'
+ ): void => {
+ this.toasts.push({ message, type });
};
@action
diff --git a/app/types/index.js b/app/types/index.js
index 2ef769b57..f0824f8d9 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,
diff --git a/shared/styles/constants.js b/shared/styles/constants.js
index 303d6beee..3615b0736 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 */
From 10a0ffe472b8d5b3dc8f56ac5f59e3efd7311446 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 12:44:32 -0700
Subject: [PATCH 11/22] Team details settings page
---
app/components/Input/Input.js | 8 +-
app/index.js | 6 ++
app/menus/ShareMenu.js | 2 +-
app/scenes/Settings/Details.js | 145 +++++++++++++++++++++++++++++++++
app/scenes/Settings/Profile.js | 16 ++--
app/stores/AuthStore.js | 16 ++++
package.json | 2 +-
server/api/team.js | 24 +++++-
server/api/user.js | 7 +-
server/policies/collection.js | 3 +-
server/policies/index.js | 1 +
server/policies/team.js | 14 ++++
12 files changed, 226 insertions(+), 18 deletions(-)
create mode 100644 app/scenes/Settings/Details.js
create mode 100644 server/policies/team.js
diff --git a/app/components/Input/Input.js b/app/components/Input/Input.js
index 05e6cf8fb..0e2ed8688 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 (
-
+
{label && {label} }
diff --git a/app/index.js b/app/index.js
index 25f0bd6ac..2dc093536 100644
--- a/app/index.js
+++ b/app/index.js
@@ -21,6 +21,7 @@ import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
+import Details from 'scenes/Settings/Details';
import Members from 'scenes/Settings/Members';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
@@ -67,6 +68,11 @@ if (element) {
+
*,
- onClose?: () => *,
+ onClose: () => *,
history: Object,
shares: SharesStore,
share: Share,
diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js
new file mode 100644
index 000000000..4aa0b283a
--- /dev/null
+++ b/app/scenes/Settings/Details.js
@@ -0,0 +1,145 @@
+// @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 Flex from 'shared/components/Flex';
+
+type Props = {
+ auth: AuthStore,
+ ui: UiStore,
+};
+
+@observer
+class Details extends React.Component {
+ 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 (
+
+
+ Details
+
+ Logo
+
+
+
+
+ Upload
+
+
+
+
+
+
+ );
+ }
+}
+
+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);
diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js
index 15682737d..11cbe2062 100644
--- a/app/scenes/Settings/Profile.js
+++ b/app/scenes/Settings/Profile.js
@@ -22,6 +22,7 @@ type Props = {
@observer
class Profile extends React.Component {
timeout: TimeoutID;
+ form: ?HTMLFormElement;
@observable name: string;
@observable avatarUrl: ?string;
@@ -58,6 +59,10 @@ class Profile extends React.Component {
this.props.ui.showToast(error || 'Unable to upload new avatar');
};
+ get isValid() {
+ return this.form && this.form.checkValidity();
+ }
+
render() {
const { user, isSaving } = this.props.auth;
if (!user) return null;
@@ -81,14 +86,15 @@ class Profile extends React.Component {
-
@@ -134,8 +140,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
-const StyledInput = styled(Input)`
- max-width: 350px;
-`;
-
export default inject('auth', 'ui')(Profile);
diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js
index 16decad9c..526210254 100644
--- a/app/stores/AuthStore.js
+++ b/app/stores/AuthStore.js
@@ -66,6 +66,22 @@ class AuthStore {
}
};
+ @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;
diff --git a/package.json b/package.json
index 7e4dda922..40ef2e469 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json > stats.json",
"build": "npm run clean && npm run build:webpack",
"start": "NODE_ENV=production node index.js",
- "dev": "NODE_ENV=development node index.js",
+ "dev": "NODE_ENV=development nodemon --watch server index.js",
"lint": "npm run lint:flow && npm run lint:js",
"lint:js": "eslint app server",
"lint:flow": "flow",
diff --git a/server/api/team.js b/server/api/team.js
index d41bac406..d04c729ca 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 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/user.js b/server/api/user.js
index 4cadd2d04..0272b3c55 100644
--- a/server/api/user.js
+++ b/server/api/user.js
@@ -21,11 +21,10 @@ 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) };
diff --git a/server/policies/collection.js b/server/policies/collection.js
index 29ae2dbf5..fb10e6c39 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 2e1492403..147ab4fad 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 000000000..45a91ddb1
--- /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();
+});
From 0b3feef47a70e793bc94e817a322abad3a988bcb Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 12:52:03 -0700
Subject: [PATCH 12/22] Hide settings items for non admins Update image
uploader for use with team logo Fix can't cancel cropping modal
---
app/components/Sidebar/Settings.js | 26 +++++-----
app/scenes/Settings/Details.js | 2 +
app/scenes/Settings/components/ImageUpload.js | 47 ++++++++++++-------
3 files changed, 48 insertions(+), 27 deletions(-)
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js
index 1ba89eae2..20d4aed1d 100644
--- a/app/components/Sidebar/Settings.js
+++ b/app/components/Sidebar/Settings.js
@@ -29,8 +29,8 @@ class SettingsSidebar extends React.Component {
};
render() {
- const { team } = this.props.auth;
- if (!team) return;
+ const { team, user } = this.props.auth;
+ if (!team || !user) return;
return (
@@ -54,21 +54,25 @@ class SettingsSidebar extends React.Component {
- }>
- Details
-
+ {user.isAdmin && (
+ }>
+ Details
+
+ )}
}>
Members
}>
Share Links
- }
- >
- Integrations
-
+ {user.isAdmin && (
+ }
+ >
+ Integrations
+
+ )}
diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js
index 4aa0b283a..48bb52f1d 100644
--- a/app/scenes/Settings/Details.js
+++ b/app/scenes/Settings/Details.js
@@ -78,6 +78,8 @@ class Details extends React.Component {
diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js
index b3ca7a81a..6c4522719 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);
} 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}
+
+ );
}
}
From da9477667cb7678adb1380c4b6706c92d31836e3 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 12:57:04 -0700
Subject: [PATCH 13/22] user.update endpoint should send full user in response
---
server/api/user.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/api/user.js b/server/api/user.js
index 0272b3c55..0ab1d9a7c 100644
--- a/server/api/user.js
+++ b/server/api/user.js
@@ -27,7 +27,7 @@ router.post('user.update', auth(), async ctx => {
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 => {
From 9315e3f0f029908dada074482b527ed0b3044809 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 31 May 2018 14:49:19 -0700
Subject: [PATCH 14/22] Members -> People Update help text throughout settings
area Add details on authentication method
---
app/components/Sidebar/Settings.js | 4 ++--
app/index.js | 8 ++------
app/scenes/Settings/Details.js | 14 ++++++++++++++
app/scenes/Settings/{Members.js => People.js} | 14 ++++++++++----
app/scenes/Settings/Shares.js | 7 +++++++
app/scenes/Settings/Tokens.js | 5 +++--
app/types/index.js | 2 ++
server/presenters/team.js | 2 ++
8 files changed, 42 insertions(+), 14 deletions(-)
rename app/scenes/Settings/{Members.js => People.js} (70%)
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js
index 20d4aed1d..d9863100e 100644
--- a/app/components/Sidebar/Settings.js
+++ b/app/components/Sidebar/Settings.js
@@ -59,8 +59,8 @@ class SettingsSidebar extends React.Component {
Details
)}
- }>
- Members
+ }>
+ People
}>
Share Links
diff --git a/app/index.js b/app/index.js
index 2dc093536..c4443c7ea 100644
--- a/app/index.js
+++ b/app/index.js
@@ -22,7 +22,7 @@ import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import Details from 'scenes/Settings/Details';
-import Members from 'scenes/Settings/Members';
+import People from 'scenes/Settings/People';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens';
@@ -73,11 +73,7 @@ if (element) {
path="/settings/details"
component={Details}
/>
-
+
{
Details
+ {team.slackConnected && (
+
+ This team is connected to a Slack team. Your
+ colleagues can join by signing in with their Slack account details.
+
+ )}
+ {team.googleConnected && (
+
+ This team is connected to a Google domain. Your
+ colleagues can join by signing in with their Google account.
+
+ )}
+
Logo
diff --git a/app/scenes/Settings/Members.js b/app/scenes/Settings/People.js
similarity index 70%
rename from app/scenes/Settings/Members.js
rename to app/scenes/Settings/People.js
index 2155b73b1..0f00999f6 100644
--- a/app/scenes/Settings/Members.js
+++ b/app/scenes/Settings/People.js
@@ -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 {
+class People extends React.Component {
componentDidMount() {
this.props.users.fetchPage({ limit: 100 });
}
@@ -28,8 +29,13 @@ class Members extends React.Component {
return (
-
- Members
+
+ People
+
+ 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.
+
{users.data.map(user => (
@@ -45,4 +51,4 @@ class Members extends React.Component {
}
}
-export default inject('auth', 'users')(Members);
+export default inject('auth', 'users')(People);
diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js
index 28139353c..cefaaac20 100644
--- a/app/scenes/Settings/Shares.js
+++ b/app/scenes/Settings/Shares.js
@@ -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 {
Share Links
+
+ 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.
+
+
{shares.orderedData.map(share => (
diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js
index 3379b2cb6..490cfee4d 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/types/index.js b/app/types/index.js
index f0824f8d9..8b629d967 100644
--- a/app/types/index.js
+++ b/app/types/index.js
@@ -28,6 +28,8 @@ export type Team = {
id: string,
name: string,
avatarUrl: string,
+ slackConnected: boolean,
+ googleConnected: boolean,
};
export type NavigationNode = {
diff --git a/server/presenters/team.js b/server/presenters/team.js
index d4121b68a..863de4a3f 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,
};
}
From a7fc72e19f8895f47e1ce0c49579fa26d8acba11 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 00:01:06 -0400
Subject: [PATCH 15/22] :green_heart:
---
app/stores/UiStore.test.js | 4 +-
server/api/__snapshots__/auth.test.js.snap | 82 ----------------------
server/api/__snapshots__/user.test.js.snap | 3 +
server/middlewares/authentication.test.js | 6 +-
4 files changed, 8 insertions(+), 87 deletions(-)
delete mode 100644 server/api/__snapshots__/auth.test.js.snap
diff --git a/app/stores/UiStore.test.js b/app/stores/UiStore.test.js
index 899e6670a..9bfde1972 100644
--- a/app/stores/UiStore.test.js
+++ b/app/stores/UiStore.test.js
@@ -1,12 +1,12 @@
/* eslint-disable */
-import UiStore from './UiStore';
+import stores from '.';
// Actions
describe('UiStore', () => {
let store;
beforeEach(() => {
- store = new UiStore();
+ store = new stores.UiStore();
});
test('#add should add errors', () => {
diff --git a/server/api/__snapshots__/auth.test.js.snap b/server/api/__snapshots__/auth.test.js.snap
deleted file mode 100644
index 2be2eba79..000000000
--- 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 81d7533b5..40a7610de 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/middlewares/authentication.test.js b/server/middlewares/authentication.test.js
index 2adb30661..751b55239 100644
--- a/server/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';
From 140afc8a51dfa60dfd70c17bcaa86afd8266c033 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 00:27:18 -0400
Subject: [PATCH 16/22] :shirt:
---
app/stores/UiStore.test.js | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/stores/UiStore.test.js b/app/stores/UiStore.test.js
index 9bfde1972..5eeb09308 100644
--- a/app/stores/UiStore.test.js
+++ b/app/stores/UiStore.test.js
@@ -6,22 +6,23 @@ describe('UiStore', () => {
let store;
beforeEach(() => {
- store = new stores.UiStore();
+ store = stores.ui;
});
test('#add should add errors', () => {
- expect(store.data.length).toBe(0);
+ 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]).toBe('second error');
+ expect(store.toasts[0].message).toBe('second error');
});
});
From b7b5bac5c3ae27e0e0ccea6e643496f409727837 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 15:02:28 -0400
Subject: [PATCH 17/22] Test team.update endpoint
---
server/api/team.test.js | 29 +++++++++++++++++++++++++++++
server/api/user.test.js | 16 ++--------------
2 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/server/api/team.test.js b/server/api/team.test.js
index 19fb0b0ba..75b0963fd 100644
--- a/server/api/team.test.js
+++ b/server/api/team.test.js
@@ -34,3 +34,32 @@ 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' },
+ });
+ const body = await res.json();
+
+ 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.test.js b/server/api/user.test.js
index a7b18e2ab..9324f40ee 100644
--- a/server/api/user.test.js
+++ b/server/api/user.test.js
@@ -13,13 +13,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 +35,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' },
});
From 2337b9df7fc94cd7a37dd3e9446a7df5cfbf541c Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 15:13:05 -0400
Subject: [PATCH 18/22] service -> serviceId
---
.gitignore | 1 +
app/models/Integration.js | 2 +-
app/stores/IntegrationsStore.js | 2 +-
server/api/hooks.js | 2 +-
server/api/hooks.test.js | 2 +-
server/api/team.test.js | 2 --
server/auth/slack.js | 8 ++++----
server/migrations/20180528233910-rename-serviceid.js | 10 ++++++++++
server/models/Authentication.js | 2 +-
server/models/Integration.js | 2 +-
server/presenters/integration.js | 2 +-
server/services/slack/index.js | 2 +-
12 files changed, 23 insertions(+), 14 deletions(-)
create mode 100644 server/migrations/20180528233910-rename-serviceid.js
diff --git a/.gitignore b/.gitignore
index 2885433ac..f00129f44 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/models/Integration.js b/app/models/Integration.js
index b1f67b34e..fa4a84447 100644
--- a/app/models/Integration.js
+++ b/app/models/Integration.js
@@ -18,7 +18,7 @@ class Integration extends BaseModel {
ui: UiStore;
id: string;
- serviceId: string;
+ service: string;
collectionId: string;
events: Events;
settings: Settings;
diff --git a/app/stores/IntegrationsStore.js b/app/stores/IntegrationsStore.js
index 5bc4a3912..39e065cf3 100644
--- a/app/stores/IntegrationsStore.js
+++ b/app/stores/IntegrationsStore.js
@@ -23,7 +23,7 @@ class IntegrationsStore extends BaseStore {
@computed
get slackIntegrations(): Integration[] {
- return _.filter(this.orderedData, { serviceId: 'slack' });
+ return _.filter(this.orderedData, { service: 'slack' });
}
@action
diff --git a/server/api/hooks.js b/server/api/hooks.js
index 8d9b91747..4c3eb354d 100644
--- a/server/api/hooks.js
+++ b/server/api/hooks.js
@@ -20,7 +20,7 @@ router.post('hooks.unfurl', async ctx => {
if (!user) return;
const auth = await Authentication.find({
- where: { serviceId: 'slack', teamId: user.teamId },
+ where: { service: 'slack', teamId: user.teamId },
});
if (!auth) return;
diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js
index 0d7add05d..3fc9b36f5 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: '',
diff --git a/server/api/team.test.js b/server/api/team.test.js
index 75b0963fd..3a266cc34 100644
--- a/server/api/team.test.js
+++ b/server/api/team.test.js
@@ -52,8 +52,6 @@ describe('#team.update', async () => {
const res = await server.post('/api/team.update', {
body: { token: user.getJwtToken(), name: 'New name' },
});
- const body = await res.json();
-
expect(res.status).toEqual(403);
});
diff --git a/server/auth/slack.js b/server/auth/slack.js
index eabd20af2..115c0349a 100644
--- a/server/auth/slack.js
+++ b/server/auth/slack.js
@@ -86,7 +86,7 @@ router.get('slack.commands', async ctx => {
});
const authentication = await Authentication.create({
- serviceId: 'slack',
+ service: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -94,7 +94,7 @@ router.get('slack.commands', async ctx => {
});
await Integration.create({
- serviceId: 'slack',
+ service: 'slack',
type: 'command',
userId: user.id,
teamId: user.teamId,
@@ -120,7 +120,7 @@ router.get('slack.post', async ctx => {
});
const authentication = await Authentication.create({
- serviceId: 'slack',
+ service: 'slack',
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -128,7 +128,7 @@ router.get('slack.post', async ctx => {
});
await Integration.create({
- serviceId: 'slack',
+ service: 'slack',
type: 'post',
userId: user.id,
teamId: user.teamId,
diff --git a/server/migrations/20180528233910-rename-serviceid.js b/server/migrations/20180528233910-rename-serviceid.js
new file mode 100644
index 000000000..ce0a2aadc
--- /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 85a07abe3..e4a479dd2 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 99b9aa321..9251ba7cd 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/presenters/integration.js b/server/presenters/integration.js
index eb0099f37..619d7cd4b 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/services/slack/index.js b/server/services/slack/index.js
index d40f10b06..b2e5fd215 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',
},
});
From a99a804ea0d842acaf79401c4349f998a745f6e8 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 15:23:33 -0400
Subject: [PATCH 19/22] :shirt:
---
server/api/user.test.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/server/api/user.test.js b/server/api/user.test.js
index 9324f40ee..f714eac69 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';
From 4c9f86c7f7ba20993937bb107e766d5479b5aa70 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Fri, 1 Jun 2018 18:00:48 -0400
Subject: [PATCH 20/22] Save avatars to le cloud in beforeSave hooks Added
encryption to uploads Updated icon for team settings
---
app/components/Sidebar/Settings.js | 3 ++-
app/scenes/Settings/components/ImageUpload.js | 2 +-
package.json | 2 +-
server/models/Team.js | 16 ++++++++++++
server/models/User.js | 25 +++++++++++--------
server/utils/s3.js | 1 +
yarn.lock | 6 ++---
7 files changed, 39 insertions(+), 16 deletions(-)
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js
index d9863100e..60af185ad 100644
--- a/app/components/Sidebar/Settings.js
+++ b/app/components/Sidebar/Settings.js
@@ -7,6 +7,7 @@ import {
CodeIcon,
UserIcon,
LinkIcon,
+ TeamIcon,
} from 'outline-icons';
import Flex from 'shared/components/Flex';
@@ -55,7 +56,7 @@ class SettingsSidebar extends React.Component {
{user.isAdmin && (
- }>
+ }>
Details
)}
diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js
index 6c4522719..e0aed52af 100644
--- a/app/scenes/Settings/components/ImageUpload.js
+++ b/app/scenes/Settings/components/ImageUpload.js
@@ -45,7 +45,7 @@ class DropToImport extends React.Component {
const asset = await uploadFile(imageBlob, { name: this.file.name });
this.props.onSuccess(asset.url);
} catch (err) {
- this.props.onError(err);
+ this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
diff --git a/package.json b/package.json
index 40ef2e469..d9e381573 100644
--- a/package.json
+++ b/package.json
@@ -133,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",
diff --git a/server/models/Team.js b/server/models/Team.js
index 1ce90d28b..808e85b5b 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';
@@ -33,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',
@@ -82,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 4c6e68ce9..117b33405 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;
@@ -57,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) {
@@ -77,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;
}
@@ -106,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/utils/s3.js b/server/utils/s3.js
index c8d0c08e1..7edeb384d 100644
--- a/server/utils/s3.js
+++ b/server/utils/s3.js
@@ -68,6 +68,7 @@ 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();
diff --git a/yarn.lock b/yarn.lock
index b7175aba8..cb46e1b43 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7326,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"
From 329d23828da9e636876e26a16ab13009dd2d42e6 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Sat, 2 Jun 2018 18:43:44 -0400
Subject: [PATCH 21/22] Fallback for domain without public logo
---
server/auth/google.js | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/server/auth/google.js b/server/auth/google.js
index 47b358d4f..091458187 100644
--- a/server/auth/google.js
+++ b/server/auth/google.js
@@ -1,4 +1,5 @@
// @flow
+import crypto from 'crypto';
import Router from 'koa-router';
import addMonths from 'date-fns/add_months';
import { capitalize } from 'lodash';
@@ -42,14 +43,28 @@ router.get('google.callback', async ctx => {
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: profile.data.hd,
+ googleId,
},
defaults: {
name: teamName,
- avatarUrl: `https://logo.clearbit.com/${profile.data.hd}`,
+ avatarUrl,
},
});
From 466033964f07d789ddf20ad2c706edbc4fd276ff Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Sat, 2 Jun 2018 19:00:16 -0400
Subject: [PATCH 22/22] Closes #482
---
server/utils/s3.js | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/server/utils/s3.js b/server/utils/s3.js
index 7edeb384d..ffe4867ce 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) => {
@@ -73,7 +76,7 @@ export const uploadToS3FromUrl = async (url: string, key: string) => {
})
.promise();
- const endpoint = publicS3Endpoint();
+ const endpoint = publicS3Endpoint(true);
return `${endpoint}/${key}`;
} catch (err) {
if (process.env.NODE_ENV === 'production') {