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 ( - + + + + {lastLoggedIn === 'slack' && 'You signed in with Slack previously'} + +   + + + {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 ( - + - {lastLoggedIn === 'slack' && 'You signed in with Slack previously'} + + {lastLoggedIn === 'slack' && 'You signed in with Slack previously'} +   - + - {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) {

Create an account

- On the same page as us? Create a beta account to give Outline a try. + On the same page as us? Create a free account to give Outline a try.

- +
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 && ( + + + + {lastSignedIn === 'slack' && 'You signed in with Slack previously'} + + + )} +   + {googleSigninEnabled && ( + + + + {lastSignedIn === 'google' && + 'You signed in with Google previously'} + + + )} + + ); +}; + +const Spacer = styled.span` + padding-left: 10px; +`; + +const Button = styled.a` + display: inline-flex; + align-items: center; + padding: 10px 20px; + color: ${color.white}; + background: ${color.black}; + border-radius: 4px; + font-weight: 600; + height: 56px; +`; + +const LastLogin = styled.p` + font-size: 12px; + color: ${color.slate}; + padding-top: 4px; +`; + +export default SigninButtons; diff --git a/server/pages/components/SignupButton.js b/server/pages/components/SignupButton.js deleted file mode 100644 index 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 ( - - - - - {lastLoggedIn === 'slack' && 'You signed in with Slack previously'} - - -   - - - - {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 {
Team
+ }> + 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 ( - - 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 /> - @@ -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 ( - +
Team
- }> - 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 {
Team
{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') {