diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts
index 8fa65e3f5..503d2c2ba 100644
--- a/frontend/src/__tests__/users.test.ts
+++ b/frontend/src/__tests__/users.test.ts
@@ -1356,6 +1356,103 @@ describe('users/userActions', () => {
expect(api.updateUser).toHaveBeenCalledTimes(1);
expect(api.updateUser).toHaveBeenCalledWith('1', { groups: ['admins'] });
});
+
+ // -----------------------------------------------------------------------
+ // Contradictory group combination guard on the bulk path (issue #1405).
+ // Regression: before the fix, bulkAddToGroup wrote the contradictory
+ // combination without validation. These assert the bulk path enforces
+ // the same rule as the inline expand panel and the modal.
+ // -----------------------------------------------------------------------
+ describe('contradictory group combination guard (issue #1405)', () => {
+ const permGroups = [
+ {
+ id: 'viewers', name: 'Viewers',
+ permissions: [{ action: 'view', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ },
+ {
+ id: 'editors', name: 'Editors',
+ permissions: [{ action: 'create', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ },
+ ];
+ const permUsers = [
+ // Alice already holds the view-only group.
+ { id: 'a', email: 'alice@test.com', groups: ['viewers'], mfa_enabled: false },
+ // Bob has no groups yet.
+ { id: 'b', email: 'bob@test.com', groups: [], mfa_enabled: false },
+ ];
+
+ beforeEach(() => {
+ userState.setAllUsers(permUsers as any);
+ userState.setAvailableGroups(permGroups as any);
+ (global.confirm as jest.Mock).mockReturnValue(true);
+ (api.listUsers as jest.Mock).mockResolvedValue({ users: permUsers });
+ (api.listGroups as jest.Mock).mockResolvedValue({ groups: permGroups });
+ });
+
+ it('does not call updateUser and shows an error when bulk-adding a write group to a view-only user', async () => {
+ userState.addSelectedUserId('a');
+
+ await userActions.bulkAddToGroup('editors');
+
+ // Pre-fix: this called updateUser('a', { groups: ['viewers', 'editors'] }).
+ // Post-fix: the contradictory combination is skipped, no API call.
+ expect(api.updateUser).not.toHaveBeenCalled();
+ const toast = document.querySelector('.toast-error');
+ expect(toast).toBeTruthy();
+ expect(toast?.querySelector('.toast-message')?.textContent).toContain('contradictory');
+ expect(toast?.querySelector('.toast-message')?.textContent).toContain('alice@test.com');
+ });
+
+ it('keeps the skipped contradictory user selected so the admin can fix it', async () => {
+ userState.addSelectedUserId('a');
+
+ await userActions.bulkAddToGroup('editors');
+
+ expect(userState.selectedUserIds.has('a')).toBe(true);
+ });
+
+ it('applies to compatible users and skips only the contradictory ones in a mixed selection', async () => {
+ userState.addSelectedUserId('a'); // view-only -> would conflict
+ userState.addSelectedUserId('b'); // no groups -> safe to add
+
+ await userActions.bulkAddToGroup('editors');
+
+ // Bob gets the write group; Alice is skipped.
+ expect(api.updateUser).toHaveBeenCalledTimes(1);
+ expect(api.updateUser).toHaveBeenCalledWith('b', { groups: ['editors'] });
+ // Compatible user is deselected, contradictory user stays selected.
+ expect(userState.selectedUserIds.has('b')).toBe(false);
+ expect(userState.selectedUserIds.has('a')).toBe(true);
+ // The error toast names the skipped user.
+ expect(document.querySelector('.toast-error')?.textContent).toContain('alice@test.com');
+ });
+
+ it('bulk-adding a compatible write group to a user with only write groups still works', async () => {
+ const writeOnly = [
+ { id: 'c', email: 'carol@test.com', groups: ['editors'], mfa_enabled: false },
+ ];
+ const extraGroups = [
+ ...permGroups,
+ {
+ id: 'admins2', name: 'Admins2',
+ permissions: [{ action: 'delete', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ },
+ ];
+ userState.setAllUsers(writeOnly as any);
+ userState.setAvailableGroups(extraGroups as any);
+ (api.listUsers as jest.Mock).mockResolvedValue({ users: writeOnly });
+ (api.listGroups as jest.Mock).mockResolvedValue({ groups: extraGroups });
+ userState.addSelectedUserId('c');
+
+ await userActions.bulkAddToGroup('admins2');
+
+ expect(api.updateUser).toHaveBeenCalledWith('c', { groups: ['editors', 'admins2'] });
+ expect(document.querySelector('.toast-error')).toBeFalsy();
+ });
+ });
});
});
@@ -2582,3 +2679,508 @@ describe('group union permission hint (issue #1001)', () => {
expect(groupsSection!.textContent).toContain('combined (union) of all selected groups');
});
});
+
+// ============================================================================
+// VALIDATE GROUP COMBINATION UNIT TESTS (issue #1405)
+// ============================================================================
+describe('validateGroupCombination (issue #1405)', () => {
+ const viewOnly = {
+ id: 'v1', name: 'Viewers',
+ permissions: [{ action: 'view', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ const writeCapable = {
+ id: 'w1', name: 'Editors',
+ permissions: [{ action: 'create', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ const mixedCapable = {
+ id: 'm1', name: 'Mixed',
+ permissions: [{ action: 'view', resource: 'a' }, { action: 'create', resource: 'b' }],
+ description: '', allowed_accounts: [],
+ };
+ const emptyPerms = {
+ id: 'e1', name: 'Empty',
+ permissions: [],
+ description: '', allowed_accounts: [],
+ };
+ const allGroups = [viewOnly, writeCapable, mixedCapable, emptyPerms];
+
+ it('returns null for empty selection', () => {
+ expect(userUtils.validateGroupCombination([], allGroups as any)).toBeNull();
+ });
+
+ it('returns null for a single view-only group', () => {
+ expect(userUtils.validateGroupCombination(['v1'], allGroups as any)).toBeNull();
+ });
+
+ it('returns null for a single write-capable group', () => {
+ expect(userUtils.validateGroupCombination(['w1'], allGroups as any)).toBeNull();
+ });
+
+ it('returns null for an empty-permission group alone', () => {
+ expect(userUtils.validateGroupCombination(['e1'], allGroups as any)).toBeNull();
+ });
+
+ it('returns null for view-only + empty-permission group (empty is neutral)', () => {
+ expect(userUtils.validateGroupCombination(['v1', 'e1'], allGroups as any)).toBeNull();
+ });
+
+ it('returns null for two write-capable groups', () => {
+ const w2 = {
+ id: 'w2', name: 'Admins',
+ permissions: [{ action: 'delete', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ expect(userUtils.validateGroupCombination(['w1', 'w2'], [...allGroups, w2] as any)).toBeNull();
+ });
+
+ it('returns null for an unknown group ID (not in allGroups)', () => {
+ expect(userUtils.validateGroupCombination(['unknown-uuid'], allGroups as any)).toBeNull();
+ });
+
+ it('returns an error string for view-only + write-capable combination', () => {
+ const result = userUtils.validateGroupCombination(['v1', 'w1'], allGroups as any);
+ expect(result).not.toBeNull();
+ expect(result).toContain('Contradictory');
+ expect(result).toContain('"Viewers"');
+ expect(result).toContain('"Editors"');
+ });
+
+ it('uses "is" (singular) for one view-only group in the error message', () => {
+ const result = userUtils.validateGroupCombination(['v1', 'w1'], allGroups as any);
+ expect(result).toContain(' is view-only');
+ });
+
+ it('uses "are" (plural) for multiple view-only groups in the error message', () => {
+ const v2 = {
+ id: 'v2', name: 'Readers',
+ permissions: [{ action: 'view', resource: 'gcp' }],
+ description: '', allowed_accounts: [],
+ };
+ const result = userUtils.validateGroupCombination(['v1', 'v2', 'w1'], [...allGroups, v2] as any);
+ expect(result).toContain(' are view-only');
+ });
+
+ it('uses "grants" (singular) for one write-capable group in the error message', () => {
+ const result = userUtils.validateGroupCombination(['v1', 'w1'], allGroups as any);
+ expect(result).toContain('grants write');
+ });
+
+ it('uses "grant" (plural) for multiple write-capable groups in the error message', () => {
+ const w2 = {
+ id: 'w2', name: 'Admins',
+ permissions: [{ action: 'delete', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ const result = userUtils.validateGroupCombination(['v1', 'w1', 'w2'], [...allGroups, w2] as any);
+ expect(result).toContain('grant write');
+ });
+
+ it('treats a mixed group (has at least one non-view action) as write-capable', () => {
+ // m1 has both view and create permissions -- must trigger a conflict when
+ // combined with a pure view-only group.
+ const result = userUtils.validateGroupCombination(['v1', 'm1'], allGroups as any);
+ expect(result).not.toBeNull();
+ expect(result).toContain('"Mixed"');
+ });
+});
+
+// ============================================================================
+// USER-SELECTION CHECKBOX UX (issue #1404)
+// Panel must not collapse when a user-selection checkbox is toggled.
+// ============================================================================
+describe('user-selection checkbox UX (issue #1404)', () => {
+ const mockUsers = [
+ { id: '1', email: 'alice@test.com', groups: ['g1'], mfa_enabled: false },
+ { id: '2', email: 'bob@test.com', groups: [], mfa_enabled: false },
+ ];
+ const mockGroups = [
+ { id: 'g1', name: 'Admins', permissions: [], description: '', allowed_accounts: [] },
+ ];
+
+ function buildDom(): void {
+ document.body.innerHTML = `
+
+
+
+ 0
+
+ `;
+ }
+
+ beforeEach(() => {
+ buildDom();
+ userState.setAllUsers(mockUsers as any);
+ userState.setFilteredUsers(mockUsers as any);
+ userState.setAvailableGroups(mockGroups as any);
+ userState.clearSelectedUserIds();
+ jest.clearAllMocks();
+ });
+
+ it('individual user checkbox does not collapse an open expand panel', () => {
+ userList.renderUsers(mockUsers as any);
+
+ // Expand user "1"
+ const expandBtn = document.querySelector(
+ '.user-expand-btn[data-user-id="1"]',
+ );
+ expect(expandBtn).toBeTruthy();
+ expandBtn!.click();
+
+ const expandRow = document.querySelector(
+ 'tr.user-expand-row[data-user-id="1"]',
+ );
+ expect(expandRow?.classList.contains('hidden')).toBe(false);
+
+ // Check user "2"'s selection checkbox (not a group-assign checkbox).
+ // Pre-fix: this called renderUsers, which rebuilt the whole table and
+ // collapsed user "1"'s expand row.
+ const cb = document.querySelector(
+ '.user-checkbox[data-user-id="2"]',
+ );
+ expect(cb).toBeTruthy();
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // User "1"'s panel must still be open after the checkbox event.
+ expect(expandRow?.classList.contains('hidden')).toBe(false);
+ });
+
+ it('select-all checkbox does not collapse an open expand panel', () => {
+ userList.renderUsers(mockUsers as any);
+
+ // Expand user "2"
+ const expandBtn = document.querySelector(
+ '.user-expand-btn[data-user-id="2"]',
+ );
+ expect(expandBtn).toBeTruthy();
+ expandBtn!.click();
+
+ const expandRow = document.querySelector(
+ 'tr.user-expand-row[data-user-id="2"]',
+ );
+ expect(expandRow?.classList.contains('hidden')).toBe(false);
+
+ // Check select-all. Pre-fix: this called renderUsers, collapsing all panels.
+ const selectAll = document.getElementById('select-all-users') as HTMLInputElement;
+ expect(selectAll).toBeTruthy();
+ selectAll!.checked = true;
+ selectAll!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ // User "2"'s expand panel must still be open.
+ expect(expandRow?.classList.contains('hidden')).toBe(false);
+ });
+
+ it('individual checkbox adds row-selected class on the affected row only', () => {
+ userList.renderUsers(mockUsers as any);
+
+ const row1 = document.querySelector('tr.user-row[data-user-id="1"]');
+ const row2 = document.querySelector('tr.user-row[data-user-id="2"]');
+ expect(row1?.classList.contains('row-selected')).toBe(false);
+ expect(row2?.classList.contains('row-selected')).toBe(false);
+
+ const cb = document.querySelector('.user-checkbox[data-user-id="1"]');
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ expect(row1?.classList.contains('row-selected')).toBe(true);
+ expect(row2?.classList.contains('row-selected')).toBe(false);
+ });
+
+ it('individual checkbox shows the bulk-actions bar when a user is checked', () => {
+ userList.renderUsers(mockUsers as any);
+
+ const bar = document.getElementById('bulk-actions-bar');
+ expect(bar?.classList.contains('hidden')).toBe(true);
+
+ const cb = document.querySelector('.user-checkbox[data-user-id="1"]');
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ expect(bar?.classList.contains('hidden')).toBe(false);
+ expect(document.getElementById('selected-count')?.textContent).toBe('1');
+ });
+
+ it('unchecking all individual checkboxes hides the bulk-actions bar again', () => {
+ userList.renderUsers(mockUsers as any);
+
+ const bar = document.getElementById('bulk-actions-bar');
+ const cb1 = document.querySelector('.user-checkbox[data-user-id="1"]');
+ cb1!.checked = true;
+ cb1!.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(bar?.classList.contains('hidden')).toBe(false);
+
+ cb1!.checked = false;
+ cb1!.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(bar?.classList.contains('hidden')).toBe(true);
+ });
+
+ it('select-all shows the bulk-actions bar with correct count and marks all rows', () => {
+ userList.renderUsers(mockUsers as any);
+
+ const bar = document.getElementById('bulk-actions-bar');
+ const selectAll = document.getElementById('select-all-users') as HTMLInputElement;
+ selectAll!.checked = true;
+ selectAll!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ expect(bar?.classList.contains('hidden')).toBe(false);
+ expect(document.getElementById('selected-count')?.textContent).toBe('2');
+ expect(
+ document.querySelector('tr.user-row[data-user-id="1"]')
+ ?.classList.contains('row-selected'),
+ ).toBe(true);
+ expect(
+ document.querySelector('tr.user-row[data-user-id="2"]')
+ ?.classList.contains('row-selected'),
+ ).toBe(true);
+ });
+
+ it('deselecting via select-all hides the bulk-actions bar and removes row-selected from all rows', () => {
+ userList.renderUsers(mockUsers as any);
+
+ const bar = document.getElementById('bulk-actions-bar');
+ const selectAll = document.getElementById('select-all-users') as HTMLInputElement;
+
+ // Select all first
+ selectAll!.checked = true;
+ selectAll!.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(bar?.classList.contains('hidden')).toBe(false);
+
+ // Then deselect all
+ selectAll!.checked = false;
+ selectAll!.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(bar?.classList.contains('hidden')).toBe(true);
+ expect(
+ document.querySelector('tr.user-row[data-user-id="1"]')
+ ?.classList.contains('row-selected'),
+ ).toBe(false);
+ expect(
+ document.querySelector('tr.user-row[data-user-id="2"]')
+ ?.classList.contains('row-selected'),
+ ).toBe(false);
+ });
+});
+
+// ============================================================================
+// CONTRADICTORY GROUP COMBINATION VALIDATION (issue #1405)
+// Validates that the expand panel and the modal both reject view-only +
+// write-capable group combinations before hitting the API.
+// ============================================================================
+describe('contradictory group combination validation (issue #1405)', () => {
+ const viewOnly = {
+ id: 'v1', name: 'Viewers',
+ permissions: [{ action: 'view', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ const writeCapable = {
+ id: 'w1', name: 'Editors',
+ permissions: [{ action: 'create', resource: 'aws' }],
+ description: '', allowed_accounts: [],
+ };
+ const mockGroups = [viewOnly, writeCapable];
+
+ // -------------------------------------------------------------------------
+ // Expand panel (inline group-assign checkboxes, issue #1405 + #998 surface)
+ // -------------------------------------------------------------------------
+ describe('expand panel group toggle', () => {
+ // Alice starts with the view-only group. Adding a write group should be
+ // blocked before the API is called.
+ const mockUsers = [
+ { id: '1', email: 'alice@test.com', groups: ['v1'], mfa_enabled: false },
+ ];
+
+ function buildDom(): void {
+ document.body.innerHTML = `
+
+
+
+ 0
+
+ `;
+ }
+
+ beforeEach(() => {
+ buildDom();
+ userState.setAllUsers(mockUsers as any);
+ userState.setFilteredUsers(mockUsers as any);
+ userState.setAvailableGroups(mockGroups as any);
+ userState.clearSelectedUserIds();
+ (api.updateUser as jest.Mock).mockResolvedValue({});
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('shows an error toast and does not call the API when adding a write group to a view-only user', async () => {
+ jest.useFakeTimers();
+ userList.renderUsers(mockUsers as any);
+
+ const expandBtn = document.querySelector(
+ '.user-expand-btn[data-user-id="1"]',
+ );
+ expect(expandBtn).toBeTruthy();
+ expandBtn!.click();
+
+ // Alice has ['v1'] (view-only). Toggling on 'w1' (write-capable)
+ // produces a contradictory combination and must be rejected.
+ const cb = document.querySelector(
+ '.group-assign-checkbox[data-user-id="1"][data-group-id="w1"]',
+ );
+ expect(cb).toBeTruthy();
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(api.updateUser).not.toHaveBeenCalled();
+ const toast = document.querySelector('.toast-error');
+ expect(toast).toBeTruthy();
+ expect(toast?.querySelector('.toast-message')?.textContent).toContain('Contradictory');
+ });
+
+ it('reverts the group-assign checkbox to its pre-toggle state on a contradictory combination', async () => {
+ jest.useFakeTimers();
+ userList.renderUsers(mockUsers as any);
+
+ const expandBtn = document.querySelector(
+ '.user-expand-btn[data-user-id="1"]',
+ );
+ expandBtn!.click();
+
+ const cb = document.querySelector(
+ '.group-assign-checkbox[data-user-id="1"][data-group-id="w1"]',
+ );
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Checkbox must be reverted to unchecked (pre-toggle state).
+ expect(cb!.checked).toBe(false);
+ });
+
+ it('calls the API when adding a write-capable group to a user with no groups (not contradictory)', async () => {
+ const user = { id: '3', email: 'carol@test.com', groups: [], mfa_enabled: false };
+ userState.setAllUsers([user] as any);
+ userState.setFilteredUsers([user] as any);
+
+ jest.useFakeTimers();
+ userList.renderUsers([user] as any);
+
+ const expandBtn = document.querySelector(
+ '.user-expand-btn[data-user-id="3"]',
+ );
+ expect(expandBtn).toBeTruthy();
+ expandBtn!.click();
+
+ const cb = document.querySelector(
+ '.group-assign-checkbox[data-user-id="3"][data-group-id="w1"]',
+ );
+ expect(cb).toBeTruthy();
+ cb!.checked = true;
+ cb!.dispatchEvent(new Event('change', { bubbles: true }));
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(api.updateUser).toHaveBeenCalledWith('3', { groups: ['w1'] });
+ expect(document.querySelector('.toast-error')).toBeFalsy();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Modal saveUser (create + edit paths, issue #1405)
+ // -------------------------------------------------------------------------
+ describe('modal saveUser', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ userState.setAvailableGroups(mockGroups as any);
+ userState.setCurrentEditingUser(null);
+ (api.createUser as jest.Mock).mockResolvedValue({});
+ (api.updateUser as jest.Mock).mockResolvedValue({});
+ (api.listUsers as jest.Mock).mockResolvedValue({ users: [] });
+ (api.listGroups as jest.Mock).mockResolvedValue({ groups: mockGroups });
+ jest.clearAllMocks();
+ });
+
+ it('shows error and does not call createUser when contradictory groups are selected on create', async () => {
+ userModals.openCreateUserModal();
+
+ // Select both v1 (view-only) and w1 (write-capable) -- contradictory.
+ const gs = document.getElementById('user-groups') as HTMLSelectElement;
+ Array.from(gs.options).forEach(opt => { opt.selected = true; });
+
+ const event = new Event('submit');
+ event.preventDefault = jest.fn();
+
+ await userModals.saveUser(event);
+
+ expect(api.createUser).not.toHaveBeenCalled();
+ const toast = document.querySelector('.toast-error');
+ expect(toast).toBeTruthy();
+ expect(toast?.querySelector('.toast-message')?.textContent).toContain('Contradictory');
+ });
+
+ it('shows error and does not call updateUser when contradictory groups are selected on edit', async () => {
+ const editUser = {
+ id: '1', email: 'alice@test.com', groups: ['v1'], mfa_enabled: false,
+ };
+ (api.getUser as jest.Mock).mockResolvedValue(editUser);
+ await userModals.openEditUserModal('1');
+
+ // Select both options (v1 + w1) -- contradictory.
+ const gs = document.getElementById('user-groups') as HTMLSelectElement;
+ Array.from(gs.options).forEach(opt => { opt.selected = true; });
+
+ const event = new Event('submit');
+ event.preventDefault = jest.fn();
+
+ await userModals.saveUser(event);
+
+ expect(api.updateUser).not.toHaveBeenCalled();
+ const toast = document.querySelector('.toast-error');
+ expect(toast).toBeTruthy();
+ expect(toast?.querySelector('.toast-message')?.textContent).toContain('Contradictory');
+ });
+
+ it('calls createUser when only a single write-capable group is selected (not contradictory)', async () => {
+ userModals.openCreateUserModal();
+ (document.getElementById('user-email') as HTMLInputElement).value = 'new@test.com';
+ (document.getElementById('user-password') as HTMLInputElement).value = 'SecurePass123!';
+
+ // Select only w1 (write-capable) -- no view-only group, so no conflict.
+ const gs = document.getElementById('user-groups') as HTMLSelectElement;
+ Array.from(gs.options).forEach(opt => { opt.selected = opt.value === 'w1'; });
+
+ const event = new Event('submit');
+ event.preventDefault = jest.fn();
+
+ await userModals.saveUser(event);
+
+ expect(api.createUser).toHaveBeenCalledWith(
+ expect.objectContaining({ groups: ['w1'] }),
+ );
+ expect(document.querySelector('.toast-error')).toBeFalsy();
+ });
+ });
+});
diff --git a/frontend/src/users/userActions.ts b/frontend/src/users/userActions.ts
index 0e1eda65b..ab1a3bc45 100644
--- a/frontend/src/users/userActions.ts
+++ b/frontend/src/users/userActions.ts
@@ -11,9 +11,10 @@ import {
selectedUserIds,
setAllUsers,
setAvailableGroups,
- clearSelectedUserIds
+ clearSelectedUserIds,
+ removeSelectedUserId
} from './state';
-import { showError, showSuccess } from './utils';
+import { showError, showSuccess, validateGroupCombination } from './utils';
import { confirmDialog } from '../confirmDialog';
import { applyFilters } from './filters';
import { renderUsers, renderUserStats, populateBulkGroupSelect } from './userList';
@@ -200,21 +201,50 @@ export async function bulkAddToGroup(groupId: string): Promise {
return;
}
- try {
- // Get current user data and add group
- const updates = Array.from(selectedUserIds).map(async userId => {
- const user = allUsers.find(u => u.id === userId);
- if (!user) return;
+ // Partition the selected users by whether adding this group would produce a
+ // contradictory combination (view-only group + write-capable group, issue
+ // #1405). The bulk path must enforce the same rule as the inline expand
+ // panel and the create/edit modal -- otherwise selecting a Viewer-group
+ // user and bulk-adding a write group silently creates the exact
+ // combination #1405 is meant to prevent. Contradictory users are skipped
+ // (never silently written) and named in an error toast so the admin knows
+ // which ones were left unchanged.
+ const applicable: { userId: string; groups: string[] }[] = [];
+ const skipped: string[] = [];
+ for (const userId of selectedUserIds) {
+ const user = allUsers.find(u => u.id === userId);
+ if (!user) continue;
+
+ const updatedGroups = [...new Set([...user.groups, groupId])];
+ if (validateGroupCombination(updatedGroups, availableGroups)) {
+ skipped.push(user.email);
+ } else {
+ applicable.push({ userId, groups: updatedGroups });
+ }
+ }
+
+ if (skipped.length > 0) {
+ showError(
+ `Skipped ${skipped.length} user(s) whose group set would be contradictory: ` +
+ `${skipped.join(', ')}. A view-only group cannot be combined with ` +
+ `write-capable groups. Remove the conflicting membership first.`
+ );
+ }
- const updatedGroups = [...new Set([...user.groups, groupId])];
- await api.updateUser(userId, { groups: updatedGroups });
- });
+ // Nothing safe to apply -- leave the selection intact so the admin can
+ // adjust the offending memberships and retry.
+ if (applicable.length === 0) return;
- await Promise.all(updates);
+ try {
+ await Promise.all(
+ applicable.map(({ userId, groups }) => api.updateUser(userId, { groups })),
+ );
- clearSelectedUserIds();
+ // Deselect only the successfully-applied users so any skipped
+ // (contradictory) users stay selected for the admin to fix.
+ applicable.forEach(({ userId }) => removeSelectedUserId(userId));
await loadUsers();
- showSuccess(`Successfully added ${count} user(s) to group`);
+ showSuccess(`Successfully added ${applicable.length} user(s) to group`);
} catch (error) {
console.error('Failed to add users to group:', error);
showError('Failed to add some users to group');
diff --git a/frontend/src/users/userList.ts b/frontend/src/users/userList.ts
index a4b4e3489..92261378f 100644
--- a/frontend/src/users/userList.ts
+++ b/frontend/src/users/userList.ts
@@ -14,7 +14,7 @@ import {
clearSelectedUserIds,
setAllUsers,
} from './state';
-import { escapeHtml, formatRelativeTime, formatDate, showSuccess, showError } from './utils';
+import { escapeHtml, formatRelativeTime, formatDate, showSuccess, showError, validateGroupCombination } from './utils';
import { openEditUserModal, deleteUser } from './userActions';
import { ADMINISTRATORS_GROUP_ID } from '../permissions';
@@ -210,6 +210,9 @@ function renderUserExpandPanel(user: APIUser): string {
/**
* Toggle a group membership for a user inline (no modal).
*
+ * Before the API call, validates that the resulting group set is not a
+ * contradictory combination (issue #1405: view-only + write-capable).
+ *
* After a successful API call, patch state in-memory and refresh only the
* affected expand panel rather than re-rendering the whole table. This
* prevents the panel from collapsing on every toggle (issue #998) and
@@ -226,6 +229,20 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
? [...new Set([...user.groups, groupId])]
: user.groups.filter(g => g !== groupId);
+ // Validate the resulting group combination before hitting the API.
+ // A view-only group (all permissions are view:*) combined with a
+ // write-capable group is semantically contradictory (issue #1405).
+ const combinationError = validateGroupCombination(next, availableGroups);
+ if (combinationError) {
+ showError(combinationError);
+ // Revert the checkbox immediately so the DOM reflects reality.
+ const cb = document.querySelector(
+ `.group-assign-checkbox[data-user-id="${userId}"][data-group-id="${groupId}"]`,
+ );
+ if (cb) cb.checked = !checked;
+ return;
+ }
+
try {
await api.updateUser(userId, { groups: next });
@@ -276,6 +293,9 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
*/
function setupUserTableListeners(): void {
// Select all checkbox
+ // Patch all rows in-place instead of calling renderUsers(), which would
+ // tear down and rebuild the entire table and collapse any open expand
+ // panels (issue #1404 symptom: expand arrow / panel lost on checkbox click).
const selectAllCheckbox = document.getElementById('select-all-users') as HTMLInputElement;
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
@@ -285,24 +305,56 @@ function setupUserTableListeners(): void {
} else {
clearSelectedUserIds();
}
- renderUsers(filteredUsers);
+ // Patch each row's selected class and its individual checkbox without
+ // re-rendering the whole table (preserves open expand panels).
+ filteredUsers.forEach(user => {
+ const row = document.querySelector(
+ `tr.user-row[data-user-id="${user.id}"]`,
+ );
+ const cb = document.querySelector(
+ `.user-checkbox[data-user-id="${user.id}"]`,
+ );
+ if (row) {
+ if (checked) row.classList.add('row-selected');
+ else row.classList.remove('row-selected');
+ }
+ if (cb) cb.checked = checked;
+ });
updateBulkActionsBar();
});
}
// Individual checkboxes
+ // Same rationale: patch the affected row in-place instead of calling
+ // renderUsers() so open expand panels are not collapsed (issue #1404).
document.querySelectorAll('.user-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const userId = (e.target as HTMLElement).dataset.userId;
if (!userId) return;
- if ((e.target as HTMLInputElement).checked) {
+ const nowChecked = (e.target as HTMLInputElement).checked;
+ if (nowChecked) {
addSelectedUserId(userId);
} else {
removeSelectedUserId(userId);
}
- renderUsers(filteredUsers);
+ // Toggle the row-selected class on just the affected row.
+ const row = document.querySelector(
+ `tr.user-row[data-user-id="${userId}"]`,
+ );
+ if (row) {
+ if (nowChecked) row.classList.add('row-selected');
+ else row.classList.remove('row-selected');
+ }
+
+ // Keep the select-all checkbox in sync.
+ const selectAll = document.getElementById('select-all-users') as HTMLInputElement | null;
+ if (selectAll) {
+ selectAll.checked =
+ selectedUserIds.size > 0 && selectedUserIds.size === filteredUsers.length;
+ }
+
updateBulkActionsBar();
});
});
diff --git a/frontend/src/users/userModals.ts b/frontend/src/users/userModals.ts
index 65b00aa8b..85b8a597e 100644
--- a/frontend/src/users/userModals.ts
+++ b/frontend/src/users/userModals.ts
@@ -14,7 +14,7 @@ import {
setCurrentEditingUser,
availableGroups
} from './state';
-import { escapeHtml, showError, showSuccess } from './utils';
+import { escapeHtml, showError, showSuccess, validateGroupCombination } from './utils';
import { loadUsers } from './userActions';
import { openModal, closeModal } from '../modal';
@@ -119,6 +119,14 @@ export async function saveUser(e: Event): Promise {
return;
}
+ // Reject contradictory group combinations (view-only + write-capable)
+ // before hitting the API so the user gets an actionable message.
+ const groupConflict = validateGroupCombination(selectedGroups, availableGroups);
+ if (groupConflict) {
+ showError(groupConflict);
+ return;
+ }
+
try {
if (currentEditingUser) {
// Update existing user
diff --git a/frontend/src/users/utils.ts b/frontend/src/users/utils.ts
index 0e78640b8..8b5730628 100644
--- a/frontend/src/users/utils.ts
+++ b/frontend/src/users/utils.ts
@@ -19,8 +19,61 @@ export { formatRelativeTime, formatDate } from '../utils';
*/
export { escapeHtml } from '../utils';
+import type { APIGroup } from '../api';
import { showToast } from '../toast';
+/**
+ * Validate that a proposed group-ID list does not contain a contradictory
+ * combination: a view-only group (all permissions carry action === 'view')
+ * paired with a write-capable group (at least one permission has a
+ * non-view action such as create/update/delete/execute/approve/admin).
+ *
+ * Because CUDly's permission model is additive (the user gets the union of
+ * all group permissions), pairing a view-only group with a write group is
+ * semantically contradictory: the intent of a "Viewer" group is that the
+ * user should have read-only access, but adding any write-capable group
+ * silently elevates them. The UI should surface this immediately rather
+ * than letting the combination go through.
+ *
+ * Groups with zero permissions are skipped (they grant nothing and do not
+ * participate in the conflict).
+ *
+ * Returns a human-readable error string when the combination is invalid,
+ * or null when it is acceptable.
+ *
+ * @param groupIds UUIDs of groups the user would be assigned
+ * @param allGroups Full list of loaded groups (from availableGroups)
+ */
+export function validateGroupCombination(
+ groupIds: string[],
+ allGroups: APIGroup[],
+): string | null {
+ const groups = groupIds
+ .map(id => allGroups.find(g => g.id === id))
+ .filter((g): g is APIGroup => g !== undefined);
+
+ const viewOnlyGroups = groups.filter(
+ g => Array.isArray(g.permissions) &&
+ g.permissions.length > 0 &&
+ g.permissions.every(p => p.action === 'view'),
+ );
+ const writeGroups = groups.filter(
+ g => Array.isArray(g.permissions) &&
+ g.permissions.some(p => p.action !== 'view'),
+ );
+
+ if (viewOnlyGroups.length === 0 || writeGroups.length === 0) return null;
+
+ const viewNames = viewOnlyGroups.map(g => `"${g.name}"`).join(', ');
+ const writeNames = writeGroups.map(g => `"${g.name}"`).join(', ');
+ return (
+ `Contradictory group combination: ${viewNames} ` +
+ `${viewOnlyGroups.length === 1 ? 'is' : 'are'} view-only but ` +
+ `${writeNames} grant${writeGroups.length === 1 ? 's' : ''} write permissions. ` +
+ `Assign a view-only group or write-capable groups, not both.`
+ );
+}
+
/**
* Show error message. Delegates to the shared toast system so callers
* across the codebase get consistent bottom-right stacked toasts with