@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
You’re 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);
|
||||
|
||||
142
app/scenes/Settings/components/ImageUpload.js
Normal file
142
app/scenes/Settings/components/ImageUpload.js
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user