Explorar el Código

Fix: Impersonate user can now export Excel/CSV/TSV/JSON.
Impersonate user and export Excel/CSV/TSV/JSON is now logged into
database table impersonatedUsers.

Thanks to xet7 !

Fixes #3827,
fixes #3284

Lauri Ojansivu hace 4 años
padre
commit
3908cd5413
Se han modificado 5 ficheros con 561 adiciones y 215 borrados
  1. 65 19
      models/export.js
  2. 29 17
      models/exportExcel.js
  3. 28 30
      models/exporter.js
  4. 79 0
      models/impersonatedUsers.js
  5. 360 149
      models/users.js

+ 65 - 19
models/export.js

@@ -22,21 +22,35 @@ if (Meteor.isServer) {
    * @param {string} boardId the ID of the board we are exporting
    * @param {string} authToken the loginToken
    */
-  JsonRoutes.add('get', '/api/boards/:boardId/export', function(req, res) {
+  JsonRoutes.add('get', '/api/boards/:boardId/export', function (req, res) {
     const boardId = req.params.boardId;
     let user = null;
+    let impersonateDone = false;
+    let adminId = null;
     const loginToken = req.query.authToken;
     if (loginToken) {
       const hashToken = Accounts._hashLoginToken(loginToken);
       user = Meteor.users.findOne({
         'services.resume.loginTokens.hashedToken': hashToken,
       });
+      adminId = user._id.toString();
+      impersonateDone = ImpersonatedUsers.findOne({
+        adminId: adminId,
+      });
     } else if (!Meteor.settings.public.sandstorm) {
       Authentication.checkUserId(req.userId);
       user = Users.findOne({ _id: req.userId, isAdmin: true });
     }
     const exporter = new Exporter(boardId);
-    if (exporter.canExport(user)) {
+    if (exporter.canExport(user) || impersonateDone) {
+      if (impersonateDone) {
+        ImpersonatedUsers.insert({
+          adminId: adminId,
+          boardId: boardId,
+          reason: 'exportJSON',
+        });
+      }
+
       JsonRoutes.sendResult(res, {
         code: 200,
         data: exporter.build(),
@@ -71,22 +85,36 @@ if (Meteor.isServer) {
   JsonRoutes.add(
     'get',
     '/api/boards/:boardId/attachments/:attachmentId/export',
-    function(req, res) {
+    function (req, res) {
       const boardId = req.params.boardId;
       const attachmentId = req.params.attachmentId;
       let user = null;
+      let impersonateDone = false;
+      let adminId = null;
       const loginToken = req.query.authToken;
       if (loginToken) {
         const hashToken = Accounts._hashLoginToken(loginToken);
         user = Meteor.users.findOne({
           'services.resume.loginTokens.hashedToken': hashToken,
         });
+        adminId = user._id.toString();
+        impersonateDone = ImpersonatedUsers.findOne({
+          adminId: adminId,
+        });
       } else if (!Meteor.settings.public.sandstorm) {
         Authentication.checkUserId(req.userId);
         user = Users.findOne({ _id: req.userId, isAdmin: true });
       }
       const exporter = new Exporter(boardId, attachmentId);
-      if (exporter.canExport(user)) {
+      if (exporter.canExport(user) || impersonateDone) {
+        if (impersonateDone) {
+          ImpersonatedUsers.insert({
+            adminId: adminId,
+            boardId: boardId,
+            attachmentId: attachmentId,
+            reason: 'exportJSONattachment',
+          });
+        }
         JsonRoutes.sendResult(res, {
           code: 200,
           data: exporter.build(),
@@ -114,15 +142,21 @@ if (Meteor.isServer) {
    * @param {string} authToken the loginToken
    * @param {string} delimiter delimiter to use while building export. Default is comma ','
    */
-  Picker.route('/api/boards/:boardId/export/csv', function(params, req, res) {
+  Picker.route('/api/boards/:boardId/export/csv', function (params, req, res) {
     const boardId = params.boardId;
     let user = null;
+    let impersonateDone = false;
+    let adminId = null;
     const loginToken = params.query.authToken;
     if (loginToken) {
       const hashToken = Accounts._hashLoginToken(loginToken);
       user = Meteor.users.findOne({
         'services.resume.loginTokens.hashedToken': hashToken,
       });
+      adminId = user._id.toString();
+      impersonateDone = ImpersonatedUsers.findOne({
+        adminId: adminId,
+      });
     } else if (!Meteor.settings.public.sandstorm) {
       Authentication.checkUserId(req.userId);
       user = Users.findOne({
@@ -131,19 +165,31 @@ if (Meteor.isServer) {
       });
     }
     const exporter = new Exporter(boardId);
-    //if (exporter.canExport(user)) {
-    body = params.query.delimiter
-      ? exporter.buildCsv(params.query.delimiter)
-      : exporter.buildCsv();
-    //'Content-Length': body.length,
-    res.writeHead(200, {
-      'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
-    });
-    res.write(body);
-    res.end();
-    //} else {
-    //  res.writeHead(403);
-    //  res.end('Permission Error');
-    //}
+    if (exporter.canExport(user) || impersonateDone) {
+      if (impersonateDone) {
+        // TODO: Checking for CSV or TSV export type does not work:
+        //   let exportType = 'export' + params.query.delimiter ? 'CSV' : 'TSV';
+        // So logging export to CSV:
+        let exportType = 'exportCSV';
+        ImpersonatedUsers.insert({
+          adminId: adminId,
+          boardId: boardId,
+          reason: exportType,
+        });
+      }
+
+      body = params.query.delimiter
+        ? exporter.buildCsv(params.query.delimiter)
+        : exporter.buildCsv();
+      //'Content-Length': body.length,
+      res.writeHead(200, {
+        'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
+      });
+      res.write(body);
+      res.end();
+    } else {
+      res.writeHead(403);
+      res.end('Permission Error');
+    }
   });
 }

+ 29 - 17
models/exportExcel.js

@@ -21,16 +21,21 @@ if (Meteor.isServer) {
    * @param {string} authToken the loginToken
    */
   const Excel = require('exceljs');
-  Picker.route('/api/boards/:boardId/exportExcel', function(params, req, res) {
+  Picker.route('/api/boards/:boardId/exportExcel', function (params, req, res) {
     const boardId = params.boardId;
     let user = null;
-
+    let impersonateDone = false;
+    let adminId = null;
     const loginToken = params.query.authToken;
     if (loginToken) {
       const hashToken = Accounts._hashLoginToken(loginToken);
       user = Meteor.users.findOne({
         'services.resume.loginTokens.hashedToken': hashToken,
       });
+      adminId = user._id.toString();
+      impersonateDone = ImpersonatedUsers.findOne({
+        adminId: adminId,
+      });
     } else if (!Meteor.settings.public.sandstorm) {
       Authentication.checkUserId(req.userId);
       user = Users.findOne({
@@ -39,7 +44,14 @@ if (Meteor.isServer) {
       });
     }
     const exporterExcel = new ExporterExcel(boardId);
-    if (exporterExcel.canExport(user)) {
+    if (exporterExcel.canExport(user) || impersonateDone) {
+      if (impersonateDone) {
+        ImpersonatedUsers.insert({
+          adminId: adminId,
+          boardId: boardId,
+          reason: 'exportExcel',
+        });
+      }
       exporterExcel.build(res);
     } else {
       res.end(TAPi18n.__('user-can-not-export-excel'));
@@ -108,7 +120,7 @@ export class ExporterExcel {
     result.subtaskItems = [];
     result.triggers = [];
     result.actions = [];
-    result.cards.forEach(card => {
+    result.cards.forEach((card) => {
       result.checklists.push(
         ...Checklists.find({
           cardId: card._id,
@@ -125,7 +137,7 @@ export class ExporterExcel {
         }).fetch(),
       );
     });
-    result.rules.forEach(rule => {
+    result.rules.forEach((rule) => {
       result.triggers.push(
         ...Triggers.find(
           {
@@ -149,32 +161,32 @@ export class ExporterExcel {
     // 1- only exports users that are linked somehow to that board
     // 2- do not export any sensitive information
     const users = {};
-    result.members.forEach(member => {
+    result.members.forEach((member) => {
       users[member.userId] = true;
     });
-    result.lists.forEach(list => {
+    result.lists.forEach((list) => {
       users[list.userId] = true;
     });
-    result.cards.forEach(card => {
+    result.cards.forEach((card) => {
       users[card.userId] = true;
       if (card.members) {
-        card.members.forEach(memberId => {
+        card.members.forEach((memberId) => {
           users[memberId] = true;
         });
       }
       if (card.assignees) {
-        card.assignees.forEach(memberId => {
+        card.assignees.forEach((memberId) => {
           users[memberId] = true;
         });
       }
     });
-    result.comments.forEach(comment => {
+    result.comments.forEach((comment) => {
       users[comment.userId] = true;
     });
-    result.activities.forEach(activity => {
+    result.activities.forEach((activity) => {
       users[activity.userId] = true;
     });
-    result.checklists.forEach(checklist => {
+    result.checklists.forEach((checklist) => {
       users[checklist.userId] = true;
     });
     const byUserIds = {
@@ -194,7 +206,7 @@ export class ExporterExcel {
     };
     result.users = Users.find(byUserIds, userFields)
       .fetch()
-      .map(user => {
+      .map((user) => {
         // user avatar is stored as a relative url, we export absolute
         if ((user.profile || {}).avatarUrl) {
           user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
@@ -389,7 +401,7 @@ export class ExporterExcel {
     const jlabel = {};
     var isFirst = 1;
     for (const klabel in result.labels) {
-      console.log(klabel);
+      // console.log(klabel);
       if (isFirst == 0) {
         jlabel[result.labels[klabel]._id] = `,${result.labels[klabel].name}`;
       } else {
@@ -589,7 +601,7 @@ export class ExporterExcel {
       //get parent name
       if (jcard.parentId) {
         const parentCard = result.cards.find(
-          card => card._id === jcard.parentId,
+          (card) => card._id === jcard.parentId,
         );
         jcard.parentCardTitle = parentCard ? parentCard.title : '';
       }
@@ -653,7 +665,7 @@ export class ExporterExcel {
         wrapText: true,
       };
     }
-    workbook.xlsx.write(res).then(function() {});
+    workbook.xlsx.write(res).then(function () {});
   }
 
   canExport(user) {

+ 28 - 30
models/exporter.js

@@ -38,7 +38,7 @@ export class Exporter {
     // [Old] for attachments we only export IDs and absolute url to original doc
     // [New] Encode attachment to base64
 
-    const getBase64Data = function(doc, callback) {
+    const getBase64Data = function (doc, callback) {
       let buffer = Buffer.allocUnsafe(0);
       buffer.fill(0);
 
@@ -49,14 +49,14 @@ export class Exporter {
       );
       const tmpWriteable = fs.createWriteStream(tmpFile);
       const readStream = doc.createReadStream();
-      readStream.on('data', function(chunk) {
+      readStream.on('data', function (chunk) {
         buffer = Buffer.concat([buffer, chunk]);
       });
 
-      readStream.on('error', function() {
+      readStream.on('error', function () {
         callback(null, null);
       });
-      readStream.on('end', function() {
+      readStream.on('end', function () {
         // done
         fs.unlink(tmpFile, () => {
           //ignored
@@ -72,7 +72,7 @@ export class Exporter {
       : byBoard;
     result.attachments = Attachments.find(byBoardAndAttachment)
       .fetch()
-      .map(attachment => {
+      .map((attachment) => {
         let filebase64 = null;
         filebase64 = getBase64DataSync(attachment);
 
@@ -105,7 +105,7 @@ export class Exporter {
     result.subtaskItems = [];
     result.triggers = [];
     result.actions = [];
-    result.cards.forEach(card => {
+    result.cards.forEach((card) => {
       result.checklists.push(
         ...Checklists.find({
           cardId: card._id,
@@ -122,7 +122,7 @@ export class Exporter {
         }).fetch(),
       );
     });
-    result.rules.forEach(rule => {
+    result.rules.forEach((rule) => {
       result.triggers.push(
         ...Triggers.find(
           {
@@ -146,27 +146,27 @@ export class Exporter {
     // 1- only exports users that are linked somehow to that board
     // 2- do not export any sensitive information
     const users = {};
-    result.members.forEach(member => {
+    result.members.forEach((member) => {
       users[member.userId] = true;
     });
-    result.lists.forEach(list => {
+    result.lists.forEach((list) => {
       users[list.userId] = true;
     });
-    result.cards.forEach(card => {
+    result.cards.forEach((card) => {
       users[card.userId] = true;
       if (card.members) {
-        card.members.forEach(memberId => {
+        card.members.forEach((memberId) => {
           users[memberId] = true;
         });
       }
     });
-    result.comments.forEach(comment => {
+    result.comments.forEach((comment) => {
       users[comment.userId] = true;
     });
-    result.activities.forEach(activity => {
+    result.activities.forEach((activity) => {
       users[activity.userId] = true;
     });
-    result.checklists.forEach(checklist => {
+    result.checklists.forEach((checklist) => {
       users[checklist.userId] = true;
     });
     const byUserIds = {
@@ -187,7 +187,7 @@ export class Exporter {
     };
     result.users = Users.find(byUserIds, userFields)
       .fetch()
-      .map(user => {
+      .map((user) => {
         // user avatar is stored as a relative url, we export absolute
         if ((user.profile || {}).avatarUrl) {
           user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
@@ -259,14 +259,14 @@ export class Exporter {
     );
     const customFieldMap = {};
     let i = 0;
-    result.customFields.forEach(customField => {
+    result.customFields.forEach((customField) => {
       customFieldMap[customField._id] = {
         position: i,
         type: customField.type,
       };
       if (customField.type === 'dropdown') {
         let options = '';
-        customField.settings.dropdownItems.forEach(item => {
+        customField.settings.dropdownItems.forEach((item) => {
           options = options === '' ? item.name : `${`${options}/${item.name}`}`;
         });
         columnHeaders.push(
@@ -308,7 +308,7 @@ export class Exporter {
     TAPi18n.__('archived'),
     */
 
-    result.cards.forEach(card => {
+    result.cards.forEach((card) => {
       const currentRow = [];
       currentRow.push(card.title);
       currentRow.push(card.description);
@@ -324,19 +324,19 @@ export class Exporter {
       currentRow.push(card.requestedBy ? card.requestedBy : ' ');
       currentRow.push(card.assignedBy ? card.assignedBy : ' ');
       let usernames = '';
-      card.members.forEach(memberId => {
+      card.members.forEach((memberId) => {
         const user = result.users.find(({ _id }) => _id === memberId);
         usernames = `${usernames + user.username} `;
       });
       currentRow.push(usernames.trim());
       let assignees = '';
-      card.assignees.forEach(assigneeId => {
+      card.assignees.forEach((assigneeId) => {
         const user = result.users.find(({ _id }) => _id === assigneeId);
         assignees = `${assignees + user.username} `;
       });
       currentRow.push(assignees.trim());
       let labels = '';
-      card.labelIds.forEach(labelId => {
+      card.labelIds.forEach((labelId) => {
         const label = result.labels.find(({ _id }) => _id === labelId);
         labels = `${labels + label.name}-${label.color} `;
       });
@@ -354,11 +354,11 @@ export class Exporter {
       if (card.vote && card.vote.question !== '') {
         let positiveVoters = '';
         let negativeVoters = '';
-        card.vote.positive.forEach(userId => {
+        card.vote.positive.forEach((userId) => {
           const user = result.users.find(({ _id }) => _id === userId);
           positiveVoters = `${positiveVoters + user.username} `;
         });
-        card.vote.negative.forEach(userId => {
+        card.vote.negative.forEach((userId) => {
           const user = result.users.find(({ _id }) => _id === userId);
           negativeVoters = `${negativeVoters + user.username} `;
         });
@@ -378,12 +378,11 @@ export class Exporter {
       currentRow.push(card.archived ? 'true' : 'false');
       //Custom fields
       const customFieldValuesToPush = new Array(result.customFields.length);
-      card.customFields.forEach(field => {
+      card.customFields.forEach((field) => {
         if (field.value !== null) {
           if (customFieldMap[field._id].type === 'date') {
-            customFieldValuesToPush[
-              customFieldMap[field._id].position
-            ] = moment(field.value).format();
+            customFieldValuesToPush[customFieldMap[field._id].position] =
+              moment(field.value).format();
           } else if (customFieldMap[field._id].type === 'dropdown') {
             const dropdownOptions = result.customFields.find(
               ({ _id }) => _id === field._id,
@@ -391,9 +390,8 @@ export class Exporter {
             const fieldValue = dropdownOptions.find(
               ({ _id }) => _id === field.value,
             ).name;
-            customFieldValuesToPush[
-              customFieldMap[field._id].position
-            ] = fieldValue;
+            customFieldValuesToPush[customFieldMap[field._id].position] =
+              fieldValue;
           } else {
             customFieldValuesToPush[customFieldMap[field._id].position] =
               field.value;

+ 79 - 0
models/impersonatedUsers.js

@@ -0,0 +1,79 @@
+ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
+
+/**
+ * A Impersonated User in wekan
+ */
+ImpersonatedUsers.attachSchema(
+  new SimpleSchema({
+    adminId: {
+      /**
+       * the admin userid that impersonates
+       */
+      type: String,
+      optional: true,
+    },
+    userId: {
+      /**
+       * the userId that is impersonated
+       */
+      type: String,
+      optional: true,
+    },
+    boardId: {
+      /**
+       * the boardId that was exported by anyone that has sometime impersonated
+       */
+      type: String,
+      optional: true,
+    },
+    attachmentId: {
+      /**
+       * the attachmentId that was exported by anyone that has sometime impersonated
+       */
+      type: String,
+      optional: true,
+    },
+    reason: {
+      /**
+       * the reason why impersonated, like exportJSON
+       */
+      type: String,
+      optional: true,
+    },
+    createdAt: {
+      /**
+       * creation date of the impersonation
+       */
+      type: Date,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert) {
+          return new Date();
+        } else if (this.isUpsert) {
+          return {
+            $setOnInsert: new Date(),
+          };
+        } else {
+          this.unset();
+        }
+      },
+    },
+    modifiedAt: {
+      /**
+       * modified date of the impersonation
+       */
+      type: Date,
+      denyUpdate: false,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert || this.isUpsert || this.isUpdate) {
+          return new Date();
+        } else {
+          this.unset();
+        }
+      },
+    },
+  }),
+);
+
+export default ImpersonatedUsers;

+ 360 - 149
models/users.js

@@ -1,4 +1,5 @@
 import { SyncedCron } from 'meteor/percolate:synced-cron';
+import ImpersonatedUsers from './impersonatedUsers';
 
 // Sandstorm context is detected using the METEOR_SETTINGS environment variable
 // in the package definition.
@@ -67,7 +68,9 @@ Users.attachSchema(
         if (this.isInsert) {
           return new Date();
         } else if (this.isUpsert) {
-          return { $setOnInsert: new Date() };
+          return {
+            $setOnInsert: new Date(),
+          };
         } else {
           this.unset();
         }
@@ -350,7 +353,9 @@ Users.attachSchema(
 
 Users.allow({
   update(userId, doc) {
-    const user = Users.findOne({ _id: userId });
+    const user = Users.findOne({
+      _id: userId,
+    });
     if ((user && user.isAdmin) || (Meteor.user() && Meteor.user().isAdmin))
       return true;
     if (!user) {
@@ -359,10 +364,18 @@ Users.allow({
     return doc._id === userId;
   },
   remove(userId, doc) {
-    const adminsNumber = Users.find({ isAdmin: true }).count();
+    const adminsNumber = Users.find({
+      isAdmin: true,
+    }).count();
     const { isAdmin } = Users.findOne(
-      { _id: userId },
-      { fields: { isAdmin: 1 } },
+      {
+        _id: userId,
+      },
+      {
+        fields: {
+          isAdmin: 1,
+        },
+      },
     );
 
     // Prevents remove of the only one administrator
@@ -440,7 +453,7 @@ if (Meteor.isClient) {
   });
 }
 
-Users.parseImportUsernames = usernamesString => {
+Users.parseImportUsernames = (usernamesString) => {
   return usernamesString.trim().split(new RegExp('\\s*[,;]\\s*'));
 };
 
@@ -454,17 +467,30 @@ Users.helpers({
 
   boards() {
     return Boards.find(
-      { 'members.userId': this._id },
-      { sort: { sort: 1 /* boards default sorting */ } },
+      {
+        'members.userId': this._id,
+      },
+      {
+        sort: {
+          sort: 1 /* boards default sorting */,
+        },
+      },
     );
   },
 
   starredBoards() {
     const { starredBoards = [] } = this.profile || {};
     return Boards.find(
-      { archived: false, _id: { $in: starredBoards } },
       {
-        sort: { sort: 1 /* boards default sorting */ },
+        archived: false,
+        _id: {
+          $in: starredBoards,
+        },
+      },
+      {
+        sort: {
+          sort: 1 /* boards default sorting */,
+        },
       },
     );
   },
@@ -477,9 +503,16 @@ Users.helpers({
   invitedBoards() {
     const { invitedBoards = [] } = this.profile || {};
     return Boards.find(
-      { archived: false, _id: { $in: invitedBoards } },
       {
-        sort: { sort: 1 /* boards default sorting */ },
+        archived: false,
+        _id: {
+          $in: invitedBoards,
+        },
+      },
+      {
+        sort: {
+          sort: 1 /* boards default sorting */,
+        },
       },
     );
   },
@@ -611,7 +644,9 @@ Users.helpers({
   },
 
   remove() {
-    User.remove({ _id: this._id });
+    User.remove({
+      _id: this._id,
+    });
   },
 });
 
@@ -714,7 +749,9 @@ Users.mutations({
   addNotification(activityId) {
     return {
       $addToSet: {
-        'profile.notifications': { activity: activityId },
+        'profile.notifications': {
+          activity: activityId,
+        },
       },
     };
   },
@@ -722,7 +759,9 @@ Users.mutations({
   removeNotification(activityId) {
     return {
       $pull: {
-        'profile.notifications': { activity: activityId },
+        'profile.notifications': {
+          activity: activityId,
+        },
       },
     };
   },
@@ -744,15 +783,27 @@ Users.mutations({
   },
 
   setAvatarUrl(avatarUrl) {
-    return { $set: { 'profile.avatarUrl': avatarUrl } };
+    return {
+      $set: {
+        'profile.avatarUrl': avatarUrl,
+      },
+    };
   },
 
   setShowCardsCountAt(limit) {
-    return { $set: { 'profile.showCardsCountAt': limit } };
+    return {
+      $set: {
+        'profile.showCardsCountAt': limit,
+      },
+    };
   },
 
   setStartDayOfWeek(startDay) {
-    return { $set: { 'profile.startDayOfWeek': startDay } };
+    return {
+      $set: {
+        'profile.startDayOfWeek': startDay,
+      },
+    };
   },
 
   setBoardView(view) {
@@ -801,15 +852,33 @@ if (Meteor.isServer) {
       if (Meteor.user() && Meteor.user().isAdmin) {
         // If setting is missing, add it
         Users.update(
-          { 'profile.hiddenSystemMessages': { $exists: false } },
-          { $set: { 'profile.hiddenSystemMessages': true } },
-          { multi: true },
+          {
+            'profile.hiddenSystemMessages': {
+              $exists: false,
+            },
+          },
+          {
+            $set: {
+              'profile.hiddenSystemMessages': true,
+            },
+          },
+          {
+            multi: true,
+          },
         );
         // If setting is false, set it to true
         Users.update(
-          { 'profile.hiddenSystemMessages': false },
-          { $set: { 'profile.hiddenSystemMessages': true } },
-          { multi: true },
+          {
+            'profile.hiddenSystemMessages': false,
+          },
+          {
+            $set: {
+              'profile.hiddenSystemMessages': true,
+            },
+          },
+          {
+            multi: true,
+          },
         );
         return true;
       } else {
@@ -836,8 +905,12 @@ if (Meteor.isServer) {
         check(email, String);
         check(importUsernames, Array);
 
-        const nUsersWithUsername = Users.find({ username }).count();
-        const nUsersWithEmail = Users.find({ email }).count();
+        const nUsersWithUsername = Users.find({
+          username,
+        }).count();
+        const nUsersWithEmail = Users.find({
+          email,
+        }).count();
         if (nUsersWithUsername > 0) {
           throw new Meteor.Error('username-already-taken');
         } else if (nUsersWithEmail > 0) {
@@ -851,7 +924,11 @@ if (Meteor.isServer) {
             email: email.toLowerCase(),
             from: 'admin',
           });
-          const user = Users.findOne(username) || Users.findOne({ username });
+          const user =
+            Users.findOne(username) ||
+            Users.findOne({
+              username,
+            });
           if (user) {
             Users.update(user._id, {
               $set: {
@@ -868,11 +945,17 @@ if (Meteor.isServer) {
       if (Meteor.user() && Meteor.user().isAdmin) {
         check(username, String);
         check(userId, String);
-        const nUsersWithUsername = Users.find({ username }).count();
+        const nUsersWithUsername = Users.find({
+          username,
+        }).count();
         if (nUsersWithUsername > 0) {
           throw new Meteor.Error('username-already-taken');
         } else {
-          Users.update(userId, { $set: { username } });
+          Users.update(userId, {
+            $set: {
+              username,
+            },
+          });
         }
       }
     },
@@ -883,8 +966,14 @@ if (Meteor.isServer) {
         }
         check(email, String);
         const existingUser = Users.findOne(
-          { 'emails.address': email },
-          { fields: { _id: 1 } },
+          {
+            'emails.address': email,
+          },
+          {
+            fields: {
+              _id: 1,
+            },
+          },
         );
         if (existingUser) {
           throw new Meteor.Error('email-already-taken');
@@ -963,7 +1052,9 @@ if (Meteor.isServer) {
         board &&
         board.members &&
         _.contains(_.pluck(board.members, 'userId'), inviter._id) &&
-        _.where(board.members, { userId: inviter._id })[0].isActive;
+        _.where(board.members, {
+          userId: inviter._id,
+        })[0].isActive;
       // GitHub issue 2060
       //_.where(board.members, { userId: inviter._id })[0].isAdmin;
       if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
@@ -973,22 +1064,39 @@ if (Meteor.isServer) {
       const posAt = username.indexOf('@');
       let user = null;
       if (posAt >= 0) {
-        user = Users.findOne({ emails: { $elemMatch: { address: username } } });
+        user = Users.findOne({
+          emails: {
+            $elemMatch: {
+              address: username,
+            },
+          },
+        });
       } else {
-        user = Users.findOne(username) || Users.findOne({ username });
+        user =
+          Users.findOne(username) ||
+          Users.findOne({
+            username,
+          });
       }
       if (user) {
         if (user._id === inviter._id)
           throw new Meteor.Error('error-user-notAllowSelf');
       } else {
         if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist');
-        if (Settings.findOne({ disableRegistration: true })) {
+        if (
+          Settings.findOne({
+            disableRegistration: true,
+          })
+        ) {
           throw new Meteor.Error('error-user-notCreated');
         }
         // Set in lowercase email before creating account
         const email = username.toLowerCase();
         username = email.substring(0, posAt);
-        const newUserId = Accounts.createUser({ username, email });
+        const newUserId = Accounts.createUser({
+          username,
+          email,
+        });
         if (!newUserId) throw new Meteor.Error('error-user-notCreated');
         // assume new user speak same language with inviter
         if (inviter.profile && inviter.profile.language) {
@@ -1032,7 +1140,10 @@ if (Meteor.isServer) {
       } catch (e) {
         throw new Meteor.Error('email-fail', e.message);
       }
-      return { username: user.username, email: user.emails[0].address };
+      return {
+        username: user.username,
+        email: user.emails[0].address,
+      };
     },
     impersonate(userId) {
       check(userId, String);
@@ -1042,8 +1153,16 @@ if (Meteor.isServer) {
       if (!Meteor.user().isAdmin)
         throw new Meteor.Error(403, 'Permission denied');
 
+      ImpersonatedUsers.insert({ adminId: Meteor.user()._id, userId: userId, reason: 'clickedImpersonate' });
       this.setUserId(userId);
     },
+    isImpersonated(userId) {
+      check(userId, String);
+      const isImpersonated = ImpersonatedUsers.findOne({
+        userId: userId,
+      });
+      return isImpersonated;
+    },
   });
   Accounts.onCreateUser((options, user) => {
     const userCount = Users.find().count();
@@ -1059,7 +1178,12 @@ if (Meteor.isServer) {
       }
       email = email.toLowerCase();
       user.username = user.services.oidc.username;
-      user.emails = [{ address: email, verified: true }];
+      user.emails = [
+        {
+          address: email,
+          verified: true,
+        },
+      ];
       const initials = user.services.oidc.fullname
         .split(/\s+/)
         .reduce((memo, word) => {
@@ -1075,7 +1199,14 @@ if (Meteor.isServer) {
 
       // see if any existing user has this email address or username, otherwise create new
       const existingUser = Meteor.users.findOne({
-        $or: [{ 'emails.address': email }, { username: user.username }],
+        $or: [
+          {
+            'emails.address': email,
+          },
+          {
+            username: user.username,
+          },
+        ],
       });
       if (!existingUser) return user;
 
@@ -1087,8 +1218,12 @@ if (Meteor.isServer) {
       existingUser.profile = user.profile;
       existingUser.authenticationMethod = user.authenticationMethod;
 
-      Meteor.users.remove({ _id: user._id });
-      Meteor.users.remove({ _id: existingUser._id }); // is going to be created again
+      Meteor.users.remove({
+        _id: user._id,
+      });
+      Meteor.users.remove({
+        _id: existingUser._id,
+      }); // is going to be created again
       return existingUser;
     }
 
@@ -1127,13 +1262,17 @@ if (Meteor.isServer) {
         "The invitation code doesn't exist",
       );
     } else {
-      user.profile = { icode: options.profile.invitationcode };
+      user.profile = {
+        icode: options.profile.invitationcode,
+      };
       user.profile.boardView = 'board-view-swimlanes';
 
       // Deletes the invitation code after the user was created successfully.
       setTimeout(
         Meteor.bindEnvironment(() => {
-          InvitationCodes.remove({ _id: invitationCode._id });
+          InvitationCodes.remove({
+            _id: invitationCode._id,
+          });
         }),
         200,
       );
@@ -1153,7 +1292,7 @@ const addCronJob = _.debounce(
 
     SyncedCron.add({
       name: 'notification_cleanup',
-      schedule: parser => parser.text('every 1 days'),
+      schedule: (parser) => parser.text('every 1 days'),
       job: () => {
         for (const user of Users.find()) {
           if (!user.profile || !user.profile.notifications) continue;
@@ -1178,15 +1317,19 @@ const addCronJob = _.debounce(
 if (Meteor.isServer) {
   // Let mongoDB ensure username unicity
   Meteor.startup(() => {
-    allowedSortValues.forEach(value => {
+    allowedSortValues.forEach((value) => {
       Lists._collection._ensureIndex(value);
     });
-    Users._collection._ensureIndex({ modifiedAt: -1 });
+    Users._collection._ensureIndex({
+      modifiedAt: -1,
+    });
     Users._collection._ensureIndex(
       {
         username: 1,
       },
-      { unique: true },
+      {
+        unique: true,
+      },
     );
     Meteor.defer(() => {
       addCronJob();
@@ -1215,7 +1358,7 @@ if (Meteor.isServer) {
   // counter.
   // We need to run this code on the server only, otherwise the incrementation
   // will be done twice.
-  Users.after.update(function(userId, user, fieldNames) {
+  Users.after.update(function (userId, user, fieldNames) {
     // The `starredBoards` list is hosted on the `profile` field. If this
     // field hasn't been modificated we don't need to run this hook.
     if (!_.contains(fieldNames, 'profile')) return;
@@ -1233,8 +1376,12 @@ if (Meteor.isServer) {
     // b. We use it to find deleted and newly inserted ids by using it in one
     // direction and then in the other.
     function incrementBoards(boardsIds, inc) {
-      boardsIds.forEach(boardId => {
-        Boards.update(boardId, { $inc: { stars: inc } });
+      boardsIds.forEach((boardId) => {
+        Boards.update(boardId, {
+          $inc: {
+            stars: inc,
+          },
+        });
       });
     }
 
@@ -1258,23 +1405,23 @@ if (Meteor.isServer) {
 
       fakeUserId.withValue(doc._id, () => {
         /*
-        // Insert the Welcome Board
-        Boards.insert({
-          title: TAPi18n.__('welcome-board'),
-          permission: 'private',
-        }, fakeUser, (err, boardId) => {
-
-          Swimlanes.insert({
-            title: TAPi18n.__('welcome-swimlane'),
-            boardId,
-            sort: 1,
-          }, fakeUser);
-
-          ['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
-            Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
-          });
-        });
-        */
+                // Insert the Welcome Board
+                Boards.insert({
+                  title: TAPi18n.__('welcome-board'),
+                  permission: 'private',
+                }, fakeUser, (err, boardId) => {
+
+                  Swimlanes.insert({
+                    title: TAPi18n.__('welcome-swimlane'),
+                    boardId,
+                    sort: 1,
+                  }, fakeUser);
+
+                  ['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
+                    Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
+                  });
+                });
+                */
 
         const Future = require('fibers/future');
         const future1 = new Future();
@@ -1290,7 +1437,9 @@ if (Meteor.isServer) {
           (err, boardId) => {
             // Insert the reference to our templates board
             Users.update(fakeUserId.get(), {
-              $set: { 'profile.templatesBoardId': boardId },
+              $set: {
+                'profile.templatesBoardId': boardId,
+              },
             });
 
             // Insert the card templates swimlane
@@ -1305,7 +1454,9 @@ if (Meteor.isServer) {
               (err, swimlaneId) => {
                 // Insert the reference to out card templates swimlane
                 Users.update(fakeUserId.get(), {
-                  $set: { 'profile.cardTemplatesSwimlaneId': swimlaneId },
+                  $set: {
+                    'profile.cardTemplatesSwimlaneId': swimlaneId,
+                  },
                 });
                 future1.return();
               },
@@ -1323,7 +1474,9 @@ if (Meteor.isServer) {
               (err, swimlaneId) => {
                 // Insert the reference to out list templates swimlane
                 Users.update(fakeUserId.get(), {
-                  $set: { 'profile.listTemplatesSwimlaneId': swimlaneId },
+                  $set: {
+                    'profile.listTemplatesSwimlaneId': swimlaneId,
+                  },
                 });
                 future2.return();
               },
@@ -1341,7 +1494,9 @@ if (Meteor.isServer) {
               (err, swimlaneId) => {
                 // Insert the reference to out board templates swimlane
                 Users.update(fakeUserId.get(), {
-                  $set: { 'profile.boardTemplatesSwimlaneId': swimlaneId },
+                  $set: {
+                    'profile.boardTemplatesSwimlaneId': swimlaneId,
+                  },
                 });
                 future3.return();
               },
@@ -1358,7 +1513,9 @@ if (Meteor.isServer) {
 
   Users.after.insert((userId, doc) => {
     // HACK
-    doc = Users.findOne({ _id: doc._id });
+    doc = Users.findOne({
+      _id: doc._id,
+    });
     if (doc.createdThroughApi) {
       // The admin user should be able to create a user despite disabling registration because
       // it is two different things (registration and creation).
@@ -1366,7 +1523,11 @@ if (Meteor.isServer) {
       // the disableRegistration check.
       // Issue : https://github.com/wekan/wekan/issues/1232
       // PR    : https://github.com/wekan/wekan/pull/1251
-      Users.update(doc._id, { $set: { createdThroughApi: '' } });
+      Users.update(doc._id, {
+        $set: {
+          createdThroughApi: '',
+        },
+      });
       return;
     }
 
@@ -1382,7 +1543,7 @@ if (Meteor.isServer) {
       if (!invitationCode) {
         throw new Meteor.Error('error-invitation-code-not-exist');
       } else {
-        invitationCode.boardsToBeInvited.forEach(boardId => {
+        invitationCode.boardsToBeInvited.forEach((boardId) => {
           const board = Boards.findOne(boardId);
           board.addMember(doc._id);
         });
@@ -1390,8 +1551,16 @@ if (Meteor.isServer) {
           doc.profile = {};
         }
         doc.profile.invitedBoards = invitationCode.boardsToBeInvited;
-        Users.update(doc._id, { $set: { profile: doc.profile } });
-        InvitationCodes.update(invitationCode._id, { $set: { valid: false } });
+        Users.update(doc._id, {
+          $set: {
+            profile: doc.profile,
+          },
+        });
+        InvitationCodes.update(invitationCode._id, {
+          $set: {
+            valid: false,
+          },
+        });
       }
     }
   });
@@ -1400,12 +1569,14 @@ if (Meteor.isServer) {
 // USERS REST API
 if (Meteor.isServer) {
   // Middleware which checks that API is enabled.
-  JsonRoutes.Middleware.use(function(req, res, next) {
+  JsonRoutes.Middleware.use(function (req, res, next) {
     const api = req.url.startsWith('/api');
     if ((api === true && process.env.WITH_API === 'true') || api === false) {
       return next();
     } else {
-      res.writeHead(301, { Location: '/' });
+      res.writeHead(301, {
+        Location: '/',
+      });
       return res.end();
     }
   });
@@ -1416,10 +1587,12 @@ if (Meteor.isServer) {
    * @summary returns the current user
    * @return_type Users
    */
-  JsonRoutes.add('GET', '/api/user', function(req, res) {
+  JsonRoutes.add('GET', '/api/user', function (req, res) {
     try {
       Authentication.checkLoggedIn(req.userId);
-      const data = Meteor.users.findOne({ _id: req.userId });
+      const data = Meteor.users.findOne({
+        _id: req.userId,
+      });
       delete data.services;
 
       // get all boards where the user is member of
@@ -1429,11 +1602,14 @@ if (Meteor.isServer) {
           'members.userId': req.userId,
         },
         {
-          fields: { _id: 1, members: 1 },
+          fields: {
+            _id: 1,
+            members: 1,
+          },
         },
       );
-      boards = boards.map(b => {
-        const u = b.members.find(m => m.userId === req.userId);
+      boards = boards.map((b) => {
+        const u = b.members.find((m) => m.userId === req.userId);
         delete u.userId;
         u.boardId = b._id;
         return u;
@@ -1461,13 +1637,16 @@ if (Meteor.isServer) {
    * @return_type [{ _id: string,
    *                 username: string}]
    */
-  JsonRoutes.add('GET', '/api/users', function(req, res) {
+  JsonRoutes.add('GET', '/api/users', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       JsonRoutes.sendResult(res, {
         code: 200,
-        data: Meteor.users.find({}).map(function(doc) {
-          return { _id: doc._id, username: doc.username };
+        data: Meteor.users.find({}).map(function (doc) {
+          return {
+            _id: doc._id,
+            username: doc.username,
+          };
         }),
       });
     } catch (error) {
@@ -1488,13 +1667,17 @@ if (Meteor.isServer) {
    * @param {string} userId the user ID or username
    * @return_type Users
    */
-  JsonRoutes.add('GET', '/api/users/:userId', function(req, res) {
+  JsonRoutes.add('GET', '/api/users/:userId', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       let id = req.params.userId;
-      let user = Meteor.users.findOne({ _id: id });
+      let user = Meteor.users.findOne({
+        _id: id,
+      });
       if (!user) {
-        user = Meteor.users.findOne({ username: id });
+        user = Meteor.users.findOne({
+          username: id,
+        });
         id = user._id;
       }
 
@@ -1505,11 +1688,14 @@ if (Meteor.isServer) {
           'members.userId': id,
         },
         {
-          fields: { _id: 1, members: 1 },
+          fields: {
+            _id: 1,
+            members: 1,
+          },
         },
       );
-      boards = boards.map(b => {
-        const u = b.members.find(m => m.userId === id);
+      boards = boards.map((b) => {
+        const u = b.members.find((m) => m.userId === id);
         delete u.userId;
         u.boardId = b._id;
         return u;
@@ -1545,12 +1731,14 @@ if (Meteor.isServer) {
    * @return_type {_id: string,
    *               title: string}
    */
-  JsonRoutes.add('PUT', '/api/users/:userId', function(req, res) {
+  JsonRoutes.add('PUT', '/api/users/:userId', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       const id = req.params.userId;
       const action = req.body.action;
-      let data = Meteor.users.findOne({ _id: id });
+      let data = Meteor.users.findOne({
+        _id: id,
+      });
       if (data !== undefined) {
         if (action === 'takeOwnership') {
           data = Boards.find(
@@ -1558,8 +1746,12 @@ if (Meteor.isServer) {
               'members.userId': id,
               'members.isAdmin': true,
             },
-            { sort: { sort: 1 /* boards default sorting */ } },
-          ).map(function(board) {
+            {
+              sort: {
+                sort: 1 /* boards default sorting */,
+              },
+            },
+          ).map(function (board) {
             if (board.hasMember(req.userId)) {
               board.removeMember(req.userId);
             }
@@ -1572,7 +1764,9 @@ if (Meteor.isServer) {
         } else {
           if (action === 'disableLogin' && id !== req.userId) {
             Users.update(
-              { _id: id },
+              {
+                _id: id,
+              },
               {
                 $set: {
                   loginDisabled: true,
@@ -1581,9 +1775,20 @@ if (Meteor.isServer) {
               },
             );
           } else if (action === 'enableLogin') {
-            Users.update({ _id: id }, { $set: { loginDisabled: '' } });
+            Users.update(
+              {
+                _id: id,
+              },
+              {
+                $set: {
+                  loginDisabled: '',
+                },
+              },
+            );
           }
-          data = Meteor.users.findOne({ _id: id });
+          data = Meteor.users.findOne({
+            _id: id,
+          });
         }
       }
       JsonRoutes.sendResult(res, {
@@ -1617,53 +1822,57 @@ if (Meteor.isServer) {
    * @return_type {_id: string,
    *               title: string}
    */
-  JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/add', function(
-    req,
-    res,
-  ) {
-    try {
-      Authentication.checkUserId(req.userId);
-      const userId = req.params.userId;
-      const boardId = req.params.boardId;
-      const action = req.body.action;
-      const { isAdmin, isNoComments, isCommentOnly } = req.body;
-      let data = Meteor.users.findOne({ _id: userId });
-      if (data !== undefined) {
-        if (action === 'add') {
-          data = Boards.find({
-            _id: boardId,
-          }).map(function(board) {
-            if (!board.hasMember(userId)) {
-              board.addMember(userId);
-              function isTrue(data) {
-                return data.toLowerCase() === 'true';
+  JsonRoutes.add(
+    'POST',
+    '/api/boards/:boardId/members/:userId/add',
+    function (req, res) {
+      try {
+        Authentication.checkUserId(req.userId);
+        const userId = req.params.userId;
+        const boardId = req.params.boardId;
+        const action = req.body.action;
+        const { isAdmin, isNoComments, isCommentOnly } = req.body;
+        let data = Meteor.users.findOne({
+          _id: userId,
+        });
+        if (data !== undefined) {
+          if (action === 'add') {
+            data = Boards.find({
+              _id: boardId,
+            }).map(function (board) {
+              if (!board.hasMember(userId)) {
+                board.addMember(userId);
+
+                function isTrue(data) {
+                  return data.toLowerCase() === 'true';
+                }
+                board.setMemberPermission(
+                  userId,
+                  isTrue(isAdmin),
+                  isTrue(isNoComments),
+                  isTrue(isCommentOnly),
+                  userId,
+                );
               }
-              board.setMemberPermission(
-                userId,
-                isTrue(isAdmin),
-                isTrue(isNoComments),
-                isTrue(isCommentOnly),
-                userId,
-              );
-            }
-            return {
-              _id: board._id,
-              title: board.title,
-            };
-          });
+              return {
+                _id: board._id,
+                title: board.title,
+              };
+            });
+          }
         }
+        JsonRoutes.sendResult(res, {
+          code: 200,
+          data: query,
+        });
+      } catch (error) {
+        JsonRoutes.sendResult(res, {
+          code: 200,
+          data: error,
+        });
       }
-      JsonRoutes.sendResult(res, {
-        code: 200,
-        data: query,
-      });
-    } catch (error) {
-      JsonRoutes.sendResult(res, {
-        code: 200,
-        data: error,
-      });
-    }
-  });
+    },
+  );
 
   /**
    * @operation remove_board_member
@@ -1682,18 +1891,20 @@ if (Meteor.isServer) {
   JsonRoutes.add(
     'POST',
     '/api/boards/:boardId/members/:userId/remove',
-    function(req, res) {
+    function (req, res) {
       try {
         Authentication.checkUserId(req.userId);
         const userId = req.params.userId;
         const boardId = req.params.boardId;
         const action = req.body.action;
-        let data = Meteor.users.findOne({ _id: userId });
+        let data = Meteor.users.findOne({
+          _id: userId,
+        });
         if (data !== undefined) {
           if (action === 'remove') {
             data = Boards.find({
               _id: boardId,
-            }).map(function(board) {
+            }).map(function (board) {
               if (board.hasMember(userId)) {
                 board.removeMember(userId);
               }
@@ -1729,7 +1940,7 @@ if (Meteor.isServer) {
    * @param {string} password the password of the new user
    * @return_type {_id: string}
    */
-  JsonRoutes.add('POST', '/api/users/', function(req, res) {
+  JsonRoutes.add('POST', '/api/users/', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       const id = Accounts.createUser({
@@ -1762,7 +1973,7 @@ if (Meteor.isServer) {
    * @param {string} userId the ID of the user to delete
    * @return_type {_id: string}
    */
-  JsonRoutes.add('DELETE', '/api/users/:userId', function(req, res) {
+  JsonRoutes.add('DELETE', '/api/users/:userId', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       const id = req.params.userId;
@@ -1800,7 +2011,7 @@ if (Meteor.isServer) {
    * @param {string} userId the ID of the user to create token for.
    * @return_type {_id: string}
    */
-  JsonRoutes.add('POST', '/api/createtoken/:userId', function(req, res) {
+  JsonRoutes.add('POST', '/api/createtoken/:userId', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
       const id = req.params.userId;