Merge pull request #485 from outline/jori/user-settings

User settings
This commit is contained in:
Jori Lallo
2017-12-17 16:36:53 -08:00
committed by GitHub
11 changed files with 400 additions and 27 deletions

View File

@@ -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;

View File

@@ -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 (
<CenteredContent>
<PageTitle title="Profile" />
<h1>Profile</h1>
<HelpText>
Youre signed in to Outline with Slack. To update your profile
information here please{' '}
<a href="https://slack.com/account/profile" target="_blank">
update your profile on Slack
</a>{' '}
and re-login to refresh.
</HelpText>
<form>
<Input label="Name" value={user.name} disabled />
<Input label="Email" value={user.email} disabled />
<ProfilePicture column>
<LabelText>Profile picture</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload new image
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit}>
<StyledInput
label="Name"
value={this.name}
onChange={this.handleNameChange}
required
/>
<Button type="submit" disabled={this.isSaving || !this.name}>
Save
</Button>
<SuccessMessage visible={this.isUpdated}>
Profile updated!
</SuccessMessage>
</form>
</CenteredContent>
);
}
}
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);

View File

@@ -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<any>,
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 (
<Modal isOpen title="">
<Flex auto column align="center" justify="center">
<AvatarEditorContainer>
<AvatarEditor
ref={ref => (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}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
/>
{this.isUploading && <LoadingIndicator />}
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
Crop avatar
</CropButton>
</Flex>
</Modal>
);
}
render() {
if (this.isCropping) {
return this.renderCropping();
} else {
return (
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
style={{}}
disablePreview
{...this.props}
>
{this.props.children}
</Dropzone>
);
}
}
}
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;

View File

@@ -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;
};

View File

@@ -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:

View File

@@ -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",

View File

@@ -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,
}
`;

View File

@@ -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);

View File

@@ -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,
},
},

View File

@@ -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();
});
});

View File

@@ -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"