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 6172b1b41..14a8561d4 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -1,43 +1,168 @@ // @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 Input from 'components/Input'; +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'; import PageTitle from 'components/PageTitle'; -import HelpText from 'components/HelpText'; +import Flex from 'shared/components/Flex'; @observer class Settings extends Component { + timeout: number; props: { auth: AuthStore, + errors: ErrorsStore, + }; + + @observable name: string; + @observable avatarUrl: ?string; + @observable isUpdated: 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, + avatarUrl: this.avatarUrl, + }); + invariant(res && res.data, 'Document list not available'); + const { data } = res; + runInAction('Settings#handleSubmit', () => { + this.props.auth.user = data; + this.isUpdated = true; + this.timeout = setTimeout(() => (this.isUpdated = false), 2500); + }); + } catch (e) { + this.props.errors.add('Failed to load documents'); + } finally { + this.isSaving = false; + } + }; + + handleNameChange = (ev: SyntheticInputEvent) => { + 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 = this.avatarUrl || user.avatarUrl; return (

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 picture + + + + + Upload new image + + + + + + + + + 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; +`; + +const ProfilePicture = styled(Flex)` + margin-bottom: ${size.huge}; +`; + +const avatarStyles = ` + width: 150px; + height: 150px; + border-radius: 50%; +`; + +const AvatarContainer = styled(Flex)` + ${avatarStyles}; + position: relative; + + div div { + ${avatarStyles}; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0; + cursor: pointer; + transition: all 250ms; + } + + &:hover div { + opacity: 1; + background: rgba(0, 0, 0, 0.75); + color: #ffffff; + } +`; + +const Avatar = styled.img` + ${avatarStyles}; +`; + +const StyledInput = styled(Input)` + max-width: 350px; +`; + +export default inject('auth', 'errors', 'auth')(Settings); diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js new file mode 100644 index 000000000..4c034a649 --- /dev/null +++ b/app/scenes/Settings/components/ImageUpload.js @@ -0,0 +1,142 @@ +// @flow +import React, { Component } from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import Dropzone from 'react-dropzone'; + +import LoadingIndicator from 'components/LoadingIndicator'; +import Flex from 'shared/components/Flex'; +import Modal from 'components/Modal'; +import Button from 'components/Button'; +import AvatarEditor from 'react-avatar-editor'; +import { uploadFile, dataUrlToBlob } from 'utils/uploadFile'; + +type Props = { + children?: React$Element, + 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: AvatarEditor; + + onDropAccepted = async (files: File[]) => { + this.isCropping = true; + this.file = files[0]; + }; + + handleCrop = async () => { + 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..16a9d26b6 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -2,19 +2,19 @@ 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 default async function uploadFile(file: File) { +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, - filename: file.name, + filename, }); invariant(response, 'Response should be available'); @@ -28,9 +28,9 @@ export default async function uploadFile(file: File) { } if (file.blob) { + // $FlowFixMe formData.append('file', file.file); } else { - // $FlowFixMe formData.append('file', file); } @@ -41,4 +41,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/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: diff --git a/package.json b/package.json index 3bee2d0e9..948c57deb 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "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", 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..77fdf41af 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -2,6 +2,7 @@ import uuid from 'uuid'; import Router from 'koa-router'; import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3'; +import Event from '../models/Event'; import auth from './middlewares/authentication'; import { presentUser } from '../presenters'; @@ -11,6 +12,22 @@ 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, avatarUrl } = ctx.body; + const endpoint = publicS3Endpoint(); + + if (name) user.name = name; + if ( + avatarUrl && + avatarUrl.startsWith(`${endpoint}/uploads/${ctx.state.user.id}`) + ) + user.avatarUrl = avatarUrl; + 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'); @@ -21,6 +38,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: { @@ -37,8 +67,8 @@ router.post('user.s3Upload', auth(), async ctx => { }, asset: { contentType: kind, - url: `${endpoint}/${key}`, name: filename, + url, size, }, }, 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(); + }); +}); diff --git a/yarn.lock b/yarn.lock index c72fa3564..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"