Sfoglia il codice sorgente

Feature: Added brute force login protection settings to Admin Panel/People/Locked Users.
Added filtering of Admin Panel/People/People: All Users/Locked Users Only/Active/Not Active.
Added visual indicators: red lock icon for locked users, green check for active users, and red X for inactive users.
Added "Unlock All" button to quickly unlock all brute force locked users.
Added ability to toggle user active status directly from the People page.
Moved lockout settings from environment variables to database so admins can configure the lockout thresholds directly in the UI.

Thanks to xet7.

Lauri Ojansivu 1 giorno fa
parent
commit
ae0d059b6f

+ 47 - 0
client/components/settings/lockedUsersBody.css

@@ -0,0 +1,47 @@
+.text-red {
+  color: #e74c3c;
+}
+
+td i.fa-lock.text-red,
+li i.fa-lock.text-red {
+  margin-right: 5px;
+}
+
+.locked-users-table {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 15px 0;
+}
+
+.locked-users-table th,
+.locked-users-table td {
+  padding: 8px;
+  text-align: left;
+  border-bottom: 1px solid #ddd;
+}
+
+.locked-users-table th {
+  background-color: #f2f2f2;
+  font-weight: bold;
+}
+
+.locked-users-table tr:hover {
+  background-color: #f5f5f5;
+}
+
+.loading-indicator {
+  padding: 10px;
+  text-align: center;
+}
+
+.loading-indicator i {
+  margin-right: 5px;
+}
+
+.locked-users-settings {
+  padding: 0 10px;
+}
+
+button.js-unlock-all-users {
+  margin-bottom: 20px;
+}

+ 175 - 0
client/components/settings/lockedUsersBody.js

@@ -0,0 +1,175 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+import LockoutSettings from '/models/lockoutSettings';
+
+BlazeComponent.extendComponent({
+  onCreated() {
+    this.lockedUsers = new ReactiveVar([]);
+    this.isLoadingLockedUsers = new ReactiveVar(false);
+
+    // Don't load immediately to prevent unnecessary spinner
+    // The data will be loaded when the tab is selected in peopleBody.js switchMenu
+  },
+
+  refreshLockedUsers() {
+    // Set loading state initially, but we'll hide it if no users are found
+    this.isLoadingLockedUsers.set(true);
+
+    Meteor.call('getLockedUsers', (err, users) => {
+      if (err) {
+        this.isLoadingLockedUsers.set(false);
+        const reason = err.reason || '';
+        const message = `${TAPi18n.__(err.error)}\n${reason}`;
+        alert(message);
+        return;
+      }
+
+      // If no users are locked, don't show loading spinner and set empty array
+      if (!users || users.length === 0) {
+        this.isLoadingLockedUsers.set(false);
+        this.lockedUsers.set([]);
+        return;
+      }
+
+      // Format the remaining time to be more human-readable
+      users.forEach(user => {
+        if (user.remainingLockTime > 60) {
+          const minutes = Math.floor(user.remainingLockTime / 60);
+          const seconds = user.remainingLockTime % 60;
+          user.remainingTimeFormatted = `${minutes}m ${seconds}s`;
+        } else {
+          user.remainingTimeFormatted = `${user.remainingLockTime}s`;
+        }
+      });
+
+      this.lockedUsers.set(users);
+      this.isLoadingLockedUsers.set(false);
+    });
+  },
+
+  unlockUser(event) {
+    const userId = $(event.currentTarget).data('user-id');
+    if (!userId) return;
+
+    if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock'))) {
+      Meteor.call('unlockUser', userId, (err, result) => {
+        if (err) {
+          const reason = err.reason || '';
+          const message = `${TAPi18n.__(err.error)}\n${reason}`;
+          alert(message);
+          return;
+        }
+
+        if (result) {
+          alert(TAPi18n.__('accounts-lockout-user-unlocked'));
+          this.refreshLockedUsers();
+        }
+      });
+    }
+  },
+
+  unlockAllUsers() {
+    if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock-all'))) {
+      Meteor.call('unlockAllUsers', (err, result) => {
+        if (err) {
+          const reason = err.reason || '';
+          const message = `${TAPi18n.__(err.error)}\n${reason}`;
+          alert(message);
+          return;
+        }
+
+        if (result) {
+          alert(TAPi18n.__('accounts-lockout-user-unlocked'));
+          this.refreshLockedUsers();
+        }
+      });
+    }
+  },
+
+  saveLockoutSettings() {
+    // Get values from form
+    const knownFailuresBeforeLockout = parseInt($('#known-failures-before-lockout').val(), 10) || 3;
+    const knownLockoutPeriod = parseInt($('#known-lockout-period').val(), 10) || 60;
+    const knownFailureWindow = parseInt($('#known-failure-window').val(), 10) || 15;
+
+    const unknownFailuresBeforeLockout = parseInt($('#unknown-failures-before-lockout').val(), 10) || 3;
+    const unknownLockoutPeriod = parseInt($('#unknown-lockout-period').val(), 10) || 60;
+    const unknownFailureWindow = parseInt($('#unknown-failure-window').val(), 10) || 15;
+
+    // Update the database
+    LockoutSettings.update('known-failuresBeforeLockout', {
+      $set: { value: knownFailuresBeforeLockout },
+    });
+    LockoutSettings.update('known-lockoutPeriod', {
+      $set: { value: knownLockoutPeriod },
+    });
+    LockoutSettings.update('known-failureWindow', {
+      $set: { value: knownFailureWindow },
+    });
+
+    LockoutSettings.update('unknown-failuresBeforeLockout', {
+      $set: { value: unknownFailuresBeforeLockout },
+    });
+    LockoutSettings.update('unknown-lockoutPeriod', {
+      $set: { value: unknownLockoutPeriod },
+    });
+    LockoutSettings.update('unknown-failureWindow', {
+      $set: { value: unknownFailureWindow },
+    });
+
+    // Reload the AccountsLockout configuration
+    Meteor.call('reloadAccountsLockout', (err, ret) => {
+      if (!err && ret) {
+        const message = TAPi18n.__('accounts-lockout-settings-updated');
+        alert(message);
+      } else {
+        const reason = err?.reason || '';
+        const message = `${TAPi18n.__(err?.error || 'error-updating-settings')}\n${reason}`;
+        alert(message);
+      }
+    });
+  },
+
+  knownFailuresBeforeLockout() {
+    return LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3;
+  },
+
+  knownLockoutPeriod() {
+    return LockoutSettings.findOne('known-lockoutPeriod')?.value || 60;
+  },
+
+  knownFailureWindow() {
+    return LockoutSettings.findOne('known-failureWindow')?.value || 15;
+  },
+
+  unknownFailuresBeforeLockout() {
+    return LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3;
+  },
+
+  unknownLockoutPeriod() {
+    return LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60;
+  },
+
+  unknownFailureWindow() {
+    return LockoutSettings.findOne('unknown-failureWindow')?.value || 15;
+  },
+
+  lockedUsers() {
+    return this.lockedUsers.get();
+  },
+
+  isLoadingLockedUsers() {
+    return this.isLoadingLockedUsers.get();
+  },
+
+  events() {
+    return [
+      {
+        'click button.js-refresh-locked-users': this.refreshLockedUsers,
+        'click button#refreshLockedUsers': this.refreshLockedUsers,
+        'click button.js-unlock-user': this.unlockUser,
+        'click button.js-unlock-all-users': this.unlockAllUsers,
+        'click button.js-lockout-save': this.saveLockoutSettings,
+      },
+    ];
+  },
+}).register('lockedUsersGeneral');

+ 84 - 0
client/components/settings/peopleBody.css

@@ -89,3 +89,87 @@ table tr:nth-child(even) {
 #deleteAction {
 #deleteAction {
   margin-left: 5% !important;
   margin-left: 5% !important;
 }
 }
+
+.divLockedUsersFilter {
+  display: flex;
+  align-items: center;
+  margin: 0 15px;
+}
+
+.divLockedUsersFilter .flex-container {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.divLockedUsersFilter .people-filter {
+  margin-bottom: 0;
+  color: #777;
+  line-height: 34px;
+}
+
+.divLockedUsersFilter .user-filter {
+  border: 1px solid #ccc;
+  border-radius: 2px;
+  padding: 4px 8px;
+  background-color: white;
+}
+
+.unlock-all-btn {
+  margin-left: 15px;
+  background-color: #e67e22;
+  color: white;
+  border: none;
+  border-radius: 2px;
+  padding: 5px 10px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.unlock-all-btn:hover {
+  background-color: #d35400;
+}
+
+.account-active-status {
+  width: 20px;
+  text-align: center;
+}
+
+.js-toggle-active-status {
+  cursor: pointer;
+}
+
+.unlock-all-success {
+  position: fixed;
+  top: 10%;
+  left: 50%;
+  transform: translateX(-50%);
+  background-color: #27ae60;
+  color: white;
+  padding: 10px 20px;
+  border-radius: 4px;
+  z-index: 9999;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+  animation: fadeOut 3s ease-in forwards;
+}
+
+@keyframes fadeOut {
+  0% { opacity: 1; }
+  70% { opacity: 1; }
+  100% { opacity: 0; }
+}
+
+.account-status {
+  width: 20px;
+  text-align: center;
+}
+
+.text-green {
+  color: #27ae60;
+}
+
+.js-toggle-lock-status {
+  cursor: pointer;
+}

+ 65 - 0
client/components/settings/peopleBody.jade

@@ -38,12 +38,28 @@ template(name="people")
             button#searchButton
             button#searchButton
               i.fa.fa-search
               i.fa.fa-search
               | {{_ 'search'}}
               | {{_ 'search'}}
+            .divLockedUsersFilter
+              .flex-container
+                span.people-filter {{_ 'admin-people-filter-show'}}
+                select.user-filter#userFilterSelect
+                  option(value="all") {{_ 'admin-people-filter-all'}}
+                  option(value="locked") {{_ 'admin-people-filter-locked'}}
+                  option(value="active") {{_ 'admin-people-filter-active'}}
+                  option(value="inactive") {{_ 'admin-people-filter-inactive'}}
+              button#unlockAllUsers.unlock-all-btn
+                i.fa.fa-unlock
+                | {{_ 'accounts-lockout-unlock-all'}}
             .ext-box-right
             .ext-box-right
               span {{#unless isMiniScreen}}{{_ 'people-number'}}{{/unless}} #{peopleNumber}
               span {{#unless isMiniScreen}}{{_ 'people-number'}}{{/unless}} #{peopleNumber}
             .divAddOrRemoveTeam#divAddOrRemoveTeam
             .divAddOrRemoveTeam#divAddOrRemoveTeam
               button#addOrRemoveTeam
               button#addOrRemoveTeam
                 i.fa.fa-edit
                 i.fa.fa-edit
                 | {{_ 'add'}} / {{_ 'delete'}} {{_ 'teams'}}
                 | {{_ 'add'}} / {{_ 'delete'}} {{_ 'teams'}}
+          else if lockedUsersSetting.get
+            span
+              i.fa.fa-lock.text-red
+              unless isMiniScreen
+                | {{_ 'accounts-lockout-locked-users'}}
 
 
       .content-body
       .content-body
         .side-menu
         .side-menu
@@ -60,6 +76,10 @@ template(name="people")
               a.js-people-menu(data-id="people-setting")
               a.js-people-menu(data-id="people-setting")
                 i.fa.fa-user
                 i.fa.fa-user
                 | {{_ 'people'}}
                 | {{_ 'people'}}
+            li
+              a.js-locked-users-menu(data-id="locked-users-setting")
+                i.fa.fa-lock.text-red
+                | {{_ 'accounts-lockout-locked-users'}}
         .main-body
         .main-body
           if loading.get
           if loading.get
             +spinner
             +spinner
@@ -69,6 +89,8 @@ template(name="people")
             +teamGeneral
             +teamGeneral
           else if peopleSetting.get
           else if peopleSetting.get
             +peopleGeneral
             +peopleGeneral
+          else if lockedUsersSetting.get
+            +lockedUsersGeneral
 
 
 
 
 template(name="orgGeneral")
 template(name="orgGeneral")
@@ -114,6 +136,8 @@ template(name="peopleGeneral")
       tr
       tr
         th
         th
           +selectAllUser
           +selectAllUser
+        th {{_ 'accounts-lockout-status'}}
+        th {{_ 'admin-people-active-status'}}
         th {{_ 'username'}}
         th {{_ 'username'}}
         th {{_ 'fullname'}}
         th {{_ 'fullname'}}
         th {{_ 'initials'}}
         th {{_ 'initials'}}
@@ -232,8 +256,20 @@ template(name="peopleRow")
     else
     else
       td
       td
         input.selectUserChkBox(type="checkbox", id="{{userData._id}}")
         input.selectUserChkBox(type="checkbox", id="{{userData._id}}")
+    td.account-status
+      if isUserLocked
+        i.fa.fa-lock.text-red.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="true", title="{{_ 'accounts-lockout-click-to-unlock'}}")
+      else
+        i.fa.fa-unlock.text-green.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="false", title="{{_ 'accounts-lockout-user-unlocked'}}")
+    td.account-active-status
+      if userData.loginDisabled
+        i.fa.fa-ban.text-red.js-toggle-active-status(data-user-id=userData._id, data-is-active="false", title="{{_ 'admin-people-user-inactive'}}")
+      else
+        i.fa.fa-check-circle.text-green.js-toggle-active-status(data-user-id=userData._id, data-is-active="true", title="{{_ 'admin-people-user-active'}}")
     if userData.loginDisabled
     if userData.loginDisabled
       td.username <s>{{ userData.username }}</s>
       td.username <s>{{ userData.username }}</s>
+    else if isUserLocked
+      td.username {{ userData.username }}
     else
     else
       td.username {{ userData.username }}
       td.username {{ userData.username }}
     if userData.loginDisabled
     if userData.loginDisabled
@@ -645,3 +681,32 @@ template(name="settingsUserPopup")
   //   that does now remove member from board, card members and assignees correctly,
   //   that does now remove member from board, card members and assignees correctly,
   //   but that should be used to remove user from all boards similarly
   //   but that should be used to remove user from all boards similarly
   // - wekan/models/users.js Delete is not enabled
   // - wekan/models/users.js Delete is not enabled
+
+template(name="lockedUsersGeneral")
+  .locked-users-settings
+    h3 {{_ 'accounts-lockout-settings'}}
+    p {{_ 'accounts-lockout-info'}}
+
+    h4 {{_ 'accounts-lockout-known-users'}}
+    .title {{_ 'accounts-lockout-failures-before'}}
+    .form-group
+      input.wekan-form-control#known-failures-before-lockout(type="number", min="1", max="10", placeholder="3" value="{{knownFailuresBeforeLockout}}")
+    .title {{_ 'accounts-lockout-period'}}
+    .form-group
+      input.wekan-form-control#known-lockout-period(type="number", min="10", max="600", placeholder="60" value="{{knownLockoutPeriod}}")
+    .title {{_ 'accounts-lockout-failure-window'}}
+    .form-group
+      input.wekan-form-control#known-failure-window(type="number", min="1", max="60", placeholder="15" value="{{knownFailureWindow}}")
+
+    h4 {{_ 'accounts-lockout-unknown-users'}}
+    .title {{_ 'accounts-lockout-failures-before'}}
+    .form-group
+      input.wekan-form-control#unknown-failures-before-lockout(type="number", min="1", max="10", placeholder="3" value="{{unknownFailuresBeforeLockout}}")
+    .title {{_ 'accounts-lockout-period'}}
+    .form-group
+      input.wekan-form-control#unknown-lockout-period(type="number", min="10", max="600", placeholder="60" value="{{unknownLockoutPeriod}}")
+    .title {{_ 'accounts-lockout-failure-window'}}
+    .form-group
+      input.wekan-form-control#unknown-failure-window(type="number", min="1", max="60", placeholder="15" value="{{unknownFailureWindow}}")
+
+    button.js-lockout-save.primary {{_ 'save'}}

+ 146 - 7
client/components/settings/peopleBody.js

@@ -1,4 +1,5 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ReactiveCache } from '/imports/reactiveCache';
+import LockoutSettings from '/models/lockoutSettings';
 
 
 const orgsPerPage = 25;
 const orgsPerPage = 25;
 const teamsPerPage = 25;
 const teamsPerPage = 25;
@@ -14,14 +15,16 @@ BlazeComponent.extendComponent({
     this.error = new ReactiveVar('');
     this.error = new ReactiveVar('');
     this.loading = new ReactiveVar(false);
     this.loading = new ReactiveVar(false);
     this.orgSetting = new ReactiveVar(true);
     this.orgSetting = new ReactiveVar(true);
-    this.teamSetting = new ReactiveVar(true);
-    this.peopleSetting = new ReactiveVar(true);
+    this.teamSetting = new ReactiveVar(false);
+    this.peopleSetting = new ReactiveVar(false);
+    this.lockedUsersSetting = new ReactiveVar(false);
     this.findOrgsOptions = new ReactiveVar({});
     this.findOrgsOptions = new ReactiveVar({});
     this.findTeamsOptions = new ReactiveVar({});
     this.findTeamsOptions = new ReactiveVar({});
     this.findUsersOptions = new ReactiveVar({});
     this.findUsersOptions = new ReactiveVar({});
     this.numberOrgs = new ReactiveVar(0);
     this.numberOrgs = new ReactiveVar(0);
     this.numberTeams = new ReactiveVar(0);
     this.numberTeams = new ReactiveVar(0);
     this.numberPeople = new ReactiveVar(0);
     this.numberPeople = new ReactiveVar(0);
+    this.userFilterType = new ReactiveVar('all');
 
 
     this.page = new ReactiveVar(1);
     this.page = new ReactiveVar(1);
     this.loadNextPageLocked = false;
     this.loadNextPageLocked = false;
@@ -92,6 +95,34 @@ BlazeComponent.extendComponent({
             this.filterPeople();
             this.filterPeople();
           }
           }
         },
         },
+        'change #userFilterSelect'(event) {
+          const filterType = $(event.target).val();
+          this.userFilterType.set(filterType);
+          this.filterPeople();
+        },
+        'click #unlockAllUsers'(event) {
+          event.preventDefault();
+          if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock-all'))) {
+            Meteor.call('unlockAllUsers', (error) => {
+              if (error) {
+                console.error('Error unlocking all users:', error);
+              } else {
+                // Show a brief success message
+                const message = document.createElement('div');
+                message.className = 'unlock-all-success';
+                message.textContent = TAPi18n.__('accounts-lockout-all-users-unlocked');
+                document.body.appendChild(message);
+
+                // Remove the message after a short delay
+                setTimeout(() => {
+                  if (message.parentNode) {
+                    message.parentNode.removeChild(message);
+                  }
+                }, 3000);
+              }
+            });
+          }
+        },
         'click #newOrgButton'() {
         'click #newOrgButton'() {
           Popup.open('newOrg');
           Popup.open('newOrg');
         },
         },
@@ -104,23 +135,50 @@ BlazeComponent.extendComponent({
         'click a.js-org-menu': this.switchMenu,
         'click a.js-org-menu': this.switchMenu,
         'click a.js-team-menu': this.switchMenu,
         'click a.js-team-menu': this.switchMenu,
         'click a.js-people-menu': this.switchMenu,
         'click a.js-people-menu': this.switchMenu,
+        'click a.js-locked-users-menu': this.switchMenu,
       },
       },
     ];
     ];
   },
   },
   filterPeople() {
   filterPeople() {
     const value = $('#searchInput').first().val();
     const value = $('#searchInput').first().val();
-    if (value === '') {
-      this.findUsersOptions.set({});
-    } else {
+    const filterType = this.userFilterType.get();
+    const currentTime = Number(new Date());
+
+    let query = {};
+
+    // Apply text search filter if there's a search value
+    if (value !== '') {
       const regex = new RegExp(value, 'i');
       const regex = new RegExp(value, 'i');
-      this.findUsersOptions.set({
+      query = {
         $or: [
         $or: [
           { username: regex },
           { username: regex },
           { 'profile.fullname': regex },
           { 'profile.fullname': regex },
           { 'emails.address': regex },
           { 'emails.address': regex },
         ],
         ],
-      });
+      };
     }
     }
+
+    // Apply filter based on selected option
+    switch (filterType) {
+      case 'locked':
+        // Show only locked users
+        query['services.accounts-lockout.unlockTime'] = { $gt: currentTime };
+        break;
+      case 'active':
+        // Show only active users (loginDisabled is false or undefined)
+        query['loginDisabled'] = { $ne: true };
+        break;
+      case 'inactive':
+        // Show only inactive users (loginDisabled is true)
+        query['loginDisabled'] = true;
+        break;
+      case 'all':
+      default:
+        // Show all users, no additional filter
+        break;
+    }
+
+    this.findUsersOptions.set(query);
   },
   },
   loadNextPage() {
   loadNextPage() {
     if (this.loadNextPageLocked === false) {
     if (this.loadNextPageLocked === false) {
@@ -186,6 +244,16 @@ BlazeComponent.extendComponent({
       this.orgSetting.set('org-setting' === targetID);
       this.orgSetting.set('org-setting' === targetID);
       this.teamSetting.set('team-setting' === targetID);
       this.teamSetting.set('team-setting' === targetID);
       this.peopleSetting.set('people-setting' === targetID);
       this.peopleSetting.set('people-setting' === targetID);
+      this.lockedUsersSetting.set('locked-users-setting' === targetID);
+
+      // When switching to locked users tab, refresh the locked users list
+      if ('locked-users-setting' === targetID) {
+        // Find the lockedUsersGeneral component and call refreshLockedUsers
+        const lockedUsersComponent = Blaze.getView($('.main-body')[0])._templateInstance;
+        if (lockedUsersComponent && lockedUsersComponent.refreshLockedUsers) {
+          lockedUsersComponent.refreshLockedUsers();
+        }
+      }
     }
     }
   },
   },
 }).register('people');
 }).register('people');
@@ -206,8 +274,36 @@ Template.peopleRow.helpers({
   userData() {
   userData() {
     return ReactiveCache.getUser(this.userId);
     return ReactiveCache.getUser(this.userId);
   },
   },
+  isUserLocked() {
+    const user = ReactiveCache.getUser(this.userId);
+    if (!user) return false;
+
+    // Check if user has accounts-lockout with unlockTime property
+    if (user.services &&
+        user.services['accounts-lockout'] &&
+        user.services['accounts-lockout'].unlockTime) {
+
+      // Check if unlockTime is in the future
+      const currentTime = Number(new Date());
+      return user.services['accounts-lockout'].unlockTime > currentTime;
+    }
+
+    return false;
+  }
 });
 });
 
 
+// Initialize filter dropdown
+Template.people.rendered = function() {
+  const template = this;
+
+  // Set the initial value of the dropdown
+  Tracker.afterFlush(function() {
+    if (template.findAll('#userFilterSelect').length) {
+      $('#userFilterSelect').val('all');
+    }
+  });
+};
+
 Template.editUserPopup.onCreated(function () {
 Template.editUserPopup.onCreated(function () {
   this.authenticationMethods = new ReactiveVar([]);
   this.authenticationMethods = new ReactiveVar([]);
   this.errorMessage = new ReactiveVar('');
   this.errorMessage = new ReactiveVar('');
@@ -415,6 +511,49 @@ BlazeComponent.extendComponent({
             else
             else
               document.getElementById("divAddOrRemoveTeam").style.display = 'none';
               document.getElementById("divAddOrRemoveTeam").style.display = 'none';
         },
         },
+        'click .js-toggle-active-status': function(ev) {
+            ev.preventDefault();
+            const userId = this.userId;
+            const user = ReactiveCache.getUser(userId);
+
+            if (!user) return;
+
+            // Toggle loginDisabled status
+            const isActive = !(user.loginDisabled === true);
+
+            // Update the user's active status
+            Users.update(userId, {
+              $set: {
+                loginDisabled: isActive
+              }
+            });
+        },
+        'click .js-toggle-lock-status': function(ev){
+            ev.preventDefault();
+            const userId = this.userId;
+            const user = ReactiveCache.getUser(userId);
+
+            if (!user) return;
+
+            // Check if user is currently locked
+            const isLocked = user.services &&
+                user.services['accounts-lockout'] &&
+                user.services['accounts-lockout'].unlockTime &&
+                user.services['accounts-lockout'].unlockTime > Number(new Date());
+
+            if (isLocked) {
+              // Unlock the user
+              Meteor.call('unlockUser', userId, (error) => {
+                if (error) {
+                  console.error('Error unlocking user:', error);
+                }
+              });
+            } else {
+              // Lock the user - this is optional, you may want to only allow unlocking
+              // If you want to implement locking too, you would need a server method for it
+              // For now, we'll leave this as a no-op
+            }
+        },
       },
       },
     ];
     ];
   },
   },

+ 2 - 0
client/components/settings/settingBody.jade

@@ -171,6 +171,8 @@ template(name='accountSettings')
         label {{_ 'no'}}
         label {{_ 'no'}}
       button.js-accounts-save.primary {{_ 'save'}}
       button.js-accounts-save.primary {{_ 'save'}}
 
 
+    // Brute force lockout settings moved to People/Locked Users section
+
 template(name='announcementSettings')
 template(name='announcementSettings')
   ul#announcement-setting.setting-detail
   ul#announcement-setting.setting-detail
     li
     li

+ 13 - 3
client/components/settings/settingBody.js

@@ -1,6 +1,7 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ReactiveCache } from '/imports/reactiveCache';
 import { TAPi18n } from '/imports/i18n';
 import { TAPi18n } from '/imports/i18n';
 import { ALLOWED_WAIT_SPINNERS } from '/config/const';
 import { ALLOWED_WAIT_SPINNERS } from '/config/const';
+import LockoutSettings from '/models/lockoutSettings';
 
 
 BlazeComponent.extendComponent({
 BlazeComponent.extendComponent({
   onCreated() {
   onCreated() {
@@ -23,6 +24,7 @@ BlazeComponent.extendComponent({
     Meteor.subscribe('announcements');
     Meteor.subscribe('announcements');
     Meteor.subscribe('accessibilitySettings');
     Meteor.subscribe('accessibilitySettings');
     Meteor.subscribe('globalwebhooks');
     Meteor.subscribe('globalwebhooks');
+    Meteor.subscribe('lockoutSettings');
   },
   },
 
 
   setError(error) {
   setError(error) {
@@ -342,15 +344,23 @@ BlazeComponent.extendComponent({
       $set: { booleanValue: allowUserDelete },
       $set: { booleanValue: allowUserDelete },
     });
     });
   },
   },
+
+  // Brute force lockout settings method moved to lockedUsersBody.js
+
   allowEmailChange() {
   allowEmailChange() {
-    return AccountSettings.findOne('accounts-allowEmailChange').booleanValue;
+    return AccountSettings.findOne('accounts-allowEmailChange')?.booleanValue || false;
   },
   },
+
   allowUserNameChange() {
   allowUserNameChange() {
-    return AccountSettings.findOne('accounts-allowUserNameChange').booleanValue;
+    return AccountSettings.findOne('accounts-allowUserNameChange')?.booleanValue || false;
   },
   },
+
   allowUserDelete() {
   allowUserDelete() {
-    return AccountSettings.findOne('accounts-allowUserDelete').booleanValue;
+    return AccountSettings.findOne('accounts-allowUserDelete')?.booleanValue || false;
   },
   },
+
+  // Lockout settings helper methods moved to lockedUsersBody.js
+
   allBoardsHideActivities() {
   allBoardsHideActivities() {
     Meteor.call('setAllBoardsHideActivities', (err, ret) => {
     Meteor.call('setAllBoardsHideActivities', (err, ret) => {
       if (!err && ret) {
       if (!err && ret) {

+ 31 - 1
imports/i18n/data/en.i18n.json

@@ -1272,5 +1272,35 @@
   "accessibility-page-enabled": "Accessibility page enabled",
   "accessibility-page-enabled": "Accessibility page enabled",
   "accessibility-info-not-added-yet": "Accessibility info has not been added yet",
   "accessibility-info-not-added-yet": "Accessibility info has not been added yet",
   "accessibility-title": "Accessibility title",
   "accessibility-title": "Accessibility title",
-  "accessibility-content": "Accessibility content"
+  "accessibility-content": "Accessibility content",
+  "accounts-lockout-settings": "Brute Force Protection Settings",
+  "accounts-lockout-info": "These settings control how login attempts are protected against brute force attacks.",
+  "accounts-lockout-known-users": "Settings for known users (correct username, wrong password)",
+  "accounts-lockout-unknown-users": "Settings for unknown users (non-existent username)",
+  "accounts-lockout-failures-before": "Failures before lockout",
+  "accounts-lockout-period": "Lockout period (seconds)",
+  "accounts-lockout-failure-window": "Failure window (seconds)",
+  "accounts-lockout-settings-updated": "Brute force protection settings have been updated",
+  "accounts-lockout-locked-users": "Locked Users",
+  "accounts-lockout-locked-users-info": "Users currently locked out due to too many failed login attempts",
+  "accounts-lockout-no-locked-users": "There are currently no locked users",
+  "accounts-lockout-failed-attempts": "Failed Attempts",
+  "accounts-lockout-remaining-time": "Remaining Time",
+  "accounts-lockout-user-unlocked": "User has been unlocked successfully",
+  "accounts-lockout-confirm-unlock": "Are you sure you want to unlock this user?",
+  "accounts-lockout-confirm-unlock-all": "Are you sure you want to unlock all locked users?",
+  "accounts-lockout-show-locked-users": "Show locked users only",
+  "accounts-lockout-user-locked": "User is locked",
+  "accounts-lockout-click-to-unlock": "Click to unlock this user",
+  "accounts-lockout-status": "Status",
+  "admin-people-filter-show": "Show:",
+  "admin-people-filter-all": "All Users",
+  "admin-people-filter-locked": "Locked Users Only",
+  "admin-people-filter-active": "Active",
+  "admin-people-filter-inactive": "Not Active",
+  "admin-people-active-status": "Active Status",
+  "admin-people-user-active": "User is active - click to deactivate",
+  "admin-people-user-inactive": "User is inactive - click to activate",
+  "accounts-lockout-all-users-unlocked": "All locked users have been unlocked",
+  "accounts-lockout-unlock-all": "Unlock All"
 }
 }

+ 157 - 0
models/lockoutSettings.js

@@ -0,0 +1,157 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+LockoutSettings = new Mongo.Collection('lockoutSettings');
+
+LockoutSettings.attachSchema(
+  new SimpleSchema({
+    _id: {
+      type: String,
+    },
+    value: {
+      type: Number,
+      decimal: false,
+    },
+    category: {
+      type: String,
+    },
+    sort: {
+      type: Number,
+      decimal: true,
+    },
+    createdAt: {
+      type: Date,
+      optional: true,
+      // 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: {
+      type: Date,
+      denyUpdate: false,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert || this.isUpsert || this.isUpdate) {
+          return new Date();
+        } else {
+          this.unset();
+        }
+      },
+    },
+  }),
+);
+
+LockoutSettings.allow({
+  update(userId) {
+    const user = ReactiveCache.getUser(userId);
+    return user && user.isAdmin;
+  },
+});
+
+if (Meteor.isServer) {
+  Meteor.startup(() => {
+    LockoutSettings._collection.createIndex({ modifiedAt: -1 });
+
+    // Known users settings
+    LockoutSettings.upsert(
+      { _id: 'known-failuresBeforeLockout' },
+      {
+        $setOnInsert: {
+          value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE
+            ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE, 10) : 3,
+          category: 'known',
+          sort: 0,
+        },
+      },
+    );
+
+    LockoutSettings.upsert(
+      { _id: 'known-lockoutPeriod' },
+      {
+        $setOnInsert: {
+          value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD
+            ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD, 10) : 60,
+          category: 'known',
+          sort: 1,
+        },
+      },
+    );
+
+    LockoutSettings.upsert(
+      { _id: 'known-failureWindow' },
+      {
+        $setOnInsert: {
+          value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW
+            ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW, 10) : 15,
+          category: 'known',
+          sort: 2,
+        },
+      },
+    );
+
+    // Unknown users settings
+    const typoVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE;
+    const correctVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BEFORE;
+
+    LockoutSettings.upsert(
+      { _id: 'unknown-failuresBeforeLockout' },
+      {
+        $setOnInsert: {
+          value: (correctVar || typoVar)
+            ? parseInt(correctVar || typoVar, 10) : 3,
+          category: 'unknown',
+          sort: 0,
+        },
+      },
+    );
+
+    LockoutSettings.upsert(
+      { _id: 'unknown-lockoutPeriod' },
+      {
+        $setOnInsert: {
+          value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD
+            ? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD, 10) : 60,
+          category: 'unknown',
+          sort: 1,
+        },
+      },
+    );
+
+    LockoutSettings.upsert(
+      { _id: 'unknown-failureWindow' },
+      {
+        $setOnInsert: {
+          value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW
+            ? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW, 10) : 15,
+          category: 'unknown',
+          sort: 2,
+        },
+      },
+    );
+  });
+}
+
+LockoutSettings.helpers({
+  getKnownConfig() {
+    return {
+      failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3,
+      lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60,
+      failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15
+    };
+  },
+  getUnknownConfig() {
+    return {
+      failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3,
+      lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60,
+      failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15
+    };
+  }
+});
+
+export default LockoutSettings;

+ 33 - 0
server/accounts-lockout-config.js

@@ -0,0 +1,33 @@
+import { AccountsLockout } from 'meteor/wekan-accounts-lockout';
+import LockoutSettings from '/models/lockoutSettings';
+
+Meteor.startup(() => {
+  // Wait for the database to be ready
+  Meteor.setTimeout(() => {
+    try {
+      // Get configurations from database
+      const knownUsersConfig = {
+        failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3,
+        lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60,
+        failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15
+      };
+
+      const unknownUsersConfig = {
+        failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3,
+        lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60,
+        failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15
+      };
+
+      // Initialize the AccountsLockout with configuration
+      const accountsLockout = new AccountsLockout({
+        knownUsers: knownUsersConfig,
+        unknownUsers: unknownUsersConfig,
+      });
+
+      // Start the accounts lockout mechanism
+      accountsLockout.startup();
+    } catch (error) {
+      console.error('Failed to initialize accounts lockout:', error);
+    }
+  }, 2000); // Small delay to ensure database is ready
+});

+ 107 - 0
server/methods/lockedUsers.js

@@ -0,0 +1,107 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+// Method to find locked users and release them if needed
+Meteor.methods({
+  getLockedUsers() {
+    // Check if user has admin rights
+    const userId = Meteor.userId();
+    if (!userId) {
+      throw new Meteor.Error('error-invalid-user', 'Invalid user');
+    }
+    const user = ReactiveCache.getUser(userId);
+    if (!user || !user.isAdmin) {
+      throw new Meteor.Error('error-not-allowed', 'Not allowed');
+    }
+
+    // Current time to check against unlockTime
+    const currentTime = Number(new Date());
+
+    // Find users that are locked (known users)
+    const lockedUsers = Meteor.users.find(
+      {
+        'services.accounts-lockout.unlockTime': {
+          $gt: currentTime,
+        }
+      },
+      {
+        fields: {
+          _id: 1,
+          username: 1,
+          emails: 1,
+          'services.accounts-lockout.unlockTime': 1,
+          'services.accounts-lockout.failedAttempts': 1
+        }
+      }
+    ).fetch();
+
+    // Format the results for the UI
+    return lockedUsers.map(user => {
+      const email = user.emails && user.emails.length > 0 ? user.emails[0].address : 'No email';
+      const remainingLockTime = Math.round((user.services['accounts-lockout'].unlockTime - currentTime) / 1000);
+
+      return {
+        _id: user._id,
+        username: user.username || 'No username',
+        email,
+        failedAttempts: user.services['accounts-lockout'].failedAttempts || 0,
+        unlockTime: user.services['accounts-lockout'].unlockTime,
+        remainingLockTime // in seconds
+      };
+    });
+  },
+
+  unlockUser(userId) {
+    // Check if user has admin rights
+    const adminId = Meteor.userId();
+    if (!adminId) {
+      throw new Meteor.Error('error-invalid-user', 'Invalid user');
+    }
+    const admin = ReactiveCache.getUser(adminId);
+    if (!admin || !admin.isAdmin) {
+      throw new Meteor.Error('error-not-allowed', 'Not allowed');
+    }
+
+    // Make sure the user to unlock exists
+    const userToUnlock = Meteor.users.findOne(userId);
+    if (!userToUnlock) {
+      throw new Meteor.Error('error-user-not-found', 'User not found');
+    }
+
+    // Unlock the user
+    Meteor.users.update(
+      { _id: userId },
+      {
+        $unset: {
+          'services.accounts-lockout': 1
+        }
+      }
+    );
+
+    return true;
+  },
+
+  unlockAllUsers() {
+    // Check if user has admin rights
+    const adminId = Meteor.userId();
+    if (!adminId) {
+      throw new Meteor.Error('error-invalid-user', 'Invalid user');
+    }
+    const admin = ReactiveCache.getUser(adminId);
+    if (!admin || !admin.isAdmin) {
+      throw new Meteor.Error('error-not-allowed', 'Not allowed');
+    }
+
+    // Unlock all users
+    Meteor.users.update(
+      { 'services.accounts-lockout.unlockTime': { $exists: true } },
+      {
+        $unset: {
+          'services.accounts-lockout': 1
+        }
+      },
+      { multi: true }
+    );
+
+    return true;
+  }
+});

+ 46 - 0
server/methods/lockoutSettings.js

@@ -0,0 +1,46 @@
+import { AccountsLockout } from 'meteor/wekan-accounts-lockout';
+import { ReactiveCache } from '/imports/reactiveCache';
+import LockoutSettings from '/models/lockoutSettings';
+
+Meteor.methods({
+  reloadAccountsLockout() {
+    // Check if user has admin rights
+    const userId = Meteor.userId();
+    if (!userId) {
+      throw new Meteor.Error('error-invalid-user', 'Invalid user');
+    }
+    const user = ReactiveCache.getUser(userId);
+    if (!user || !user.isAdmin) {
+      throw new Meteor.Error('error-not-allowed', 'Not allowed');
+    }
+
+    try {
+      // Get configurations from database
+      const knownUsersConfig = {
+        failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3,
+        lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60,
+        failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15
+      };
+
+      const unknownUsersConfig = {
+        failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3,
+        lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60,
+        failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15
+      };
+
+      // Initialize the AccountsLockout with configuration
+      const accountsLockout = new AccountsLockout({
+        knownUsers: knownUsersConfig,
+        unknownUsers: unknownUsersConfig,
+      });
+
+      // Start the accounts lockout mechanism
+      accountsLockout.startup();
+
+      return true;
+    } catch (error) {
+      console.error('Failed to reload accounts lockout:', error);
+      throw new Meteor.Error('error-reloading-settings', 'Error reloading settings');
+    }
+  }
+});

+ 6 - 0
server/publications/lockoutSettings.js

@@ -0,0 +1,6 @@
+import LockoutSettings from '/models/lockoutSettings';
+
+Meteor.publish('lockoutSettings', function() {
+  const ret = LockoutSettings.find();
+  return ret;
+});