nodebb-plugin-admin-chats 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,8 @@ A professional administration plugin for NodeBB that allows administrators to mo
4
4
 
5
5
  ## Main Features
6
6
 
7
- * **Global Chat Access**: Administrators can view any private or group conversation.
8
- * **Management Privileges**: Grants admins the ability to edit or delete any message in any room.
7
+ * **Global Chat Access**: Administrators and users with chat view permission can view any private or group conversation.
8
+ * **Management Privileges**: Administrators and users with chat management permission can edit or delete any message in any room.
9
9
  * **Admin Room Locking**: Administrators can lock any chat room so only admins can continue replying.
10
10
  * **Profile Integration**: Adds a "View Chats" button to the user profile menu for administrators to quickly audit a user's interactions.
11
11
  * **Non-Intrusive Monitoring**: Admins can load and view room history without being added as permanent members, maintaining a clean member list.
@@ -16,7 +16,17 @@ A professional administration plugin for NodeBB that allows administrators to mo
16
16
  ### Plugin Information
17
17
  * **Name**: Super Admin Chat Control
18
18
  * **ID**: `nodebb-plugin-admin-chats`
19
- * **Compatibility**: NodeBB versions `^3.0.0` or `^4.0.0`
19
+ * **Compatibility**: NodeBB version `^4.0.0`
20
+
21
+ ### Permissions
22
+ The plugin exposes two global permissions in the ACP:
23
+
24
+ * `admin-chats:view`: View any chat room and load history (read-only access).
25
+ * `admin-chats:manage`: Manage any chat room (edit/delete messages, lock/unlock rooms, and other moderator-level actions).
26
+
27
+ ### Chat Route Behavior (`/chats`)
28
+ NodeBB exposes a core route at `/chats` (and `/chats/:roomId/:index?`) that redirects logged-in users to their personal chat page under `/user/:userslug/chats/...`.
29
+ This plugin also rewrites chat notification links to use `/user/:userslug/chats/...`, ensuring admins land on the correct user-scoped chat view when opening notifications.
20
30
 
21
31
  ### Implemented Hooks
22
32
  The plugin utilizes several filters to elevate administrator permissions:
@@ -37,6 +47,14 @@ The plugin overrides core messaging functions to ensure full administrative cont
37
47
 
38
48
  ## Installation
39
49
 
50
+ ### Option 1: Via Admin Control Panel (Recommended)
51
+ 1. Navigate to the NodeBB Admin Control Panel (ACP).
52
+ 2. Go to **Extend** → **Plugins**.
53
+ 3. Search for "nodebb-plugin-admin-chats".
54
+ 4. Click **Install**.
55
+ 5. Activate the plugin and restart NodeBB.
56
+
57
+ ### Option 2: Via Terminal
40
58
  1. Install the plugin via terminal:
41
59
  ```bash
42
60
  npm install nodebb-plugin-admin-chats
@@ -53,4 +71,4 @@ The plugin includes a `client.js` script that:
53
71
  * Cleans up the chat UI empty states for a better admin experience.
54
72
 
55
73
  ---
56
- *Developed by [palmoni5](https://github.com/palmoni5/nodebb-plugin-admin-chats).*
74
+ *Developed by [palmoni5](https://github.com/palmoni5/nodebb-plugin-admin-chats).*
package/client.js CHANGED
@@ -1,4 +1,6 @@
1
1
  $(document).ready(function() {
2
+ syncAdminAccessFromAjaxify();
3
+ fetchAdminPrivileges();
2
4
  const LOCK_PREFIX = '[admin-chat-lock]';
3
5
  const roomStateCache = new Map();
4
6
  const TEXT = {
@@ -25,12 +27,106 @@ $(document).ready(function() {
25
27
  function isEnglishSystem() {
26
28
  return $('html').attr('lang') && $('html').attr('lang').startsWith('en');
27
29
  }
28
-
29
30
  function t(key) {
30
31
  const dict = isEnglishSystem() ? TEXT.en : TEXT.he;
31
32
  return dict[key];
32
33
  }
33
34
 
35
+ function hasAdminChatsAccess() {
36
+ return !!((ajaxify && ajaxify.data && (ajaxify.data.adminChatsAccess || ajaxify.data.adminChatsCanManage)) || window.adminChatsAccess || window.adminChatsCanManage || (app.user && app.user.isAdmin));
37
+ }
38
+
39
+ function isAdminUser() {
40
+ return !!((ajaxify && ajaxify.data && ajaxify.data.adminChatsIsAdmin) || window.adminChatsIsAdmin || (app.user && app.user.isAdmin));
41
+ }
42
+
43
+ async function fetchAdminPrivileges() {
44
+ if (!hasAdminChatsAccess() || window.adminChatsPrivilegesPending) {
45
+ return;
46
+ }
47
+
48
+ window.adminChatsPrivilegesPending = true;
49
+ try {
50
+ const response = await fetch(`${config.relative_path || ''}/api/admin-chats/privileges`, {
51
+ headers: {
52
+ 'x-csrf-token': config.csrf_token,
53
+ },
54
+ credentials: 'same-origin',
55
+ });
56
+
57
+ if (!response.ok) {
58
+ return;
59
+ }
60
+
61
+ const payload = await response.json();
62
+ if (payload && typeof payload.adminChatsAccess === 'boolean') {
63
+ window.adminChatsAccess = payload.adminChatsAccess;
64
+ }
65
+ if (payload && typeof payload.adminChatsCanManage === 'boolean') {
66
+ window.adminChatsCanManage = payload.adminChatsCanManage;
67
+ }
68
+ if (payload && typeof payload.adminChatsIsAdmin === 'boolean') {
69
+ window.adminChatsIsAdmin = payload.adminChatsIsAdmin;
70
+ }
71
+ } catch (err) {
72
+ // ignore
73
+ } finally {
74
+ window.adminChatsPrivilegesPending = false;
75
+ }
76
+ }
77
+
78
+ function syncAdminAccessFromAjaxify() {
79
+ if (!ajaxify || !ajaxify.data) {
80
+ return;
81
+ }
82
+ if (typeof ajaxify.data.adminChatsAccess === 'boolean') {
83
+ window.adminChatsAccess = ajaxify.data.adminChatsAccess;
84
+ }
85
+ if (typeof ajaxify.data.adminChatsCanManage === 'boolean') {
86
+ window.adminChatsCanManage = ajaxify.data.adminChatsCanManage;
87
+ }
88
+ if (typeof ajaxify.data.adminChatsIsAdmin === 'boolean') {
89
+ window.adminChatsIsAdmin = ajaxify.data.adminChatsIsAdmin;
90
+ }
91
+ }
92
+
93
+ function canManageChats() {
94
+ return !!((ajaxify && ajaxify.data && ajaxify.data.adminChatsCanManage) || window.adminChatsCanManage || isAdminUser());
95
+ }
96
+
97
+ let adminChatsAccessProbe = null;
98
+
99
+ function resolveAdminChatsAccess() {
100
+ if (hasAdminChatsAccess()) {
101
+ return Promise.resolve(true);
102
+ }
103
+ if (window.adminChatsAccessChecked) {
104
+ return Promise.resolve(false);
105
+ }
106
+ if (adminChatsAccessProbe) {
107
+ return adminChatsAccessProbe;
108
+ }
109
+
110
+ adminChatsAccessProbe = fetch(`${config.relative_path || ''}/api/admin-chats?start=0`, {
111
+ headers: {
112
+ 'x-csrf-token': config.csrf_token,
113
+ },
114
+ credentials: 'same-origin',
115
+ }).then((response) => {
116
+ const ok = response && response.ok;
117
+ window.adminChatsAccess = ok;
118
+ return ok;
119
+ }).catch(() => {
120
+ window.adminChatsAccess = false;
121
+ return false;
122
+ }).finally(() => {
123
+ window.adminChatsAccessChecked = true;
124
+ adminChatsAccessProbe = null;
125
+ });
126
+
127
+ return adminChatsAccessProbe;
128
+ }
129
+
34
130
  // Get lockedAction message from server translations only (based on forum settings)
35
131
  function getLockedActionMessage() {
36
132
  if (window.adminChatsTranslations && window.adminChatsTranslations.lockedAction) {
@@ -160,12 +256,20 @@ $(document).ready(function() {
160
256
  Chats._adminAllChatsPatched = true;
161
257
  }
162
258
 
259
+ // Auto-open chat if roomId is specified in URL
260
+ const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
261
+ if (currentRoomId && !$('body').hasClass('chat-loaded')) {
262
+ setTimeout(function() {
263
+ Chats.switchChat(currentRoomId);
264
+ }, 100);
265
+ }
266
+
163
267
  bindAdminRecentChatsInfiniteScroll();
164
268
  });
165
269
  }
166
270
 
167
271
  function replaceAdminEmptyStateText() {
168
- if (!app.user.isAdmin) {
272
+ if (!hasAdminChatsAccess()) {
169
273
  return;
170
274
  }
171
275
 
@@ -239,19 +343,50 @@ $(document).ready(function() {
239
343
  credentials: 'same-origin',
240
344
  });
241
345
  if (!response.ok) {
346
+ if (hasAdminChatsAccess() && !isAdminAllChatsPage()) {
347
+ const adminResponse = await fetch(`${config.relative_path || ''}/api/admin-chats/page/${roomId}`, {
348
+ headers: {
349
+ 'x-csrf-token': config.csrf_token,
350
+ },
351
+ credentials: 'same-origin',
352
+ });
353
+ if (!adminResponse.ok) {
354
+ return null;
355
+ }
356
+ const adminPayload = await adminResponse.json();
357
+ const adminRoom = adminPayload && adminPayload.roomId ? adminPayload : (adminPayload && adminPayload.room ? adminPayload.room : null);
358
+ if (adminRoom) {
359
+ setCachedRoomData(roomId, adminRoom);
360
+ }
361
+ return adminRoom;
362
+ }
242
363
  return null;
243
364
  }
244
365
 
245
366
  const payload = await response.json();
246
367
  const roomData = payload && payload.roomId ? payload : (payload && payload.room ? payload.room : null);
368
+ if (roomData && !roomData.adminChatLock && hasAdminChatsAccess() && !isAdminAllChatsPage()) {
369
+ const adminResponse = await fetch(`${config.relative_path || ''}/api/admin-chats/page/${roomId}`, {
370
+ headers: {
371
+ 'x-csrf-token': config.csrf_token,
372
+ },
373
+ credentials: 'same-origin',
374
+ });
375
+ if (adminResponse.ok) {
376
+ const adminPayload = await adminResponse.json();
377
+ const adminRoom = adminPayload && adminPayload.roomId ? adminPayload : (adminPayload && adminPayload.room ? adminPayload.room : null);
378
+ if (adminRoom && adminRoom.adminChatLock) {
379
+ roomData.adminChatLock = adminRoom.adminChatLock;
380
+ }
381
+ }
382
+ }
247
383
  if (roomData) {
248
384
  setCachedRoomData(roomId, roomData);
249
385
  }
250
386
  return roomData;
251
387
  }
252
-
253
388
  function isLockedForUser(roomData) {
254
- return !!(roomData && roomData.adminChatLock && roomData.adminChatLock.isLocked && !(app.user && app.user.isAdmin));
389
+ return !!(roomData && roomData.adminChatLock && roomData.adminChatLock.isLocked && !canManageChats());
255
390
  }
256
391
 
257
392
  function setComposerHidden($window, hidden) {
@@ -306,6 +441,15 @@ $(document).ready(function() {
306
441
  .toggleClass('hidden', hidden);
307
442
  }
308
443
 
444
+ function updateDeleteRestoreVisibility($window) {
445
+ $window.find('[component="chat/message"]').each(function() {
446
+ const $message = $(this);
447
+ const isDeleted = $message.hasClass('deleted');
448
+ $message.find('[data-action="delete"]').toggleClass('hidden', isDeleted).toggle(!isDeleted);
449
+ $message.find('[data-action="restore"]').toggleClass('hidden', !isDeleted).toggle(isDeleted);
450
+ });
451
+ }
452
+
309
453
  function normalizeLockMessages($window) {
310
454
  $window.find('[component="chat/system-message"] > div').each(function() {
311
455
  const $el = $(this);
@@ -328,7 +472,7 @@ $(document).ready(function() {
328
472
  function renderAdminLockControl($window, roomData) {
329
473
  $window.find('.admin-chat-lock-toggle-item, .admin-chat-lock-divider, .admin-chat-lock-item-wrap').remove();
330
474
 
331
- if (!app.user.isAdmin || !roomData || !roomData.roomId) {
475
+ if (!canManageChats() || !roomData || !roomData.roomId) {
332
476
  return;
333
477
  }
334
478
 
@@ -374,6 +518,7 @@ $(document).ready(function() {
374
518
  const hidden = isLockedForUser(roomData);
375
519
  setComposerHidden($window, hidden);
376
520
  updateLockedActionVisibility($window, hidden);
521
+ updateDeleteRestoreVisibility($window);
377
522
  renderLockBanner($window, hidden);
378
523
  renderAdminLockControl($window, roomData);
379
524
  normalizeLockMessages($window);
@@ -444,33 +589,30 @@ $(document).ready(function() {
444
589
  }
445
590
 
446
591
  $(window).on('action:ajaxify.end', function(ev, data) {
592
+ syncAdminAccessFromAjaxify();
593
+ fetchAdminPrivileges();
447
594
  const templateName = ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '';
448
595
 
449
- if (app.user.isAdmin && templateName.startsWith('account/')) {
450
- const userSlug = ajaxify.data.userslug || (ajaxify.data.user && ajaxify.data.user.userslug);
451
-
452
- if (userSlug) {
453
- const relativePath = config.relative_path || '';
454
- const btnHtml = `
455
- <li role="presentation">
456
- <a class="dropdown-item rounded-1 d-flex align-items-center gap-2" href="${relativePath}/user/${userSlug}/chats" role="menuitem">
457
- <i class="far fa-fw fa-comments"></i>
458
- <span>${t('viewChats')}</span>
459
- </a>
460
- </li>
461
- <li role="presentation" class="dropdown-divider"></li>
462
- `;
463
- const menu = $('.account-sub-links');
464
- if (menu.length) {
465
- menu.find(`a[href*="/user/${userSlug}/chats"]`).parent().remove();
466
- menu.prepend(btnHtml);
467
- }
468
- }
596
+ if (templateName.startsWith('account/')) {
597
+ injectProfileChatsMenu();
598
+ setTimeout(injectProfileChatsMenu, 200);
469
599
  }
470
600
 
471
601
  if (isAdminAllChatsPage()) {
472
602
  patchForumChatsForAdminAll();
473
603
  bindAdminRecentChatsInfiniteScroll();
604
+
605
+ // Auto-open chat if roomId is specified in URL and chat is not already loaded
606
+ const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
607
+ if (currentRoomId && !$('body').hasClass('chat-loaded')) {
608
+ setTimeout(function() {
609
+ require(['forum/chats'], function(Chats) {
610
+ if (Chats && Chats.switchChat) {
611
+ Chats.switchChat(currentRoomId);
612
+ }
613
+ });
614
+ }, 200);
615
+ }
474
616
  }
475
617
 
476
618
  const url = data && data.url ? data.url : '';
@@ -478,6 +620,11 @@ $(document).ready(function() {
478
620
  refreshChatUi();
479
621
  setTimeout(refreshChatUi, 500);
480
622
  setTimeout(refreshChatUi, 1200);
623
+
624
+ // Handle chat navigation for internal URL changes
625
+ if (url.match(/^chats\/\d+/)) {
626
+ setTimeout(handleChatNavigation, 300);
627
+ }
481
628
  }
482
629
  });
483
630
 
@@ -485,6 +632,20 @@ $(document).ready(function() {
485
632
  if (isAdminAllChatsPage()) {
486
633
  patchForumChatsForAdminAll();
487
634
  bindAdminRecentChatsInfiniteScroll();
635
+
636
+ // Auto-open chat if roomId is specified in URL and no chat is currently active
637
+ const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
638
+ const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid');
639
+
640
+ if (currentRoomId && (!activeChatId || activeChatId !== String(currentRoomId))) {
641
+ setTimeout(function() {
642
+ require(['forum/chats'], function(Chats) {
643
+ if (Chats && Chats.switchChat) {
644
+ Chats.switchChat(currentRoomId);
645
+ }
646
+ });
647
+ }, 100);
648
+ }
488
649
  }
489
650
  refreshChatUi();
490
651
  setTimeout(refreshChatUi, 200);
@@ -499,6 +660,58 @@ $(document).ready(function() {
499
660
  setTimeout(refreshChatUi, 0);
500
661
  });
501
662
 
663
+ if (window.socket) {
664
+ socket.removeListener('event:chats.delete', refreshChatUi);
665
+ socket.removeListener('event:chats.restore', refreshChatUi);
666
+ socket.on('event:chats.delete', function() { setTimeout(refreshChatUi, 0); });
667
+ socket.on('event:chats.restore', function() { setTimeout(refreshChatUi, 0); });
668
+ }
669
+
670
+ // Handle browser back/forward navigation
671
+ $(window).on('popstate', function() {
672
+ if (isAdminAllChatsPage()) {
673
+ setTimeout(handleChatNavigation, 100);
674
+ }
675
+ });
676
+
677
+ // Handle URL changes for internal navigation
678
+ function handleChatNavigation() {
679
+ if (!isAdminAllChatsPage()) {
680
+ return;
681
+ }
682
+
683
+ // Try to get roomId from multiple sources
684
+ let currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
685
+
686
+ // If not found in ajaxify.data, try to extract from URL
687
+ if (!currentRoomId) {
688
+ const urlMatch = window.location.pathname.match(/\/chats\/(\d+)/);
689
+ if (urlMatch) {
690
+ currentRoomId = parseInt(urlMatch[1], 10);
691
+ }
692
+ }
693
+
694
+ const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid');
695
+
696
+ if (currentRoomId && (!activeChatId || activeChatId !== String(currentRoomId))) {
697
+ require(['forum/chats'], function(Chats) {
698
+ if (Chats && Chats.switchChat) {
699
+ Chats.switchChat(currentRoomId);
700
+ }
701
+ });
702
+ }
703
+ }
704
+
705
+ $(document).on('click', '[data-action="delete"], [data-action="restore"]', function() {
706
+ const $window = $(this).closest('[component="chat/message/window"]');
707
+ if (!$window.length) {
708
+ return;
709
+ }
710
+ setTimeout(function() {
711
+ updateDeleteRestoreVisibility($window);
712
+ }, 50);
713
+ });
714
+
502
715
  $(document).on('click', '.admin-chat-lock-toggle-item', async function(ev) {
503
716
  ev.preventDefault();
504
717
 
@@ -517,7 +730,7 @@ $(document).ready(function() {
517
730
  const currentRoom = ajaxify && ajaxify.data && (ajaxify.data.room || ajaxify.data);
518
731
  if (currentRoom && parseInt(currentRoom.roomId, 10) === roomId) {
519
732
  currentRoom.adminChatLock = result.lockData;
520
- currentRoom.canReply = !result.lockData.isLocked || (app.user && app.user.isAdmin);
733
+ currentRoom.canReply = !result.lockData.isLocked || canManageChats();
521
734
  currentRoom.showUserInput = currentRoom.canReply;
522
735
  if (ajaxify.data.room) {
523
736
  ajaxify.data.room.adminChatLock = result.lockData;
@@ -528,7 +741,7 @@ $(document).ready(function() {
528
741
 
529
742
  const cached = getCachedRoomData(roomId) || { roomId };
530
743
  cached.adminChatLock = result.lockData;
531
- cached.canReply = !result.lockData.isLocked || (app.user && app.user.isAdmin);
744
+ cached.canReply = !result.lockData.isLocked || canManageChats();
532
745
  cached.showUserInput = cached.canReply;
533
746
  setCachedRoomData(roomId, cached);
534
747
  await refreshChatUi();
@@ -552,4 +765,3 @@ $(document).ready(function() {
552
765
  app.alertError._adminChatsWrapped = true;
553
766
  }
554
767
  });
555
-
package/library.js CHANGED
@@ -11,6 +11,8 @@ const helpers = require.main.require('./src/controllers/helpers');
11
11
  const plugin = {};
12
12
  const LOCK_PREFIX = '[admin-chat-lock]';
13
13
  const LOCKED_MESSAGE_TEXT = '🔒 חדר זה ננעל ע"י המנהלים.';
14
+ const ADMIN_CHATS_PRIVILEGE = 'admin-chats:view';
15
+ const ADMIN_CHATS_MANAGE_PRIVILEGE = 'admin-chats:manage';
14
16
 
15
17
  async function getLockedActionMessage(uid) {
16
18
  try {
@@ -36,13 +38,121 @@ async function getLockedActionMessage(uid) {
36
38
  const ADMIN_CHAT_PAGE_SIZE = 30;
37
39
  const ADMIN_CHAT_SCAN_SIZE = 100;
38
40
 
41
+ async function canAccessAdminChats(uid) {
42
+ if (!uid) {
43
+ return false;
44
+ }
45
+
46
+ const [isAdmin, canView, canManage] = await Promise.all([
47
+ User.isAdministrator(uid),
48
+ privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
49
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
50
+ ]);
51
+
52
+ return isAdmin || canView || canManage;
53
+ }
54
+
55
+ async function canManageAdminChats(uid) {
56
+ if (!uid) {
57
+ return false;
58
+ }
59
+
60
+ const [isAdmin, canManage] = await Promise.all([
61
+ User.isAdministrator(uid),
62
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
63
+ ]);
64
+
65
+ return isAdmin || canManage;
66
+ }
67
+
68
+ async function isUserMemberOfRoom(uid, roomId) {
69
+ if (!uid || !roomId) {
70
+ return false;
71
+ }
72
+
73
+ return await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid);
74
+ }
39
75
  plugin.init = async function (params) {
40
76
  registerRoutes(params.app, params.router, params.middleware);
41
77
  overrideMessagingFunctions();
42
78
  overrideChatsApi();
43
79
  overrideCoreChatRedirect(params.controllers);
44
80
  };
81
+ plugin.registerPrivileges = async function (data) {
82
+ if (!data || !data.privileges || typeof data.privileges.set !== 'function') {
83
+ return data;
84
+ }
85
+
86
+ data.privileges.set(ADMIN_CHATS_PRIVILEGE, {
87
+ label: '[[admin-chats:admin-chats-view]]',
88
+ type: 'moderation',
89
+ });
90
+
91
+ data.privileges.set(ADMIN_CHATS_MANAGE_PRIVILEGE, {
92
+ label: '[[admin-chats:admin-chats-manage]]',
93
+ type: 'moderation',
94
+ });
95
+
96
+ return data;
97
+ };
45
98
 
99
+ plugin.filterNotificationCreate = async function (data) {
100
+ if (!data || !data.data) {
101
+ return data;
102
+ }
103
+
104
+ const notification = data.data;
105
+
106
+ // Check if this is a chat notification with path like /chats/123
107
+ if (notification.path && notification.path.match(/^\/chats\/\d+/)) {
108
+ const chatMatch = notification.path.match(/^\/chats\/(\d+)(?:\/(\d+))?$/);
109
+ if (chatMatch && notification && notification.uid) {
110
+ const roomId = chatMatch[1];
111
+ const index = chatMatch[2];
112
+
113
+ // Get the user's slug to redirect to their personal chat page
114
+ const userSlug = await User.getUserField(notification.uid, 'userslug');
115
+ if (userSlug) {
116
+ notification.path = `/user/${userSlug}/chats/${roomId}${index ? `/${index}` : ''}`;
117
+ }
118
+ }
119
+ }
120
+
121
+ return data;
122
+ };
123
+
124
+ plugin.filterUserNotificationsGetNotifications = async function (data) {
125
+ if (!data || !data.uid || !Array.isArray(data.notifications) || !data.notifications.length) {
126
+ return data;
127
+ }
128
+
129
+ const userSlug = await User.getUserField(data.uid, 'userslug');
130
+ if (!userSlug) {
131
+ return data;
132
+ }
133
+
134
+ const userPathRegex = /\/user\/[^/]+\/chats\//;
135
+ const chatPathRegex = /\/chats\/(\d+)(?:\/(\d+))?(?=$|[/?#])/;
136
+
137
+ data.notifications.forEach((notification) => {
138
+ if (!notification || !notification.path) {
139
+ return;
140
+ }
141
+ if (userPathRegex.test(notification.path)) {
142
+ return;
143
+ }
144
+ const match = notification.path.match(chatPathRegex);
145
+ if (!match) {
146
+ return;
147
+ }
148
+ const roomId = match[1];
149
+ const index = match[2];
150
+ const replacement = `/user/${userSlug}/chats/${roomId}${index ? `/${index}` : ''}`;
151
+ notification.path = notification.path.replace(chatPathRegex, replacement);
152
+ });
153
+
154
+ return data;
155
+ };
46
156
  plugin.addScripts = async function (data) {
47
157
  let isHebrew = false;
48
158
 
@@ -70,10 +180,14 @@ plugin.addScripts = async function (data) {
70
180
  lockedAction: isHebrew ? 'לא ניתן לבצע פעולה זו בחדר נעול' : 'This action cannot be performed in a locked room.',
71
181
  };
72
182
 
183
+ const isAdmin = uid ? await User.isAdministrator(uid) : false;
184
+ const adminChatsManage = uid ? (isAdmin || await privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid)) : false;
185
+ const adminChatsAccess = uid ? (isAdmin || adminChatsManage || await privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid)) : false;
186
+
73
187
  data.scripts = data.scripts || [];
74
188
  data.scripts.push({
75
189
  src: false,
76
- script: `window.adminChatsTranslations = ${JSON.stringify(translations)};`
190
+ script: `window.adminChatsTranslations = ${JSON.stringify(translations)}; window.adminChatsAccess = ${adminChatsAccess}; window.adminChatsIsAdmin = ${isAdmin}; window.adminChatsCanManage = ${adminChatsManage};`
77
191
  });
78
192
 
79
193
  return data;
@@ -81,6 +195,11 @@ plugin.addScripts = async function (data) {
81
195
 
82
196
  plugin.addProfileLink = async function (data) {
83
197
  try {
198
+ const uid = data && (data.uid || (data.req && data.req.uid));
199
+ if (!uid || !await canAccessAdminChats(uid)) {
200
+ return data;
201
+ }
202
+
84
203
  const userSlug = data.user ? data.user.userslug : (data.userData ? data.userData.userslug : null);
85
204
 
86
205
  if (userSlug) {
@@ -88,12 +207,12 @@ plugin.addProfileLink = async function (data) {
88
207
  id: 'admin-view-chats',
89
208
  route: 'user/' + userSlug + '/chats',
90
209
  icon: 'fa-comments',
91
- name: 'צפה בצ\'אטים',
210
+ name: '[[admin-chats:profile.viewChats]]',
92
211
  visibility: {
93
- self: false,
212
+ self: true,
94
213
  other: true,
95
- moderator: false,
96
- globalMod: false,
214
+ moderator: true,
215
+ globalMod: true,
97
216
  admin: true,
98
217
  }
99
218
  });
@@ -105,16 +224,19 @@ plugin.addProfileLink = async function (data) {
105
224
  };
106
225
 
107
226
  plugin.isRoomOwner = async function (payload) {
108
- const isAdmin = await User.isAdministrator(payload.uid);
109
- if (isAdmin) {
227
+ const [isAdmin, canManage] = await Promise.all([
228
+ User.isAdministrator(payload.uid),
229
+ canManageAdminChats(payload.uid),
230
+ ]);
231
+ if (isAdmin || canManage) {
110
232
  payload.isOwner = true;
111
233
  }
112
234
  return payload;
113
235
  };
114
236
 
115
237
  plugin.isUserInRoom = async function (payload) {
116
- const isAdmin = await User.isAdministrator(payload.uid);
117
- if (isAdmin) {
238
+ const uid = getCallerUid(payload);
239
+ if (await canAccessAdminChats(uid)) {
118
240
  payload.inRoom = true;
119
241
  }
120
242
  return payload;
@@ -123,13 +245,28 @@ plugin.isUserInRoom = async function (payload) {
123
245
  plugin.canReply = async function (payload) {
124
246
  const uid = getCallerUid(payload);
125
247
  const roomId = getRoomId(payload);
126
- const isAdmin = await User.isAdministrator(uid);
127
248
 
128
- if (isAdmin) {
249
+ const [isAdmin, canView, canManage] = await Promise.all([
250
+ User.isAdministrator(uid),
251
+ privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
252
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
253
+ ]);
254
+ const isManager = isAdmin || canManage;
255
+ const hasAccess = isAdmin || canView || canManage;
256
+
257
+ if (isManager) {
129
258
  payload.canReply = true;
130
259
  return payload;
131
260
  }
132
261
 
262
+ if (roomId && hasAccess) {
263
+ const isMember = await isUserMemberOfRoom(uid, roomId);
264
+ if (!isMember) {
265
+ payload.canReply = false;
266
+ return payload;
267
+ }
268
+ }
269
+
133
270
  if (roomId && await isRoomLocked(roomId)) {
134
271
  payload.canReply = false;
135
272
  }
@@ -149,6 +286,17 @@ plugin.filterMessagingSend = async function (payload) {
149
286
  return payload;
150
287
  }
151
288
 
289
+ if (await canManageAdminChats(uid)) {
290
+ return payload;
291
+ }
292
+
293
+ if (await canAccessAdminChats(uid)) {
294
+ const isMember = await isUserMemberOfRoom(uid, roomId);
295
+ if (!isMember) {
296
+ throw new Error('[[error:no-privileges]]');
297
+ }
298
+ }
299
+
152
300
  if (await isRoomLocked(roomId)) {
153
301
  const message = await getLockedActionMessage(uid);
154
302
  throw new Error(message);
@@ -157,6 +305,9 @@ plugin.filterMessagingSend = async function (payload) {
157
305
  return payload;
158
306
  };
159
307
  plugin.filterAddUsersToRoom = async function (payload) {
308
+ if (await canManageAdminChats(payload.uid)) {
309
+ return payload;
310
+ }
160
311
  if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
161
312
  const message = await getLockedActionMessage(payload.uid);
162
313
  throw new Error(message);
@@ -165,6 +316,9 @@ plugin.filterAddUsersToRoom = async function (payload) {
165
316
  };
166
317
 
167
318
  plugin.filterRemoveUsersFromRoom = async function (payload) {
319
+ if (await canManageAdminChats(payload.uid)) {
320
+ return payload;
321
+ }
168
322
  if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
169
323
  const message = await getLockedActionMessage(payload.uid);
170
324
  throw new Error(message);
@@ -173,6 +327,9 @@ plugin.filterRemoveUsersFromRoom = async function (payload) {
173
327
  };
174
328
 
175
329
  plugin.filterRenameRoom = async function (payload) {
330
+ if (await canManageAdminChats(payload.uid)) {
331
+ return payload;
332
+ }
176
333
  if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
177
334
  const message = await getLockedActionMessage(payload.uid);
178
335
  throw new Error(message);
@@ -181,24 +338,21 @@ plugin.filterRenameRoom = async function (payload) {
181
338
  };
182
339
 
183
340
  plugin.canGetMessages = async function (payload) {
184
- const isAdmin = await User.isAdministrator(payload.callerUid);
185
- if (isAdmin) {
341
+ if (await canAccessAdminChats(payload.callerUid)) {
186
342
  payload.canGet = true;
187
343
  }
188
344
  return payload;
189
345
  };
190
346
 
191
347
  plugin.canGetRecentChats = async function (payload) {
192
- const isAdmin = await User.isAdministrator(payload.callerUid);
193
- if (isAdmin) {
348
+ if (await canAccessAdminChats(payload.callerUid)) {
194
349
  payload.canGet = true;
195
350
  }
196
351
  return payload;
197
352
  };
198
353
 
199
354
  plugin.canGetPublicChats = async function (payload) {
200
- const isAdmin = await User.isAdministrator(payload.callerUid);
201
- if (isAdmin) {
355
+ if (await canAccessAdminChats(payload.callerUid)) {
202
356
  payload.canGet = true;
203
357
  }
204
358
  return payload;
@@ -210,20 +364,27 @@ plugin.onLoadRoom = async function (payload) {
210
364
  return payload;
211
365
  }
212
366
 
213
- const isAdmin = await User.isAdministrator(uid);
367
+ const [isAdmin, canView, canManage] = await Promise.all([
368
+ User.isAdministrator(uid),
369
+ privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
370
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
371
+ ]);
372
+ const isManager = isAdmin || canManage;
373
+ const hasAccess = isAdmin || canView || canManage;
374
+
214
375
  const lockData = await getRoomLockData(room.roomId);
215
- const isLockedForUser = lockData.isLocked && !isAdmin;
376
+ const isLockedForUser = lockData.isLocked && !isManager;
216
377
 
217
378
  room.adminChatLock = lockData;
218
379
  room.messages = ensureFirstVisibleMessageHeader(buildRoomMessagesWithLockNotice(room.messages, lockData));
219
380
  room.canReply = room.canReply && !isLockedForUser;
220
381
  room.showUserInput = room.showUserInput && !isLockedForUser;
221
382
 
222
- if (!isAdmin) {
383
+ if (!hasAccess) {
223
384
  return payload;
224
385
  }
225
386
 
226
- const isOfficialMember = await db.isSortedSetMember(`chat:room:${room.roomId}:uids`, uid);
387
+ const isOfficialMember = await isUserMemberOfRoom(uid, room.roomId);
227
388
 
228
389
  if (!isOfficialMember) {
229
390
  await db.sortedSetRemove(`chat:room:${room.roomId}:uids:online`, uid);
@@ -268,12 +429,24 @@ plugin.onLoadRoom = async function (payload) {
268
429
 
269
430
  room.messages = ensureFirstVisibleMessageHeader(room.messages);
270
431
  }
432
+ }
433
+
434
+ if (!isManager && !isOfficialMember) {
435
+ room.canReply = false;
436
+ room.showUserInput = false;
437
+ }
271
438
 
272
- room.isAdmin = true;
439
+ room.isAdmin = isManager;
440
+ room.isAdminOrGlobalMod = isManager;
441
+ payload.isAdminOrGlobalMod = isManager;
442
+ if (isManager) {
273
443
  room.isOwner = true;
274
- } else {
275
- room.isAdmin = true;
276
444
  }
445
+ room.adminChatsAccess = hasAccess;
446
+ payload.adminChatsAccess = hasAccess;
447
+ payload.adminChatsCanManage = isManager;
448
+ payload.adminChatsIsAdmin = isAdmin;
449
+
277
450
  return payload;
278
451
  };
279
452
 
@@ -285,7 +458,7 @@ function overrideCoreChatRedirect(controllers) {
285
458
 
286
459
  const originalRedirectToChat = chatsController.redirectToChat;
287
460
  const wrappedRedirectToChat = async function (req, res, next) {
288
- if (req && req.uid && await User.isAdministrator(req.uid)) {
461
+ if (req && req.uid && await canAccessAdminChats(req.uid)) {
289
462
  return await renderAdminChatsPage(req, res, next);
290
463
  }
291
464
 
@@ -303,7 +476,7 @@ function overrideChatsApi() {
303
476
 
304
477
  const originalKick = ChatsAPI.kick;
305
478
  const wrappedKick = async function (caller, data) {
306
- if (caller && !await User.isAdministrator(caller.uid) && data && data.roomId && Array.isArray(data.uids) && data.uids.length === 1 && parseInt(data.uids[0], 10) === parseInt(caller.uid, 10) && await isRoomLocked(data.roomId)) {
479
+ if (caller && !await User.isAdministrator(caller.uid) && !await canManageAdminChats(caller.uid) && data && data.roomId && Array.isArray(data.uids) && data.uids.length === 1 && parseInt(data.uids[0], 10) === parseInt(caller.uid, 10) && await isRoomLocked(data.roomId)) {
307
480
  const message = await getLockedActionMessage(caller.uid);
308
481
  throw new Error(message);
309
482
  }
@@ -325,6 +498,10 @@ function overrideMessagingFunctions() {
325
498
  return true;
326
499
  }
327
500
 
501
+ if (await canManageAdminChats(uid)) {
502
+ return true;
503
+ }
504
+
328
505
  const roomId = await getMessageRoomId(messageId);
329
506
  if (roomId && await isRoomLocked(roomId)) {
330
507
  const message = await getLockedActionMessage(uid);
@@ -339,6 +516,10 @@ function overrideMessagingFunctions() {
339
516
  return true;
340
517
  }
341
518
 
519
+ if (await canManageAdminChats(uid)) {
520
+ return true;
521
+ }
522
+
342
523
  const roomId = await getMessageRoomId(messageId);
343
524
  if (roomId && await isRoomLocked(roomId)) {
344
525
  const message = await getLockedActionMessage(uid);
@@ -349,7 +530,7 @@ function overrideMessagingFunctions() {
349
530
  };
350
531
 
351
532
  Messaging.canViewMessage = async function (mids, roomId, uid) {
352
- if (await User.isAdministrator(uid)) {
533
+ if (await canAccessAdminChats(uid)) {
353
534
  return Array.isArray(mids) ? mids.map(() => true) : true;
354
535
  }
355
536
  return await originalCanViewMessage(mids, roomId, uid);
@@ -358,9 +539,23 @@ function overrideMessagingFunctions() {
358
539
  if (typeof originalCanReply === 'function') {
359
540
  Messaging.canReply = async function (...args) {
360
541
  const guessed = guessRoomIdUidFromArgs(args);
361
- if (await User.isAdministrator(guessed.uid)) {
542
+ const [isAdmin, canView, canManage] = await Promise.all([
543
+ User.isAdministrator(guessed.uid),
544
+ privileges.global.can(ADMIN_CHATS_PRIVILEGE, guessed.uid),
545
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, guessed.uid),
546
+ ]);
547
+ const isManager = isAdmin || canManage;
548
+ const hasAccess = isAdmin || canView || canManage;
549
+
550
+ if (isManager) {
362
551
  return true;
363
552
  }
553
+ if (guessed.roomId && hasAccess) {
554
+ const isMember = await isUserMemberOfRoom(guessed.uid, guessed.roomId);
555
+ if (!isMember) {
556
+ return false;
557
+ }
558
+ }
364
559
  if (guessed.roomId && await isRoomLocked(guessed.roomId)) {
365
560
  return false;
366
561
  }
@@ -393,6 +588,15 @@ function registerRoutes(app, router, middleware) {
393
588
 
394
589
  const pageController = async (req, res, next) => {
395
590
  try {
591
+ // If user is not allowed, redirect to user-specific chat URL
592
+ if (req.uid && !await canAccessAdminChats(req.uid)) {
593
+ const userSlug = await User.getUserField(req.uid, 'userslug');
594
+ if (userSlug && req.params.roomId) {
595
+ const redirectUrl = `${req.baseUrl || ''}/user/${userSlug}/chats/${req.params.roomId}${req.params.index ? `/${req.params.index}` : ''}`;
596
+ return res.redirect(redirectUrl);
597
+ }
598
+ }
599
+
396
600
  await renderAdminChatsPage(req, res, next);
397
601
  } catch (err) {
398
602
  next(err);
@@ -416,6 +620,25 @@ function registerRoutes(app, router, middleware) {
416
620
  }
417
621
  });
418
622
 
623
+
624
+ router.get('/api/admin-chats/privileges', ...routeMiddleware, async (req, res) => {
625
+ try {
626
+ if (!await assertAdminChatsAccess(req, res)) {
627
+ return;
628
+ }
629
+
630
+ const isAdmin = await User.isAdministrator(req.uid);
631
+ const canManage = isAdmin || await privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, req.uid);
632
+ res.json({
633
+ adminChatsAccess: true,
634
+ adminChatsCanManage: canManage,
635
+ adminChatsIsAdmin: isAdmin,
636
+ });
637
+ } catch (err) {
638
+ res.status(500).json({ status: { code: 'error', message: err.message } });
639
+ }
640
+ });
641
+
419
642
  router.get('/api/admin-chats/page/:roomId?/:index?', ...routeMiddleware, async (req, res) => {
420
643
  try {
421
644
  if (!await assertAdminChatsAccess(req, res)) {
@@ -438,8 +661,8 @@ function registerRoutes(app, router, middleware) {
438
661
  return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
439
662
  }
440
663
 
441
- if (!await User.isAdministrator(actingUid)) {
442
- return res.status(403).json({ status: { code: 'forbidden', message: 'Admin only' } });
664
+ if (!await User.isAdministrator(actingUid) && !await canManageAdminChats(actingUid)) {
665
+ return res.status(403).json({ status: { code: 'forbidden', message: 'Admin or manage privilege required' } });
443
666
  }
444
667
 
445
668
  if (!await roomExists(roomId)) {
@@ -480,7 +703,7 @@ async function renderAdminChatsPage(req, res, next) {
480
703
  }
481
704
 
482
705
  async function assertAdminChatsAccess(req, res) {
483
- if (!req.uid || !await User.isAdministrator(req.uid)) {
706
+ if (!req.uid || !await canAccessAdminChats(req.uid)) {
484
707
  helpers.notAllowed(req, res);
485
708
  return false;
486
709
  }
@@ -490,6 +713,13 @@ async function assertAdminChatsAccess(req, res) {
490
713
 
491
714
  async function buildAdminChatsPayload(req) {
492
715
  const userslug = await User.getUserField(req.uid, 'userslug');
716
+ const [isAdmin, canView, canManage] = await Promise.all([
717
+ User.isAdministrator(req.uid),
718
+ privileges.global.can(ADMIN_CHATS_PRIVILEGE, req.uid),
719
+ privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, req.uid),
720
+ ]);
721
+ const adminChatsManage = isAdmin || canManage;
722
+ const adminChatsAccess = isAdmin || canView || canManage;
493
723
  const [recentChats, publicRooms, privateRoomCount] = await Promise.all([
494
724
  getAdminRecentChats(req.uid, 0, ADMIN_CHAT_PAGE_SIZE),
495
725
  Messaging.getPublicRooms(req.uid, req.uid),
@@ -501,6 +731,9 @@ async function buildAdminChatsPayload(req) {
501
731
  uid: req.uid,
502
732
  userslug,
503
733
  adminAllChats: true,
734
+ adminChatsAccess,
735
+ adminChatsIsAdmin: isAdmin,
736
+ adminChatsCanManage: adminChatsManage,
504
737
  rooms: recentChats.rooms,
505
738
  nextStart: recentChats.nextStart,
506
739
  publicRooms: publicRooms || [],
@@ -697,6 +930,10 @@ async function canNonAdminModifyLockedRoom(uid, roomId) {
697
930
  return false;
698
931
  }
699
932
 
933
+ if (await canManageAdminChats(uid)) {
934
+ return false;
935
+ }
936
+
700
937
  return await isRoomLocked(roomId);
701
938
  }
702
939
  async function roomExists(roomId) {
@@ -833,4 +1070,3 @@ function getRoomId(payload) {
833
1070
  }
834
1071
 
835
1072
  module.exports = plugin;
836
-
package/package.json CHANGED
@@ -1,12 +1,9 @@
1
1
  {
2
2
  "name": "nodebb-plugin-admin-chats",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "A plugin that allows the administrator to view and manage user chats.",
5
5
  "main": "library.js",
6
- "dependencies": {
7
- "lodash": "^4.17.0"
8
- },
9
6
  "nbbpm": {
10
- "compatibility": "^3.0.0 || ^4.0.0"
7
+ "compatibility": "^4.0.0"
11
8
  }
12
9
  }
package/plugin.json CHANGED
@@ -63,6 +63,19 @@
63
63
  {
64
64
  "hook": "filter:scripts.get",
65
65
  "method": "addScripts"
66
+ },
67
+ {
68
+ "hook": "filter:notifications.create",
69
+ "method": "filterNotificationCreate"
70
+ },
71
+ {
72
+ "hook": "filter:user.notifications.getNotifications",
73
+ "method": "filterUserNotificationsGetNotifications"
74
+ },
75
+ {
76
+ "hook": "static:privileges.global.init",
77
+ "method": "registerPrivileges"
66
78
  }
67
- ]
79
+ ],
80
+ "languages": "public/languages"
68
81
  }
@@ -5,5 +5,7 @@
5
5
  "errors.update": "Unable to update room lock.",
6
6
  "profile.viewChats": "View Chats",
7
7
  "empty.selectChat": "Please select a chat from the sidebar.",
8
- "errors.lockedAction": "This action cannot be performed in a locked room."
8
+ "errors.lockedAction": "This action cannot be performed in a locked room.",
9
+ "admin-chats-view": "View all chats",
10
+ "admin-chats-manage": "Manage chat rooms"
9
11
  }
@@ -5,5 +5,7 @@
5
5
  "errors.update": "לא ניתן לעדכן את נעילת החדר.",
6
6
  "profile.viewChats": "צפיה בצ'אטים",
7
7
  "empty.selectChat": "אנא בחר צ'אט מסרגל הצד.",
8
- "errors.lockedAction": "לא ניתן לבצע פעולה זו בחדר נעול"
8
+ "errors.lockedAction": "לא ניתן לבצע פעולה זו בחדר נעול",
9
+ "admin-chats-view": "צפיה בכל הצ'אטים",
10
+ "admin-chats-manage": "ניהול חדרי צ'אט"
9
11
  }