Skip to content

Commit d7babe8

Browse files
committed
Merge pull request #1427 from LeanerCloud/fix/qa-admin-user-mgmt
fix(admin): validate group combos, fix checkbox UX, remove stale bulk checkboxes
2 parents 9a8a9c7 + 3c0fe9f commit d7babe8

35 files changed

Lines changed: 1653 additions & 207 deletions

‎.golangci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ linters:
133133
- misspell
134134
path: (internal/config/(store_postgres|store_postgres_pgxmock_test|store_postgres_ladder|types|interfaces)|pkg/ladder/(store|types_test)|internal/api/(handler_history|handler_ri_exchange(_test)?|router_handlers_test|coverage_extras_test)|internal/mocks/stores|internal/purchase/scheduled_fire_test)\.go
135135
text: "cancelled|initialised"
136+
- linters:
137+
- misspell
138+
path: internal/database/postgres/migrations/000082_rename_cancelled_to_canceled_test\.go
136139
paths:
137140
- third_party$
138141
- builtin$

‎frontend/src/__tests__/users.test.ts‎

Lines changed: 602 additions & 0 deletions
Large diffs are not rendered by default.

‎frontend/src/users/userActions.ts‎

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
selectedUserIds,
1212
setAllUsers,
1313
setAvailableGroups,
14-
clearSelectedUserIds
14+
clearSelectedUserIds,
15+
removeSelectedUserId
1516
} from './state';
16-
import { showError, showSuccess } from './utils';
17+
import { showError, showSuccess, validateGroupCombination } from './utils';
1718
import { confirmDialog } from '../confirmDialog';
1819
import { applyFilters } from './filters';
1920
import { renderUsers, renderUserStats, populateBulkGroupSelect } from './userList';
@@ -200,21 +201,50 @@ export async function bulkAddToGroup(groupId: string): Promise<void> {
200201
return;
201202
}
202203

203-
try {
204-
// Get current user data and add group
205-
const updates = Array.from(selectedUserIds).map(async userId => {
206-
const user = allUsers.find(u => u.id === userId);
207-
if (!user) return;
204+
// Partition the selected users by whether adding this group would produce a
205+
// contradictory combination (view-only group + write-capable group, issue
206+
// #1405). The bulk path must enforce the same rule as the inline expand
207+
// panel and the create/edit modal -- otherwise selecting a Viewer-group
208+
// user and bulk-adding a write group silently creates the exact
209+
// combination #1405 is meant to prevent. Contradictory users are skipped
210+
// (never silently written) and named in an error toast so the admin knows
211+
// which ones were left unchanged.
212+
const applicable: { userId: string; groups: string[] }[] = [];
213+
const skipped: string[] = [];
214+
for (const userId of selectedUserIds) {
215+
const user = allUsers.find(u => u.id === userId);
216+
if (!user) continue;
217+
218+
const updatedGroups = [...new Set([...user.groups, groupId])];
219+
if (validateGroupCombination(updatedGroups, availableGroups)) {
220+
skipped.push(user.email);
221+
} else {
222+
applicable.push({ userId, groups: updatedGroups });
223+
}
224+
}
225+
226+
if (skipped.length > 0) {
227+
showError(
228+
`Skipped ${skipped.length} user(s) whose group set would be contradictory: ` +
229+
`${skipped.join(', ')}. A view-only group cannot be combined with ` +
230+
`write-capable groups. Remove the conflicting membership first.`
231+
);
232+
}
208233

209-
const updatedGroups = [...new Set([...user.groups, groupId])];
210-
await api.updateUser(userId, { groups: updatedGroups });
211-
});
234+
// Nothing safe to apply -- leave the selection intact so the admin can
235+
// adjust the offending memberships and retry.
236+
if (applicable.length === 0) return;
212237

213-
await Promise.all(updates);
238+
try {
239+
await Promise.all(
240+
applicable.map(({ userId, groups }) => api.updateUser(userId, { groups })),
241+
);
214242

215-
clearSelectedUserIds();
243+
// Deselect only the successfully-applied users so any skipped
244+
// (contradictory) users stay selected for the admin to fix.
245+
applicable.forEach(({ userId }) => removeSelectedUserId(userId));
216246
await loadUsers();
217-
showSuccess(`Successfully added ${count} user(s) to group`);
247+
showSuccess(`Successfully added ${applicable.length} user(s) to group`);
218248
} catch (error) {
219249
console.error('Failed to add users to group:', error);
220250
showError('Failed to add some users to group');

‎frontend/src/users/userList.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
clearSelectedUserIds,
1515
setAllUsers,
1616
} from './state';
17-
import { escapeHtml, formatRelativeTime, formatDate, showSuccess, showError } from './utils';
17+
import { escapeHtml, formatRelativeTime, formatDate, showSuccess, showError, validateGroupCombination } from './utils';
1818
import { openEditUserModal, deleteUser } from './userActions';
1919
import { ADMINISTRATORS_GROUP_ID } from '../permissions';
2020

@@ -210,6 +210,9 @@ function renderUserExpandPanel(user: APIUser): string {
210210
/**
211211
* Toggle a group membership for a user inline (no modal).
212212
*
213+
* Before the API call, validates that the resulting group set is not a
214+
* contradictory combination (issue #1405: view-only + write-capable).
215+
*
213216
* After a successful API call, patch state in-memory and refresh only the
214217
* affected expand panel rather than re-rendering the whole table. This
215218
* prevents the panel from collapsing on every toggle (issue #998) and
@@ -226,6 +229,20 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
226229
? [...new Set([...user.groups, groupId])]
227230
: user.groups.filter(g => g !== groupId);
228231

232+
// Validate the resulting group combination before hitting the API.
233+
// A view-only group (all permissions are view:*) combined with a
234+
// write-capable group is semantically contradictory (issue #1405).
235+
const combinationError = validateGroupCombination(next, availableGroups);
236+
if (combinationError) {
237+
showError(combinationError);
238+
// Revert the checkbox immediately so the DOM reflects reality.
239+
const cb = document.querySelector<HTMLInputElement>(
240+
`.group-assign-checkbox[data-user-id="${userId}"][data-group-id="${groupId}"]`,
241+
);
242+
if (cb) cb.checked = !checked;
243+
return;
244+
}
245+
229246
try {
230247
await api.updateUser(userId, { groups: next });
231248

@@ -276,6 +293,9 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
276293
*/
277294
function setupUserTableListeners(): void {
278295
// Select all checkbox
296+
// Patch all rows in-place instead of calling renderUsers(), which would
297+
// tear down and rebuild the entire table and collapse any open expand
298+
// panels (issue #1404 symptom: expand arrow / panel lost on checkbox click).
279299
const selectAllCheckbox = document.getElementById('select-all-users') as HTMLInputElement;
280300
if (selectAllCheckbox) {
281301
selectAllCheckbox.addEventListener('change', (e) => {
@@ -285,24 +305,56 @@ function setupUserTableListeners(): void {
285305
} else {
286306
clearSelectedUserIds();
287307
}
288-
renderUsers(filteredUsers);
308+
// Patch each row's selected class and its individual checkbox without
309+
// re-rendering the whole table (preserves open expand panels).
310+
filteredUsers.forEach(user => {
311+
const row = document.querySelector<HTMLElement>(
312+
`tr.user-row[data-user-id="${user.id}"]`,
313+
);
314+
const cb = document.querySelector<HTMLInputElement>(
315+
`.user-checkbox[data-user-id="${user.id}"]`,
316+
);
317+
if (row) {
318+
if (checked) row.classList.add('row-selected');
319+
else row.classList.remove('row-selected');
320+
}
321+
if (cb) cb.checked = checked;
322+
});
289323
updateBulkActionsBar();
290324
});
291325
}
292326

293327
// Individual checkboxes
328+
// Same rationale: patch the affected row in-place instead of calling
329+
// renderUsers() so open expand panels are not collapsed (issue #1404).
294330
document.querySelectorAll('.user-checkbox').forEach(checkbox => {
295331
checkbox.addEventListener('change', (e) => {
296332
const userId = (e.target as HTMLElement).dataset.userId;
297333
if (!userId) return;
298334

299-
if ((e.target as HTMLInputElement).checked) {
335+
const nowChecked = (e.target as HTMLInputElement).checked;
336+
if (nowChecked) {
300337
addSelectedUserId(userId);
301338
} else {
302339
removeSelectedUserId(userId);
303340
}
304341

305-
renderUsers(filteredUsers);
342+
// Toggle the row-selected class on just the affected row.
343+
const row = document.querySelector<HTMLElement>(
344+
`tr.user-row[data-user-id="${userId}"]`,
345+
);
346+
if (row) {
347+
if (nowChecked) row.classList.add('row-selected');
348+
else row.classList.remove('row-selected');
349+
}
350+
351+
// Keep the select-all checkbox in sync.
352+
const selectAll = document.getElementById('select-all-users') as HTMLInputElement | null;
353+
if (selectAll) {
354+
selectAll.checked =
355+
selectedUserIds.size > 0 && selectedUserIds.size === filteredUsers.length;
356+
}
357+
306358
updateBulkActionsBar();
307359
});
308360
});

‎frontend/src/users/userModals.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
setCurrentEditingUser,
1515
availableGroups
1616
} from './state';
17-
import { escapeHtml, showError, showSuccess } from './utils';
17+
import { escapeHtml, showError, showSuccess, validateGroupCombination } from './utils';
1818
import { loadUsers } from './userActions';
1919
import { openModal, closeModal } from '../modal';
2020

@@ -119,6 +119,14 @@ export async function saveUser(e: Event): Promise<void> {
119119
return;
120120
}
121121

122+
// Reject contradictory group combinations (view-only + write-capable)
123+
// before hitting the API so the user gets an actionable message.
124+
const groupConflict = validateGroupCombination(selectedGroups, availableGroups);
125+
if (groupConflict) {
126+
showError(groupConflict);
127+
return;
128+
}
129+
122130
try {
123131
if (currentEditingUser) {
124132
// Update existing user

‎frontend/src/users/utils.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,61 @@ export { formatRelativeTime, formatDate } from '../utils';
1919
*/
2020
export { escapeHtml } from '../utils';
2121

22+
import type { APIGroup } from '../api';
2223
import { showToast } from '../toast';
2324

25+
/**
26+
* Validate that a proposed group-ID list does not contain a contradictory
27+
* combination: a view-only group (all permissions carry action === 'view')
28+
* paired with a write-capable group (at least one permission has a
29+
* non-view action such as create/update/delete/execute/approve/admin).
30+
*
31+
* Because CUDly's permission model is additive (the user gets the union of
32+
* all group permissions), pairing a view-only group with a write group is
33+
* semantically contradictory: the intent of a "Viewer" group is that the
34+
* user should have read-only access, but adding any write-capable group
35+
* silently elevates them. The UI should surface this immediately rather
36+
* than letting the combination go through.
37+
*
38+
* Groups with zero permissions are skipped (they grant nothing and do not
39+
* participate in the conflict).
40+
*
41+
* Returns a human-readable error string when the combination is invalid,
42+
* or null when it is acceptable.
43+
*
44+
* @param groupIds UUIDs of groups the user would be assigned
45+
* @param allGroups Full list of loaded groups (from availableGroups)
46+
*/
47+
export function validateGroupCombination(
48+
groupIds: string[],
49+
allGroups: APIGroup[],
50+
): string | null {
51+
const groups = groupIds
52+
.map(id => allGroups.find(g => g.id === id))
53+
.filter((g): g is APIGroup => g !== undefined);
54+
55+
const viewOnlyGroups = groups.filter(
56+
g => Array.isArray(g.permissions) &&
57+
g.permissions.length > 0 &&
58+
g.permissions.every(p => p.action === 'view'),
59+
);
60+
const writeGroups = groups.filter(
61+
g => Array.isArray(g.permissions) &&
62+
g.permissions.some(p => p.action !== 'view'),
63+
);
64+
65+
if (viewOnlyGroups.length === 0 || writeGroups.length === 0) return null;
66+
67+
const viewNames = viewOnlyGroups.map(g => `"${g.name}"`).join(', ');
68+
const writeNames = writeGroups.map(g => `"${g.name}"`).join(', ');
69+
return (
70+
`Contradictory group combination: ${viewNames} ` +
71+
`${viewOnlyGroups.length === 1 ? 'is' : 'are'} view-only but ` +
72+
`${writeNames} grant${writeGroups.length === 1 ? 's' : ''} write permissions. ` +
73+
`Assign a view-only group or write-capable groups, not both.`
74+
);
75+
}
76+
2477
/**
2578
* Show error message. Delegates to the shared toast system so callers
2679
* across the codebase get consistent bottom-right stacked toasts with

‎internal/analytics/collector_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,15 @@ func (m *mockConfigStore) TransitionExecutionStatus(ctx context.Context, executi
279279
return nil, nil
280280
}
281281

282-
func (m *mockConfigStore) SetCancelledBy(_ context.Context, _ string, _ string) error {
282+
func (m *mockConfigStore) SetCanceledBy(_ context.Context, _ string, _ string) error {
283283
return nil
284284
}
285285

286-
func (m *mockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) {
286+
func (m *mockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, canceledBy *string) (bool, string, error) {
287287
return false, "", nil
288288
}
289289

290-
func (m *mockConfigStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) {
290+
func (m *mockConfigStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, canceledBy *string) (bool, string, error) {
291291
return false, "", nil
292292
}
293293

‎internal/api/coverage_extras_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ func TestHandler_rejectRIExchange_AlreadyProcessed(t *testing.T) {
332332
mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return(
333333
&config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "tok"}, nil)
334334
// Transition returns nil indicating already processed
335-
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything).
335+
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "canceled", mock.Anything).
336336
Return(nil, nil)
337337

338338
h := &Handler{config: mockStore}

‎internal/api/handler_history.go‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,12 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR
114114
// wave-2) appear in the History view with a Revoke button before the cloud SDK
115115
// call fires. Without this entry the row is invisible to the History UI, making
116116
// the Revoke button unreachable (issue #290, second-wave CR Finding E).
117-
// historyExecutionStatuses includes both "canceled" (new canonical spelling) and
118-
// "cancelled" (DB-stored value written by CancelExecutionAtomic until migration
119-
// #1277 renames the column). Both spellings must be accepted until that migration lands.
120-
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "canceled", "cancelled"}
117+
// Both the US-spelling status (config.StatusCanceled) and the legacy British
118+
// spelling (config.LegacyStatusCanceled) are listed: during the expand-contract
119+
// rename (migration 000078) old code may still write the legacy value before
120+
// the rolling deploy completes. The contract migration (#1278) normalizes the
121+
// data once the deploy is verified stable; drop the legacy entry here then.
122+
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", config.StatusCanceled, config.LegacyStatusCanceled}
121123

122124
// approvalExpiryWindow is how long a pending approval stays actionable
123125
// before the History view flips it to "expired". Aligns with the
@@ -332,7 +334,10 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P
332334
row.StatusDescription = exec.Error
333335
case "expired":
334336
row.StatusDescription = "approval link expired (not approved within 7 days)"
335-
case "canceled", "cancelled": // both spellings until migration #1277 renames the DB column
337+
case config.StatusCanceled, config.LegacyStatusCanceled:
338+
// The legacy British spelling is still matched during the
339+
// expand-contract rename (migration 000078) until the contract
340+
// migration (#1278) normalizes and drops it.
336341
annotateCancelled(row, exec, approver)
337342
default:
338343
// In-flight (approved/running/scheduled/paused) and audit-gap
@@ -398,15 +403,15 @@ func annotateInFlightOrAuditGapRow(row *config.PurchaseHistoryRecord, exec confi
398403
}
399404

400405
// annotateCancelled resolves who canceled the execution:
401-
// 1. exec.CancelledBy — populated by the session-authed deep-link flow;
406+
// 1. exec.CanceledBy -- populated by the session-authed deep-link flow;
402407
// exact session-authed click attribution.
403-
// 2. approver — the notification inbox that received the cancel token;
408+
// 2. approver -- the notification inbox that received the cancel token;
404409
// authoritative accountable party but not necessarily the clicker.
405410
// Used on legacy token-only paths (async workers, old email clicks).
406411
func annotateCancelled(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) {
407-
if exec.CancelledBy != nil && *exec.CancelledBy != "" {
408-
row.Approver = *exec.CancelledBy
409-
row.StatusDescription = "canceled by " + *exec.CancelledBy
412+
if exec.CanceledBy != nil && *exec.CanceledBy != "" {
413+
row.Approver = *exec.CanceledBy
414+
row.StatusDescription = "canceled by " + *exec.CanceledBy
410415
return
411416
}
412417
if approver != "" {
@@ -1009,10 +1014,14 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS
10091014
case "expired":
10101015
summary.TotalExpired++
10111016
continue
1012-
case "canceled", "cancelled": // both spellings until migration #1277 renames the DB column
1017+
case config.StatusCanceled, config.LegacyStatusCanceled:
10131018
// A canceled purchase represents zero committed spend and zero
10141019
// realized savings (issue #736). Exclude from all dollar KPIs and
1015-
// from TotalCompleted — the money was never committed.
1020+
// from TotalCompleted -- the money was never committed. The legacy
1021+
// British spelling is matched alongside the US one during the
1022+
// expand-contract rename (migration 000078) so legacy rows can't
1023+
// inflate KPIs mid-deploy; the contract migration (#1278) drops it
1024+
// once the deploy is stable.
10161025
continue
10171026
}
10181027
summary.TotalCompleted++

‎internal/api/handler_history_test.go‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,10 +1708,17 @@ func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) {
17081708
{Status: "", UpfrontCost: 50.0, EstimatedSavings: 5.0}, // legacy row, no status
17091709
// One pending row that should be counted as pending, not completed.
17101710
{Status: "pending", UpfrontCost: 999.0, EstimatedSavings: 99.0},
1711-
// Two canceled rows — the regression case from issue #736.
1712-
// Neither must appear in the dollar KPIs or TotalCompleted.
1713-
{Status: "canceled", UpfrontCost: 500.0, EstimatedSavings: 50.0},
1714-
{Status: "canceled", UpfrontCost: 750.0, EstimatedSavings: 75.0},
1711+
// Two canceled rows — the regression case from issue #736. Neither must
1712+
// appear in the dollar KPIs or TotalCompleted. One uses the new US
1713+
// spelling (config.StatusCanceled) and one the legacy British spelling
1714+
// (config.LegacyStatusCanceled): during the expand-contract rename
1715+
// (migration 000078) a mixed fleet still emits the legacy spelling, so
1716+
// the dual-spelling read path must exclude BOTH. The constant carries
1717+
// the legacy value without a literal the US-locale misspell linter would
1718+
// flag (and without a nolint). Drop the legacy fixture once the contract
1719+
// migration (#1278) normalizes the data.
1720+
{Status: config.StatusCanceled, UpfrontCost: 500.0, EstimatedSavings: 50.0},
1721+
{Status: config.LegacyStatusCanceled, UpfrontCost: 750.0, EstimatedSavings: 75.0},
17151722
}
17161723

17171724
summary := summarizePurchaseHistory(purchases)

0 commit comments

Comments
 (0)