diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts
index 31f13241f..4a952e644 100644
--- a/frontend/src/__tests__/users.test.ts
+++ b/frontend/src/__tests__/users.test.ts
@@ -2069,3 +2069,159 @@ describe('users module integration', () => {
expect(bar?.classList.contains('hidden')).toBe(false);
});
});
+
+// ============================================================================
+// BULK TOOLBAR TESTS (issue #974)
+// ============================================================================
+describe('users bulk-actions toolbar', () => {
+ const mockGroups = [
+ { id: 'admins', name: 'Admins', permissions: [], description: '', allowed_accounts: [] },
+ { id: 'developers', name: 'Developers', permissions: [], description: '', allowed_accounts: [] },
+ ];
+
+ function buildToolbarDom(): void {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+ `;
+ }
+
+ beforeEach(() => {
+ buildToolbarDom();
+ userState.setAllUsers([]);
+ userState.setFilteredUsers([]);
+ userState.setAvailableGroups(mockGroups as any);
+ userState.clearSelectedUserIds();
+ jest.clearAllMocks();
+ });
+
+ describe('toolbar visibility', () => {
+ it('should be hidden when no users are selected', () => {
+ userList.updateBulkActionsBar();
+
+ const bar = document.getElementById('bulk-actions-bar');
+ expect(bar?.classList.contains('hidden')).toBe(true);
+ });
+
+ it('should be visible with correct count when one user is selected', () => {
+ userState.addSelectedUserId('user-1');
+
+ userList.updateBulkActionsBar();
+
+ const bar = document.getElementById('bulk-actions-bar');
+ expect(bar?.classList.contains('hidden')).toBe(false);
+ const count = document.getElementById('selected-count');
+ expect(count?.textContent).toBe('1');
+ });
+
+ it('should be visible with correct count when multiple users are selected', () => {
+ userState.addSelectedUserId('user-1');
+ userState.addSelectedUserId('user-2');
+ userState.addSelectedUserId('user-3');
+
+ userList.updateBulkActionsBar();
+
+ const bar = document.getElementById('bulk-actions-bar');
+ expect(bar?.classList.contains('hidden')).toBe(false);
+ const count = document.getElementById('selected-count');
+ expect(count?.textContent).toBe('3');
+ });
+
+ it('should become hidden again after all rows are unchecked', () => {
+ userState.addSelectedUserId('user-1');
+ userList.updateBulkActionsBar();
+
+ const barBefore = document.getElementById('bulk-actions-bar');
+ expect(barBefore?.classList.contains('hidden')).toBe(false);
+
+ userState.clearSelectedUserIds();
+ userList.updateBulkActionsBar();
+
+ const barAfter = document.getElementById('bulk-actions-bar');
+ expect(barAfter?.classList.contains('hidden')).toBe(true);
+ });
+ });
+
+ describe('bulk-delete-btn', () => {
+ it('should call bulkDeleteUsers when clicked via setupUserHandlers', async () => {
+ const mockUser = { id: '1', email: 'u@t.com', role: 'user', groups: [], mfa_enabled: false };
+ userState.setAllUsers([mockUser] as any);
+ userState.addSelectedUserId('1');
+
+ (api.deleteUser as jest.Mock).mockResolvedValue({});
+ (api.listUsers as jest.Mock).mockResolvedValue({ users: [] });
+ (api.listGroups as jest.Mock).mockResolvedValue({ groups: [] });
+
+ userHandlers.setupUserHandlers();
+
+ const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
+ bulkDeleteBtn?.click();
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(api.deleteUser).toHaveBeenCalledWith('1');
+ });
+ });
+
+ describe('bulk-group-select', () => {
+ it('should populate with available groups', () => {
+ userList.populateBulkGroupSelect();
+
+ const select = document.getElementById('bulk-group-select') as HTMLSelectElement;
+ // placeholder + 2 groups
+ expect(select.options.length).toBe(3);
+ expect(select.options[1]?.value).toBe('admins');
+ expect(select.options[1]?.text).toBe('Admins');
+ expect(select.options[2]?.value).toBe('developers');
+ expect(select.options[2]?.text).toBe('Developers');
+ });
+
+ it('should call bulkAddToGroup with selected group id on change via setupUserHandlers', async () => {
+ const mockUser = { id: '1', email: 'test@test.com', role: 'user', groups: [], mfa_enabled: false };
+ userState.setAllUsers([mockUser] as any);
+ userState.addSelectedUserId('1');
+
+ (api.updateUser as jest.Mock).mockResolvedValue({});
+ (api.listUsers as jest.Mock).mockResolvedValue({ users: [mockUser] });
+ (api.listGroups as jest.Mock).mockResolvedValue({ groups: mockGroups });
+
+ userHandlers.setupUserHandlers();
+
+ const select = document.getElementById('bulk-group-select') as HTMLSelectElement;
+ select.value = 'admins';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(api.updateUser).toHaveBeenCalledWith('1', { groups: ['admins'] });
+ });
+
+ it('should not call bulkAddToGroup when placeholder is selected', async () => {
+ userState.addSelectedUserId('1');
+ (api.updateUser as jest.Mock).mockResolvedValue({});
+
+ userHandlers.setupUserHandlers();
+
+ const select = document.getElementById('bulk-group-select') as HTMLSelectElement;
+ select.value = '';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(api.updateUser).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 36b825be9..c3a899706 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -701,6 +701,13 @@ User Management
+
+ 0 users selected
+
+
+
diff --git a/frontend/src/users/handlers.ts b/frontend/src/users/handlers.ts
index 5a852f8b4..635af3d31 100644
--- a/frontend/src/users/handlers.ts
+++ b/frontend/src/users/handlers.ts
@@ -11,6 +11,7 @@ import { availableGroups } from './state';
import { handleUserSearch, handleFilterChange, clearFilters, updateGroupFilterDropdown } from './filters';
import { openCreateUserModal, closeUserModal, saveUser } from './userModals';
import { bulkDeleteUsers, bulkAddToGroup } from './userActions';
+import { populateBulkGroupSelect } from './userList';
/**
* Setup event handlers for user management
@@ -83,6 +84,21 @@ export function setupUserHandlers(): void {
});
}
- // Populate group filter dropdown
+ // Bulk group select dropdown (replaces the prompt on the production toolbar)
+ const bulkGroupSelect = document.getElementById('bulk-group-select') as HTMLSelectElement | null;
+ if (bulkGroupSelect) {
+ bulkGroupSelect.addEventListener('change', () => {
+ const groupId = bulkGroupSelect.value;
+ if (groupId) {
+ void bulkAddToGroup(groupId).then(() => {
+ // Reset to placeholder after the operation completes
+ bulkGroupSelect.value = '';
+ });
+ }
+ });
+ }
+
+ // Populate both group dropdowns
updateGroupFilterDropdown();
+ populateBulkGroupSelect();
}
diff --git a/frontend/src/users/index.ts b/frontend/src/users/index.ts
index 52d952af1..b4732d17c 100644
--- a/frontend/src/users/index.ts
+++ b/frontend/src/users/index.ts
@@ -18,7 +18,7 @@ export { formatRelativeTime, escapeHtml, showError, showSuccess } from './utils'
export { applyFilters, handleUserSearch, handleFilterChange, clearFilters, updateGroupFilterDropdown } from './filters';
// Re-export user list rendering
-export { renderUsers, renderUserStats, updateBulkActionsBar } from './userList';
+export { renderUsers, renderUserStats, updateBulkActionsBar, populateBulkGroupSelect } from './userList';
// Re-export user modals
export { openCreateUserModal, openEditUserModal, closeUserModal, saveUser } from './userModals';
diff --git a/frontend/src/users/userActions.ts b/frontend/src/users/userActions.ts
index 7a87f6d95..0e1eda65b 100644
--- a/frontend/src/users/userActions.ts
+++ b/frontend/src/users/userActions.ts
@@ -16,7 +16,7 @@ import {
import { showError, showSuccess } from './utils';
import { confirmDialog } from '../confirmDialog';
import { applyFilters } from './filters';
-import { renderUsers, renderUserStats } from './userList';
+import { renderUsers, renderUserStats, populateBulkGroupSelect } from './userList';
import { renderGroups } from '../groups/groupList';
import { renderPermissionMatrix } from './permissionMatrix';
@@ -90,6 +90,7 @@ export async function loadUsers(): Promise {
renderUsers(filteredUsers);
renderGroups(groups);
renderUserStats();
+ populateBulkGroupSelect();
const matrixContainer = document.getElementById('permission-matrix');
if (matrixContainer) {
diff --git a/frontend/src/users/userList.ts b/frontend/src/users/userList.ts
index 2ee3e1656..e1e71acaf 100644
--- a/frontend/src/users/userList.ts
+++ b/frontend/src/users/userList.ts
@@ -328,7 +328,7 @@ function setupUserTableListeners(): void {
}
/**
- * Update bulk actions bar
+ * Update bulk actions bar visibility and selection count.
*/
export function updateBulkActionsBar(): void {
const bulkBar = document.getElementById('bulk-actions-bar');
@@ -344,3 +344,19 @@ export function updateBulkActionsBar(): void {
if (countEl) countEl.textContent = selectedCount.toString();
}
}
+
+/**
+ * Populate the #bulk-group-select dropdown with the currently loaded groups.
+ * Called by loadUsers after groups are refreshed so the options stay in sync.
+ */
+export function populateBulkGroupSelect(): void {
+ const select = document.getElementById('bulk-group-select') as HTMLSelectElement | null;
+ if (!select) return;
+ while (select.options.length > 1) select.remove(1);
+ for (const group of availableGroups) {
+ const opt = document.createElement('option');
+ opt.value = group.id;
+ opt.textContent = group.name;
+ select.add(opt);
+ }
+}