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.
-
-
-
);
}
}
-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"