Account Deletion (#716)

Adds ability to remove user account, wipes personal information and soft-deletes record.
This commit is contained in:
Tom Moor
2018-07-10 21:05:01 -07:00
committed by GitHub
parent f15ac0ee2a
commit 2d6f906b83
37 changed files with 254 additions and 79 deletions

View File

@ -1,3 +0,0 @@
// @flow
import Collection from './Collection';
export default Collection;

View File

@ -1,3 +0,0 @@
// @flow
import CollectionDelete from './CollectionDelete';
export default CollectionDelete;

View File

@ -1,3 +0,0 @@
// @flow
import CollectionEdit from './CollectionEdit';
export default CollectionEdit;

View File

@ -1,3 +0,0 @@
// @flow
import CollectionExport from './CollectionExport';
export default CollectionExport;

View File

@ -1,3 +0,0 @@
// @flow
import CollectionNew from './CollectionNew';
export default CollectionNew;

View File

@ -1,3 +0,0 @@
// @flow
import Dashboard from './Dashboard';
export default Dashboard;

View File

@ -1,3 +0,0 @@
// @flow
import DocumentDelete from './DocumentDelete';
export default DocumentDelete;

View File

@ -1,3 +0,0 @@
// @flow
import DocumentShare from './DocumentShare';
export default DocumentShare;

View File

@ -1,3 +0,0 @@
// @flow
import Drafts from './Drafts';
export default Drafts;

View File

@ -1,3 +0,0 @@
// @flow
import ErrorSuspended from './ErrorSuspended';
export default ErrorSuspended;

View File

@ -1,3 +0,0 @@
// @flow
import Home from './Home';
export default Home;

View File

@ -1,3 +0,0 @@
// @flow
import KeyboardShortcuts from './KeyboardShortcuts';
export default KeyboardShortcuts;

View File

@ -11,6 +11,7 @@ import Input, { LabelText } from 'components/Input';
import Button from 'components/Button';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import UserDelete from 'scenes/UserDelete';
import Flex from 'shared/components/Flex';
type Props = {
@ -25,6 +26,7 @@ class Profile extends React.Component<Props> {
@observable name: string;
@observable avatarUrl: ?string;
@observable showDeleteModal: boolean = false;
componentDidMount() {
if (this.props.auth.user) {
@ -58,6 +60,10 @@ class Profile extends React.Component<Props> {
this.props.ui.showToast(error || 'Unable to upload new avatar');
};
toggleDeleteAccount = () => {
this.showDeleteModal = !this.showDeleteModal;
};
get isValid() {
return this.form && this.form.checkValidity();
}
@ -97,11 +103,28 @@ class Profile extends React.Component<Props> {
{isSaving ? 'Saving…' : 'Save'}
</Button>
</form>
<DangerZone>
<LabelText>Delete Account</LabelText>
<p>
You may delete your account at any time, note that this is
unrecoverable.{' '}
<a onClick={this.toggleDeleteAccount}>Delete account</a>.
</p>
</DangerZone>
{this.showDeleteModal && (
<UserDelete onRequestClose={this.toggleDeleteAccount} />
)}
</CenteredContent>
);
}
}
const DangerZone = styled.div`
position: absolute;
bottom: 16px;
`;
const ProfilePicture = styled(Flex)`
margin-bottom: 24px;
`;

View File

@ -1,3 +0,0 @@
// @flow
import Starred from './Starred';
export default Starred;

62
app/scenes/UserDelete.js Normal file
View File

@ -0,0 +1,62 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { inject, observer } from 'mobx-react';
import Button from 'components/Button';
import Flex from 'shared/components/Flex';
import HelpText from 'components/HelpText';
import Modal from 'components/Modal';
import AuthStore from 'stores/AuthStore';
type Props = {
auth: AuthStore,
onRequestClose: () => *,
};
@observer
class UserDelete extends React.Component<Props> {
@observable isDeleting: boolean;
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isDeleting = true;
try {
const success = await this.props.auth.deleteUser();
if (success) {
this.props.auth.logout();
}
} finally {
this.isDeleting = false;
}
};
render() {
const { auth, ...rest } = this.props;
return (
<Modal isOpen title="Delete Account" {...rest}>
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure? Deleting your account will destory identifying data
associated with your user and cannot be undone. You will be
immediately logged out of Outline and all your API tokens will be
revoked.
</HelpText>
<HelpText>
<strong>Note:</strong> Signing back in will cause a new account to
be automatically reprovisioned.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? 'Deleting…' : 'Delete My Account'}
</Button>
</form>
</Flex>
</Modal>
);
}
}
export default inject('auth')(UserDelete);

View File

@ -50,6 +50,17 @@ class AuthStore {
}
};
@action
deleteUser = async () => {
await client.post(`/user.delete`, { confirmation: true });
runInAction('AuthStore#updateUser', () => {
this.user = null;
this.team = null;
this.token = null;
});
};
@action
updateUser = async (params: { name: string, avatarUrl: ?string }) => {
this.isSaving = true;

View File

@ -26,6 +26,15 @@ Object {
}
`;
exports[`#user.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#user.demote should demote an admin 1`] = `
Object {
"data": Object {
@ -61,29 +70,6 @@ Object {
}
`;
exports[`#user.info should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#user.info should return known user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"createdAt": "2018-01-01T00:00:00.000Z",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",
},
"ok": true,
"status": 200,
}
`;
exports[`#user.promote should promote a new admin 1`] = `
Object {
"data": Object {

View File

@ -164,4 +164,22 @@ router.post('user.activate', auth(), async ctx => {
};
});
router.post('user.delete', auth(), async ctx => {
const { confirmation } = ctx.body;
ctx.assertPresent(confirmation, 'confirmation is required');
const user = ctx.state.user;
authorize(user, 'delete', user);
try {
await user.destroy();
} catch (err) {
throw new ValidationError(err.message);
}
ctx.body = {
success: true,
};
});
export default router;

View File

@ -3,6 +3,7 @@ import TestServer from 'fetch-test-server';
import app from '..';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';
const server = new TestServer(app.callback());
@ -11,19 +12,60 @@ afterAll(server.close);
describe('#user.info', async () => {
it('should return known user', async () => {
const { user } = await seed();
const user = await buildUser();
const res = await server.post('/api/user.info', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
expect(body.data.id).toEqual(user.id);
expect(body.data.name).toEqual(user.name);
});
it('should require authentication', async () => {
await seed();
const res = await server.post('/api/user.info');
expect(res.status).toEqual(401);
});
});
describe('#user.delete', async () => {
it('should not allow deleting without confirmation', async () => {
const user = await buildUser();
const res = await server.post('/api/user.delete', {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(400);
});
it('should allow deleting last admin if only user', async () => {
const user = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(200);
});
it('should not allow deleting last admin if many users', async () => {
const user = await buildUser({ isAdmin: true });
await buildUser({ teamId: user.teamId, isAdmin: false });
const res = await server.post('/api/user.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(400);
});
it('should allow deleting user account with confirmation', async () => {
const user = await buildUser();
const res = await server.post('/api/user.delete', {
body: { token: user.getJwtToken(), confirmation: true },
});
expect(res.status).toEqual(200);
});
it('should require authentication', async () => {
const res = await server.post('/api/user.delete');
const body = await res.json();
expect(res.status).toEqual(401);
@ -44,7 +86,6 @@ describe('#user.update', async () => {
});
it('should require authentication', async () => {
await seed();
const res = await server.post('/api/user.update');
const body = await res.json();
@ -67,7 +108,7 @@ describe('#user.promote', async () => {
});
it('should require admin', async () => {
const { user } = await seed();
const user = await buildUser();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
@ -96,7 +137,7 @@ describe('#user.demote', async () => {
});
it("shouldn't demote admins if only one available ", async () => {
const { admin } = await seed();
const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.demote', {
body: {
@ -111,7 +152,7 @@ describe('#user.demote', async () => {
});
it('should require admin', async () => {
const { user } = await seed();
const user = await buildUser();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), id: user.id },
});
@ -139,8 +180,7 @@ describe('#user.suspend', async () => {
});
it("shouldn't allow suspending the user themselves", async () => {
const { admin } = await seed();
const admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/user.suspend', {
body: {
token: admin.getJwtToken(),
@ -154,7 +194,7 @@ describe('#user.suspend', async () => {
});
it('should require admin', async () => {
const { user } = await seed();
const user = await buildUser();
const res = await server.post('/api/user.suspend', {
body: { token: user.getJwtToken(), id: user.id },
});
@ -187,7 +227,7 @@ describe('#user.activate', async () => {
});
it('should require admin', async () => {
const { user } = await seed();
const user = await buildUser();
const res = await server.post('/api/user.activate', {
body: { token: user.getJwtToken(), id: user.id },
});

View File

@ -0,0 +1,16 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'deletedAt', {
type: Sequelize.DATE,
allowNull: true
});
await queryInterface.addColumn('teams', 'deletedAt', {
type: Sequelize.DATE,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'deletedAt');
await queryInterface.removeColumn('teams', 'deletedAt');
}
}

View File

@ -0,0 +1,14 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'serviceId', {
type: Sequelize.STRING,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'serviceId', {
type: Sequelize.STRING,
allowNull: false,
});
}
}

View File

@ -31,4 +31,11 @@ const ApiKey = sequelize.define(
}
);
ApiKey.associate = models => {
ApiKey.belongsTo(models.User, {
as: 'user',
foreignKey: 'userId',
});
};
export default ApiKey;

View File

@ -141,8 +141,8 @@ Document.associate = models => {
{
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy' },
{ model: models.User, as: 'updatedBy' },
{ model: models.User, as: 'createdBy', paranoid: false },
{ model: models.User, as: 'updatedBy', paranoid: false },
],
where: {
publishedAt: {
@ -156,8 +156,8 @@ Document.associate = models => {
Document.addScope('withUnpublished', {
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy' },
{ model: models.User, as: 'updatedBy' },
{ model: models.User, as: 'createdBy', paranoid: false },
{ model: models.User, as: 'updatedBy', paranoid: false },
],
});
Document.addScope('withViews', userId => ({

View File

@ -6,6 +6,7 @@ import subMinutes from 'date-fns/sub_minutes';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import { sendEmail } from '../mailer';
import { Star, ApiKey } from '.';
const User = sequelize.define(
'user',
@ -25,13 +26,14 @@ const User = sequelize.define(
slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault('jwtSecret'),
lastActiveAt: DataTypes.DATE,
lastActiveIp: DataTypes.STRING,
lastActiveIp: { type: DataTypes.STRING, allowNull: true },
lastSignedInAt: DataTypes.DATE,
lastSignedInIp: DataTypes.STRING,
lastSignedInIp: { type: DataTypes.STRING, allowNull: true },
suspendedAt: DataTypes.DATE,
suspendedById: DataTypes.UUID,
},
{
paranoid: true,
getterMethods: {
isSuspended() {
return !!this.suspendedAt;
@ -91,6 +93,41 @@ const setRandomJwtSecret = model => {
model.jwtSecret = crypto.randomBytes(64).toString('hex');
};
const removeIdentifyingInfo = async model => {
await ApiKey.destroy({ where: { userId: model.id } });
await Star.destroy({ where: { userId: model.id } });
model.email = '';
model.name = 'Unknown';
model.avatarUrl = '';
model.serviceId = null;
model.username = null;
model.slackData = null;
model.lastActiveIp = null;
model.lastSignedInIp = null;
// this shouldn't be needed once this issue is resolved:
// https://github.com/sequelize/sequelize/issues/9318
await model.save({ hooks: false });
};
const checkLastAdmin = async model => {
const teamId = model.teamId;
if (model.isAdmin) {
const userCount = await User.count({ where: { teamId } });
const adminCount = await User.count({ where: { isAdmin: true, teamId } });
if (userCount > 1 && adminCount <= 1) {
throw new Error(
'Cannot delete account as only admin. Please transfer admin permissions to another user and try again.'
);
}
}
};
User.beforeDestroy(checkLastAdmin);
User.beforeDestroy(removeIdentifyingInfo);
User.beforeSave(uploadAvatar);
User.beforeCreate(setRandomJwtSecret);
User.afterCreate(user => sendEmail('welcome', user.email));