From 2d6f906b83bf4cc5932f99feb71397ab27440442 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 10 Jul 2018 21:05:01 -0700 Subject: [PATCH] Account Deletion (#716) Adds ability to remove user account, wipes personal information and soft-deletes record. --- app/scenes/{Collection => }/Collection.js | 0 app/scenes/Collection/index.js | 3 - .../CollectionDelete.js | 0 app/scenes/CollectionDelete/index.js | 3 - .../{CollectionEdit => }/CollectionEdit.js | 0 app/scenes/CollectionEdit/index.js | 3 - .../CollectionExport.js | 0 app/scenes/CollectionExport/index.js | 3 - .../{CollectionNew => }/CollectionNew.js | 0 app/scenes/CollectionNew/index.js | 3 - app/scenes/{Dashboard => }/Dashboard.js | 0 app/scenes/Dashboard/index.js | 3 - .../{DocumentDelete => }/DocumentDelete.js | 0 app/scenes/DocumentDelete/index.js | 3 - .../{DocumentShare => }/DocumentShare.js | 0 app/scenes/DocumentShare/index.js | 3 - app/scenes/{Drafts => }/Drafts.js | 0 app/scenes/Drafts/index.js | 3 - .../{ErrorSuspended => }/ErrorSuspended.js | 0 app/scenes/ErrorSuspended/index.js | 3 - app/scenes/{Home => }/Home.js | 0 app/scenes/Home/index.js | 3 - .../KeyboardShortcuts.js | 0 app/scenes/KeyboardShortcuts/index.js | 3 - app/scenes/Settings/Profile.js | 23 +++++++ app/scenes/{Starred => }/Starred.js | 0 app/scenes/Starred/index.js | 3 - app/scenes/UserDelete.js | 62 +++++++++++++++++++ app/stores/AuthStore.js | 11 ++++ server/api/__snapshots__/user.test.js.snap | 32 +++------- server/api/user.js | 18 ++++++ server/api/user.test.js | 62 +++++++++++++++---- .../20180707220121-more-soft-delete.js | 16 +++++ .../20180708231200-serviceid-null.js | 14 +++++ server/models/ApiKey.js | 7 +++ server/models/Document.js | 8 +-- server/models/User.js | 41 +++++++++++- 37 files changed, 254 insertions(+), 79 deletions(-) rename app/scenes/{Collection => }/Collection.js (100%) delete mode 100644 app/scenes/Collection/index.js rename app/scenes/{CollectionDelete => }/CollectionDelete.js (100%) delete mode 100644 app/scenes/CollectionDelete/index.js rename app/scenes/{CollectionEdit => }/CollectionEdit.js (100%) delete mode 100644 app/scenes/CollectionEdit/index.js rename app/scenes/{CollectionExport => }/CollectionExport.js (100%) delete mode 100644 app/scenes/CollectionExport/index.js rename app/scenes/{CollectionNew => }/CollectionNew.js (100%) delete mode 100644 app/scenes/CollectionNew/index.js rename app/scenes/{Dashboard => }/Dashboard.js (100%) delete mode 100644 app/scenes/Dashboard/index.js rename app/scenes/{DocumentDelete => }/DocumentDelete.js (100%) delete mode 100644 app/scenes/DocumentDelete/index.js rename app/scenes/{DocumentShare => }/DocumentShare.js (100%) delete mode 100644 app/scenes/DocumentShare/index.js rename app/scenes/{Drafts => }/Drafts.js (100%) delete mode 100644 app/scenes/Drafts/index.js rename app/scenes/{ErrorSuspended => }/ErrorSuspended.js (100%) delete mode 100644 app/scenes/ErrorSuspended/index.js rename app/scenes/{Home => }/Home.js (100%) delete mode 100644 app/scenes/Home/index.js rename app/scenes/{KeyboardShortcuts => }/KeyboardShortcuts.js (100%) delete mode 100644 app/scenes/KeyboardShortcuts/index.js rename app/scenes/{Starred => }/Starred.js (100%) delete mode 100644 app/scenes/Starred/index.js create mode 100644 app/scenes/UserDelete.js create mode 100644 server/migrations/20180707220121-more-soft-delete.js create mode 100644 server/migrations/20180708231200-serviceid-null.js diff --git a/app/scenes/Collection/Collection.js b/app/scenes/Collection.js similarity index 100% rename from app/scenes/Collection/Collection.js rename to app/scenes/Collection.js diff --git a/app/scenes/Collection/index.js b/app/scenes/Collection/index.js deleted file mode 100644 index c25fbb86..00000000 --- a/app/scenes/Collection/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Collection from './Collection'; -export default Collection; diff --git a/app/scenes/CollectionDelete/CollectionDelete.js b/app/scenes/CollectionDelete.js similarity index 100% rename from app/scenes/CollectionDelete/CollectionDelete.js rename to app/scenes/CollectionDelete.js diff --git a/app/scenes/CollectionDelete/index.js b/app/scenes/CollectionDelete/index.js deleted file mode 100644 index e86f5889..00000000 --- a/app/scenes/CollectionDelete/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionDelete from './CollectionDelete'; -export default CollectionDelete; diff --git a/app/scenes/CollectionEdit/CollectionEdit.js b/app/scenes/CollectionEdit.js similarity index 100% rename from app/scenes/CollectionEdit/CollectionEdit.js rename to app/scenes/CollectionEdit.js diff --git a/app/scenes/CollectionEdit/index.js b/app/scenes/CollectionEdit/index.js deleted file mode 100644 index 205ea254..00000000 --- a/app/scenes/CollectionEdit/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionEdit from './CollectionEdit'; -export default CollectionEdit; diff --git a/app/scenes/CollectionExport/CollectionExport.js b/app/scenes/CollectionExport.js similarity index 100% rename from app/scenes/CollectionExport/CollectionExport.js rename to app/scenes/CollectionExport.js diff --git a/app/scenes/CollectionExport/index.js b/app/scenes/CollectionExport/index.js deleted file mode 100644 index 025b78c9..00000000 --- a/app/scenes/CollectionExport/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionExport from './CollectionExport'; -export default CollectionExport; diff --git a/app/scenes/CollectionNew/CollectionNew.js b/app/scenes/CollectionNew.js similarity index 100% rename from app/scenes/CollectionNew/CollectionNew.js rename to app/scenes/CollectionNew.js diff --git a/app/scenes/CollectionNew/index.js b/app/scenes/CollectionNew/index.js deleted file mode 100644 index 651d8d5c..00000000 --- a/app/scenes/CollectionNew/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionNew from './CollectionNew'; -export default CollectionNew; diff --git a/app/scenes/Dashboard/Dashboard.js b/app/scenes/Dashboard.js similarity index 100% rename from app/scenes/Dashboard/Dashboard.js rename to app/scenes/Dashboard.js diff --git a/app/scenes/Dashboard/index.js b/app/scenes/Dashboard/index.js deleted file mode 100644 index d204b5e4..00000000 --- a/app/scenes/Dashboard/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Dashboard from './Dashboard'; -export default Dashboard; diff --git a/app/scenes/DocumentDelete/DocumentDelete.js b/app/scenes/DocumentDelete.js similarity index 100% rename from app/scenes/DocumentDelete/DocumentDelete.js rename to app/scenes/DocumentDelete.js diff --git a/app/scenes/DocumentDelete/index.js b/app/scenes/DocumentDelete/index.js deleted file mode 100644 index 90e2e111..00000000 --- a/app/scenes/DocumentDelete/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentDelete from './DocumentDelete'; -export default DocumentDelete; diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare.js similarity index 100% rename from app/scenes/DocumentShare/DocumentShare.js rename to app/scenes/DocumentShare.js diff --git a/app/scenes/DocumentShare/index.js b/app/scenes/DocumentShare/index.js deleted file mode 100644 index c480add3..00000000 --- a/app/scenes/DocumentShare/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentShare from './DocumentShare'; -export default DocumentShare; diff --git a/app/scenes/Drafts/Drafts.js b/app/scenes/Drafts.js similarity index 100% rename from app/scenes/Drafts/Drafts.js rename to app/scenes/Drafts.js diff --git a/app/scenes/Drafts/index.js b/app/scenes/Drafts/index.js deleted file mode 100644 index 70862787..00000000 --- a/app/scenes/Drafts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Drafts from './Drafts'; -export default Drafts; diff --git a/app/scenes/ErrorSuspended/ErrorSuspended.js b/app/scenes/ErrorSuspended.js similarity index 100% rename from app/scenes/ErrorSuspended/ErrorSuspended.js rename to app/scenes/ErrorSuspended.js diff --git a/app/scenes/ErrorSuspended/index.js b/app/scenes/ErrorSuspended/index.js deleted file mode 100644 index 9406c393..00000000 --- a/app/scenes/ErrorSuspended/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import ErrorSuspended from './ErrorSuspended'; -export default ErrorSuspended; diff --git a/app/scenes/Home/Home.js b/app/scenes/Home.js similarity index 100% rename from app/scenes/Home/Home.js rename to app/scenes/Home.js diff --git a/app/scenes/Home/index.js b/app/scenes/Home/index.js deleted file mode 100644 index 6726c754..00000000 --- a/app/scenes/Home/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Home from './Home'; -export default Home; diff --git a/app/scenes/KeyboardShortcuts/KeyboardShortcuts.js b/app/scenes/KeyboardShortcuts.js similarity index 100% rename from app/scenes/KeyboardShortcuts/KeyboardShortcuts.js rename to app/scenes/KeyboardShortcuts.js diff --git a/app/scenes/KeyboardShortcuts/index.js b/app/scenes/KeyboardShortcuts/index.js deleted file mode 100644 index ad40fe58..00000000 --- a/app/scenes/KeyboardShortcuts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import KeyboardShortcuts from './KeyboardShortcuts'; -export default KeyboardShortcuts; diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js index c71236bd..7a24e14a 100644 --- a/app/scenes/Settings/Profile.js +++ b/app/scenes/Settings/Profile.js @@ -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 { @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 { 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 { {isSaving ? 'Saving…' : 'Save'} + + + Delete Account +

+ You may delete your account at any time, note that this is + unrecoverable.{' '} + Delete account. +

+
+ {this.showDeleteModal && ( + + )} ); } } +const DangerZone = styled.div` + position: absolute; + bottom: 16px; +`; + const ProfilePicture = styled(Flex)` margin-bottom: 24px; `; diff --git a/app/scenes/Starred/Starred.js b/app/scenes/Starred.js similarity index 100% rename from app/scenes/Starred/Starred.js rename to app/scenes/Starred.js diff --git a/app/scenes/Starred/index.js b/app/scenes/Starred/index.js deleted file mode 100644 index 7d5e1f4a..00000000 --- a/app/scenes/Starred/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Starred from './Starred'; -export default Starred; diff --git a/app/scenes/UserDelete.js b/app/scenes/UserDelete.js new file mode 100644 index 00000000..00f33244 --- /dev/null +++ b/app/scenes/UserDelete.js @@ -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 { + @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 ( + + +
+ + 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. + + + Note: Signing back in will cause a new account to + be automatically reprovisioned. + + +
+
+
+ ); + } +} + +export default inject('auth')(UserDelete); diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index 52621025..ba9cd30d 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -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; diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 451c115c..ea9827a2 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -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 { diff --git a/server/api/user.js b/server/api/user.js index 0ab1d9a7..ad1fcb1a 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -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; diff --git a/server/api/user.test.js b/server/api/user.test.js index f714eac6..f2f7a5a0 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -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 }, }); diff --git a/server/migrations/20180707220121-more-soft-delete.js b/server/migrations/20180707220121-more-soft-delete.js new file mode 100644 index 00000000..fb179eeb --- /dev/null +++ b/server/migrations/20180707220121-more-soft-delete.js @@ -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'); + } +} \ No newline at end of file diff --git a/server/migrations/20180708231200-serviceid-null.js b/server/migrations/20180708231200-serviceid-null.js new file mode 100644 index 00000000..25af19bf --- /dev/null +++ b/server/migrations/20180708231200-serviceid-null.js @@ -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, + }); + } +} \ No newline at end of file diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index d2e11591..47d17096 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -31,4 +31,11 @@ const ApiKey = sequelize.define( } ); +ApiKey.associate = models => { + ApiKey.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); +}; + export default ApiKey; diff --git a/server/models/Document.js b/server/models/Document.js index 7bcb61d9..bf2c2052 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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 => ({ diff --git a/server/models/User.js b/server/models/User.js index 06303697..85e2397b 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -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));