From cd1d2430bb8343b17bf1c7a0134f12150da6a54c Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Sun, 3 Dec 2017 00:00:23 -0800 Subject: [PATCH 1/8] =?UTF-8?q?Added=20a=20setting=20to=20update=20user?= =?UTF-8?q?=E2=80=99s=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scenes/Settings/Settings.js | 84 ++++++++++++++++++---- server/api/__snapshots__/user.test.js.snap | 23 ++++++ server/api/middlewares/validation.js | 6 ++ server/api/user.js | 11 +++ server/api/user.test.js | 28 ++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index 6172b1b41..8c23c85a7 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -1,17 +1,65 @@ // @flow import React, { Component } from 'react'; +import { observable, runInAction } from 'mobx'; import { observer, inject } from 'mobx-react'; +import invariant from 'invariant'; +import styled from 'styled-components'; +import { color, size } from 'shared/styles/constants'; +import { client } from 'utils/ApiClient'; import AuthStore from 'stores/AuthStore'; +import ErrorsStore from 'stores/ErrorsStore'; import Input from 'components/Input'; +import Button from 'components/Button'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; -import HelpText from 'components/HelpText'; @observer class Settings extends Component { + timeout: number; props: { auth: AuthStore, + errors: ErrorsStore, + }; + + @observable name: string; + @observable updated: boolean; + @observable isSaving: boolean; + + componentDidMount() { + if (this.props.auth.user) { + this.name = this.props.auth.user.name; + } + } + + componentWillUnmount() { + clearTimeout(this.timeout); + } + + handleSubmit = async (ev: SyntheticEvent) => { + ev.preventDefault(); + this.isSaving = true; + + try { + const res = await client.post(`/user.update`, { + name: this.name, + }); + invariant(res && res.data, 'Document list not available'); + const { data } = res; + runInAction('Settings#handleSubmit', () => { + this.props.auth.user = data; + this.updated = true; + this.timeout = setTimeout(() => (this.updated = false), 2500); + }); + } catch (e) { + this.props.errors.add('Failed to load documents'); + } finally { + this.isSaving = false; + } + }; + + handleNameChange = (ev: SyntheticInputEvent) => { + this.name = ev.target.value; }; render() { @@ -22,22 +70,32 @@ class Settings extends Component {

Profile

- - You’re signed in to Outline with Slack. To update your profile - information here please{' '} - - update your profile on Slack - {' '} - and re-login to refresh. - -
- - + + + + + Profile updated! +
); } } -export default inject('auth')(Settings); +const SuccessMessage = styled.span` + margin-left: ${size.large}; + color: ${color.slate}; + opacity: ${props => (props.visible ? 1 : 0)}; + + transition: opacity 0.25s; +`; + +export default inject('auth', 'errors', 'auth')(Settings); diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 888d2362c..6ad5d9af9 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -22,3 +22,26 @@ Object { "status": 200, } `; + +exports[`#user.update should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +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", + "name": "New name", + "username": "user1", + }, + "ok": true, + "status": 200, +} +`; diff --git a/server/api/middlewares/validation.js b/server/api/middlewares/validation.js index d7ef52bc3..231595cf1 100644 --- a/server/api/middlewares/validation.js +++ b/server/api/middlewares/validation.js @@ -11,6 +11,12 @@ export default function validation() { } }; + ctx.assertNotEmpty = function assertNotEmpty(value, message) { + if (value === '') { + throw apiError(400, 'validation_error', message); + } + }; + ctx.assertEmail = (value, message) => { if (!validator.isEmail(value)) { throw apiError(400, 'validation_error', message); diff --git a/server/api/user.js b/server/api/user.js index b50b0bd1b..ccc3a4874 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -11,6 +11,17 @@ router.post('user.info', auth(), async ctx => { ctx.body = { data: await presentUser(ctx, ctx.state.user) }; }); +router.post('user.update', auth(), async ctx => { + const { user } = ctx.state; + const { name } = ctx.body; + ctx.assertNotEmpty(name, "name can't be empty"); + + if (name) user.name = name; + await user.save(); + + ctx.body = { data: await presentUser(ctx, user) }; +}); + router.post('user.s3Upload', auth(), async ctx => { const { filename, kind, size } = ctx.body; ctx.assertPresent(filename, 'filename is required'); diff --git a/server/api/user.test.js b/server/api/user.test.js index 847f33191..e3bc2644f 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -38,3 +38,31 @@ describe('#user.info', async () => { expect(body).toMatchSnapshot(); }); }); + +describe('#user.update', async () => { + it('should update user profile information', async () => { + await seed(); + const user = await User.findOne({ + where: { + email: 'user1@example.com', + }, + }); + + const res = await server.post('/api/user.update', { + body: { token: user.getJwtToken(), name: 'New name' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body).toMatchSnapshot(); + }); + + it('should require authentication', async () => { + await seed(); + const res = await server.post('/api/user.update'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); From c2879c51b2170cdc6903ee2212c6086520207910 Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Sun, 3 Dec 2017 08:47:21 -0800 Subject: [PATCH 2/8] started working on avatar upload --- app/scenes/Settings/Settings.js | 51 +++++++++++++++++++++++++++++++-- package.json | 1 + yarn.lock | 4 +++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index 8c23c85a7..8f53e749c 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -9,10 +9,11 @@ import { color, size } from 'shared/styles/constants'; import { client } from 'utils/ApiClient'; import AuthStore from 'stores/AuthStore'; import ErrorsStore from 'stores/ErrorsStore'; -import Input from 'components/Input'; +import Input, { LabelText } from 'components/Input'; import Button from 'components/Button'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; +import Flex from 'shared/components/Flex'; @observer class Settings extends Component { @@ -65,12 +66,21 @@ class Settings extends Component { render() { const { user } = this.props.auth; if (!user) return null; + const avatarUrl = user.avatarUrl; return (

Profile

- + + Profile picture + + + + Upload new image + + +
Date: Wed, 13 Dec 2017 23:17:08 -0800 Subject: [PATCH 3/8] Avatar upload --- app/components/Editor/changes.js | 2 +- app/scenes/Settings/Settings.js | 34 ++++- app/scenes/Settings/components/ImageUpload.js | 143 ++++++++++++++++++ app/utils/uploadFile.js | 26 +++- package.json | 2 +- server/api/user.js | 31 +++- yarn.lock | 10 +- 7 files changed, 228 insertions(+), 20 deletions(-) create mode 100644 app/scenes/Settings/components/ImageUpload.js diff --git a/app/components/Editor/changes.js b/app/components/Editor/changes.js index 8e96d78ff..b44c128c6 100644 --- a/app/components/Editor/changes.js +++ b/app/components/Editor/changes.js @@ -3,7 +3,7 @@ import { Change } from 'slate'; import { Editor } from 'slate-react'; import uuid from 'uuid'; import EditList from './plugins/EditList'; -import uploadFile from 'utils/uploadFile'; +import { uploadFile } from 'utils/uploadFile'; const { changes } = EditList; diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index 8f53e749c..c5fa7427d 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -9,6 +9,7 @@ import { color, size } from 'shared/styles/constants'; import { client } from 'utils/ApiClient'; import AuthStore from 'stores/AuthStore'; import ErrorsStore from 'stores/ErrorsStore'; +import ImageUpload from './components/ImageUpload'; import Input, { LabelText } from 'components/Input'; import Button from 'components/Button'; import CenteredContent from 'components/CenteredContent'; @@ -24,6 +25,7 @@ class Settings extends Component { }; @observable name: string; + @observable avatarUrl: ?string; @observable updated: boolean; @observable isSaving: boolean; @@ -44,6 +46,7 @@ class Settings extends Component { try { const res = await client.post(`/user.update`, { name: this.name, + avatarUrl: this.avatarUrl, }); invariant(res && res.data, 'Document list not available'); const { data } = res; @@ -63,10 +66,18 @@ class Settings extends Component { this.name = ev.target.value; }; + handleAvatarUpload = (avatarUrl: string) => { + this.avatarUrl = avatarUrl; + }; + + handleAvatarError = (error: ?string) => { + this.props.errors.add(error || 'Unable to upload new avatar'); + }; + render() { const { user } = this.props.auth; if (!user) return null; - const avatarUrl = user.avatarUrl; + const avatarUrl = this.avatarUrl || user.avatarUrl; return ( @@ -75,14 +86,19 @@ class Settings extends Component { Profile picture - - - Upload new image - + + + + Upload new image + + - , + onSuccess: string => void, + onError: string => void, +}; + +@observer +class DropToImport extends Component { + @observable isUploading: boolean = false; + @observable isCropping: boolean = false; + @observable zoom: number = 1; + props: Props; + file: File; + avatarEditorRef: HTMLCanvasElement; + + onDropAccepted = async (files: File[]) => { + this.isCropping = true; + this.file = files[0]; + }; + + handleCrop = async () => { + // $FlowIssue getImage() exists + const canvas = this.avatarEditorRef.getImage(); + const imageBlob = dataUrlToBlob(canvas.toDataURL()); + try { + const asset = await uploadFile(imageBlob, { name: this.file.name }); + this.props.onSuccess(asset.url); + } catch (err) { + this.props.onError('Unable to upload image'); + } finally { + this.isUploading = false; + this.isCropping = false; + } + }; + + handleZoom = (event: SyntheticDragEvent) => { + let target = event.target; + if (target instanceof HTMLInputElement) { + this.zoom = parseFloat(target.value); + } + }; + + renderCropping() { + return ( + + + + (this.avatarEditorRef = ref)} + image={this.file} + width={250} + height={250} + border={25} + borderRadius={150} + color={[255, 255, 255, 0.6]} // RGBA + scale={this.zoom} + rotate={0} + /> + + + {this.isUploading && } + + Crop avatar + + + + ); + } + + render() { + if (this.isCropping) { + return this.renderCropping(); + } else { + return ( + + {this.props.children} + + ); + } + } +} + +const AvatarEditorContainer = styled(Flex)` + margin-bottom: 30px; +`; + +const RangeInput = styled.input` + display: block; + width: 300px; + margin-bottom: 30px; + height: 4px; + cursor: pointer; + color: inherit; + border-radius: 99999px; + background-color: #dee1e3; + appearance: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + height: 16px; + width: 16px; + border-radius: 50%; + background: black; + cursor: pointer; + } + + &:focus { + outline: none; + } +`; + +const CropButton = styled(Button)` + width: 300px; +`; + +export default DropToImport; diff --git a/app/utils/uploadFile.js b/app/utils/uploadFile.js index 7b53e39e2..f88d9f806 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -6,15 +6,22 @@ type File = { blob: boolean, type: string, size: number, - name: string, + name?: string, file: string, }; -export default async function uploadFile(file: File) { +type Options = { + name?: string, +}; + +export const uploadFile = async (file: File | Blob, option?: Options) => { + // $FlowFixMe Blob makes life hard + const filename = (option && option.name) || file.name; + const response = await client.post('/user.s3Upload', { kind: file.type, size: file.size, - filename: file.name, + filename, }); invariant(response, 'Response should be available'); @@ -28,6 +35,7 @@ export default async function uploadFile(file: File) { } if (file.blob) { + // $FlowFixMe formData.append('file', file.file); } else { // $FlowFixMe @@ -41,4 +49,14 @@ export default async function uploadFile(file: File) { await fetch(data.uploadUrl, options); return asset; -} +}; + +export const dataUrlToBlob = (dataURL: string) => { + var blobBin = atob(dataURL.split(',')[1]); + var array = []; + for (var i = 0; i < blobBin.length; i++) { + array.push(blobBin.charCodeAt(i)); + } + const file = new Blob([new Uint8Array(array)], { type: 'image/png' }); + return file; +}; diff --git a/package.json b/package.json index 66d702365..948c57deb 100644 --- a/package.json +++ b/package.json @@ -145,10 +145,10 @@ "randomstring": "1.1.5", "raw-loader": "^0.5.1", "react": "^16.1.0", + "react-avatar-editor": "^10.3.0", "react-dom": "^16.1.0", "react-dropzone": "4.2.1", "react-helmet": "^5.2.0", - "react-image-crop": "^3.0.7", "react-keydown": "^1.7.3", "react-markdown": "^3.0.2", "react-medium-image-zoom": "^3.0.2", diff --git a/server/api/user.js b/server/api/user.js index ccc3a4874..e6f72fb50 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -1,7 +1,13 @@ // @flow import uuid from 'uuid'; import Router from 'koa-router'; +<<<<<<< HEAD import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3'; +======= + +import Event from '../models/Event'; +import { makePolicy, signPolicy } from '../utils/s3'; +>>>>>>> Avatar upload import auth from './middlewares/authentication'; import { presentUser } from '../presenters'; @@ -13,10 +19,16 @@ router.post('user.info', auth(), async ctx => { router.post('user.update', auth(), async ctx => { const { user } = ctx.state; - const { name } = ctx.body; - ctx.assertNotEmpty(name, "name can't be empty"); + const { name, avatarUrl } = ctx.body; if (name) user.name = name; + if ( + avatarUrl && + avatarUrl.startsWith( + `${process.env.AWS_S3_UPLOAD_BUCKET_URL}uploads/${ctx.state.user.id}` + ) + ) + user.avatarUrl = avatarUrl; await user.save(); ctx.body = { data: await presentUser(ctx, user) }; @@ -32,6 +44,19 @@ router.post('user.s3Upload', auth(), async ctx => { const key = `uploads/${ctx.state.user.id}/${s3Key}/${filename}`; const policy = makePolicy(); const endpoint = publicS3Endpoint(); + const url = `${endpoint}/${key}`; + + await Event.create({ + name: 'user.s3Upload', + data: { + filename, + kind, + size, + url, + }, + teamId: ctx.state.user.teamId, + userId: ctx.state.user.id, + }); ctx.body = { data: { @@ -48,8 +73,8 @@ router.post('user.s3Upload', auth(), async ctx => { }, asset: { contentType: kind, - url: `${endpoint}/${key}`, name: filename, + url, size, }, }, diff --git a/yarn.lock b/yarn.lock index 359840fae..93444126b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7379,6 +7379,12 @@ rc@^1.0.1, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-avatar-editor@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-10.3.0.tgz#7ee54774a274b3aa733d8651e6138d4904114c7d" + dependencies: + prop-types "^15.5.8" + react-create-component-from-tag-prop@^1.2.1: version "1.3.1" resolved "https://registry.npmjs.org/react-create-component-from-tag-prop/-/react-create-component-from-tag-prop-1.3.1.tgz#5389407d99f88ba2b36351780a6094470b44a7c7" @@ -7415,10 +7421,6 @@ react-helmet@^5.2.0: prop-types "^15.5.4" react-side-effect "^1.1.0" -react-image-crop@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-3.0.7.tgz#8f7f1f03031c76c8b6306d11f7d1b8bf3007ab8c" - react-immutable-proptypes@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4" From 308d36c528194bed9f33ac8491d2e94fef255b7d Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Wed, 13 Dec 2017 23:32:45 -0800 Subject: [PATCH 4/8] Fixes s3 path --- server/api/user.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/server/api/user.js b/server/api/user.js index e6f72fb50..77fdf41af 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -1,13 +1,8 @@ // @flow import uuid from 'uuid'; import Router from 'koa-router'; -<<<<<<< HEAD import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3'; -======= - import Event from '../models/Event'; -import { makePolicy, signPolicy } from '../utils/s3'; ->>>>>>> Avatar upload import auth from './middlewares/authentication'; import { presentUser } from '../presenters'; @@ -20,13 +15,12 @@ router.post('user.info', auth(), async ctx => { router.post('user.update', auth(), async ctx => { const { user } = ctx.state; const { name, avatarUrl } = ctx.body; + const endpoint = publicS3Endpoint(); if (name) user.name = name; if ( avatarUrl && - avatarUrl.startsWith( - `${process.env.AWS_S3_UPLOAD_BUCKET_URL}uploads/${ctx.state.user.id}` - ) + avatarUrl.startsWith(`${endpoint}/uploads/${ctx.state.user.id}`) ) user.avatarUrl = avatarUrl; await user.save(); From 7fc1af1b74794d18416d3600b0c29f06434a2a76 Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Thu, 14 Dec 2017 22:56:35 -0800 Subject: [PATCH 5/8] added s3 envvars to circle --- circle.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/circle.yml b/circle.yml index feccc0ada..8b8777db1 100644 --- a/circle.yml +++ b/circle.yml @@ -11,6 +11,8 @@ machine: DATABASE_URL: postgres://ubuntu@localhost:5432/circle_test URL: http://localhost:3000 SMTP_FROM_EMAIL: hello@example.com + AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com + AWS_S3_UPLOAD_BUCKET_NAME: outline-circle dependencies: override: From 62c014a8e98452f14f38f95cb0fbc49a141f5ac2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 17 Dec 2017 14:09:17 -0800 Subject: [PATCH 6/8] :shirt: --- app/utils/uploadFile.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/app/utils/uploadFile.js b/app/utils/uploadFile.js index f88d9f806..16a9d26b6 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -2,22 +2,15 @@ import { client } from './ApiClient'; import invariant from 'invariant'; -type File = { - blob: boolean, - type: string, - size: number, - name?: string, - file: string, -}; - type Options = { name?: string, }; -export const uploadFile = async (file: File | Blob, option?: Options) => { - // $FlowFixMe Blob makes life hard - const filename = (option && option.name) || file.name; - +export const uploadFile = async ( + file: File | Blob, + option?: Options = { name: '' } +) => { + const filename = file instanceof File ? file.name : option.name; const response = await client.post('/user.s3Upload', { kind: file.type, size: file.size, @@ -38,7 +31,6 @@ export const uploadFile = async (file: File | Blob, option?: Options) => { // $FlowFixMe formData.append('file', file.file); } else { - // $FlowFixMe formData.append('file', file); } From 8f3c82fe57871549a905d7409396f931150605c2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 17 Dec 2017 14:14:49 -0800 Subject: [PATCH 7/8] Remove Flow suppression --- app/scenes/Settings/components/ImageUpload.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js index f3f3d6110..4c034a649 100644 --- a/app/scenes/Settings/components/ImageUpload.js +++ b/app/scenes/Settings/components/ImageUpload.js @@ -25,7 +25,7 @@ class DropToImport extends Component { @observable zoom: number = 1; props: Props; file: File; - avatarEditorRef: HTMLCanvasElement; + avatarEditorRef: AvatarEditor; onDropAccepted = async (files: File[]) => { this.isCropping = true; @@ -33,7 +33,6 @@ class DropToImport extends Component { }; handleCrop = async () => { - // $FlowIssue getImage() exists const canvas = this.avatarEditorRef.getImage(); const imageBlob = dataUrlToBlob(canvas.toDataURL()); try { From 8b2261595313ebae338acfffc96887d33b2773bc Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Sun, 17 Dec 2017 16:27:23 -0800 Subject: [PATCH 8/8] Rename updated --- app/scenes/Settings/Settings.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index c5fa7427d..14a8561d4 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -26,7 +26,7 @@ class Settings extends Component { @observable name: string; @observable avatarUrl: ?string; - @observable updated: boolean; + @observable isUpdated: boolean; @observable isSaving: boolean; componentDidMount() { @@ -52,8 +52,8 @@ class Settings extends Component { const { data } = res; runInAction('Settings#handleSubmit', () => { this.props.auth.user = data; - this.updated = true; - this.timeout = setTimeout(() => (this.updated = false), 2500); + this.isUpdated = true; + this.timeout = setTimeout(() => (this.isUpdated = false), 2500); }); } catch (e) { this.props.errors.add('Failed to load documents'); @@ -107,7 +107,7 @@ class Settings extends Component { - + Profile updated!