Account Deletion (#716)
Adds ability to remove user account, wipes personal information and soft-deletes record.
This commit is contained in:
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Collection from './Collection';
|
||||
export default Collection;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionDelete from './CollectionDelete';
|
||||
export default CollectionDelete;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionEdit from './CollectionEdit';
|
||||
export default CollectionEdit;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionExport from './CollectionExport';
|
||||
export default CollectionExport;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionNew from './CollectionNew';
|
||||
export default CollectionNew;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Dashboard from './Dashboard';
|
||||
export default Dashboard;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentDelete from './DocumentDelete';
|
||||
export default DocumentDelete;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentShare from './DocumentShare';
|
||||
export default DocumentShare;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Drafts from './Drafts';
|
||||
export default Drafts;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import ErrorSuspended from './ErrorSuspended';
|
||||
export default ErrorSuspended;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Home from './Home';
|
||||
export default Home;
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import KeyboardShortcuts from './KeyboardShortcuts';
|
||||
export default KeyboardShortcuts;
|
@ -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;
|
||||
`;
|
||||
|
@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Starred from './Starred';
|
||||
export default Starred;
|
62
app/scenes/UserDelete.js
Normal file
62
app/scenes/UserDelete.js
Normal 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);
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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 },
|
||||
});
|
||||
|
16
server/migrations/20180707220121-more-soft-delete.js
Normal file
16
server/migrations/20180707220121-more-soft-delete.js
Normal 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');
|
||||
}
|
||||
}
|
14
server/migrations/20180708231200-serviceid-null.js
Normal file
14
server/migrations/20180708231200-serviceid-null.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
@ -31,4 +31,11 @@ const ApiKey = sequelize.define(
|
||||
}
|
||||
);
|
||||
|
||||
ApiKey.associate = models => {
|
||||
ApiKey.belongsTo(models.User, {
|
||||
as: 'user',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
};
|
||||
|
||||
export default ApiKey;
|
||||
|
@ -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 => ({
|
||||
|
@ -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));
|
||||
|
Reference in New Issue
Block a user