Skip to content

fix(ui,cache): three bulk-action UX/cache bugs - #579

Merged
CybotTM merged 6 commits into
mainfrom
fix/bulk-action-ux
Apr 24, 2026
Merged

fix(ui,cache): three bulk-action UX/cache bugs#579
CybotTM merged 6 commits into
mainfrom
fix/bulk-action-ux

Conversation

@CybotTM

@CybotTM CybotTM commented Apr 24, 2026

Copy link
Copy Markdown
Member

Summary

Three independent bugs surfaced while reviewing the shipped UI revamp (#571).
Each lands as its own atomic commit — bisect stays useful, each commit
builds, vets, tests, and lints independently.

# Commit Bug
1 bf8926f Already-disabled users still showed a "Disable" action button in the drawer
2 45e4ef1 After Disable/Delete the list and drawer kept showing the now-changed entity for several seconds (AD replication lag between the writing DC and the readonly-bind DC used by the cache refresh)
3 20db214 Disable/Delete redirected to the bare /users route, discarding OU filter, enabled filter, and the open drawer on every action

Commit 1 — fix(ui): gate Disable action on User/Computer.Enabled

Wraps the Disable form in if vm.IsAD && vm.User.Enabled (and the mirror for computers) so it only surfaces when the action is meaningful. One-line template change, 85-line test matrix covering IsAD × Enabled.

Commit 2 — fix(cache): optimistic updates on delete + disable

New Manager hooks — OnDelete{User,Group,Computer} and OnDisable{User,Computer} — that mutate the in-memory cache synchronously in the per-entity success branch of the bulk handlers. No longer depend on winning the race against AD replication; the trailing Refresh() now runs as a reconciliation pass only.

Follows the existing OnAddUserToGroup / OnRemoveUserFromGroup pattern. Supporting change: a new unexported Cache[T].remove(dn) that drops a single entry by DN and rebuilds the O(1) indexes.

Commit 3 — fix(ui): preserve filters + drawer on bulk action redirects

All twelve 303 redirects in `bulk_handlers.go` now route through a new `bulkRedirectAfter` helper that:

  • Preserves the originating query string (OU, enabled, member-of filter chips) by reading the `Referer` header.
  • Keeps `?panel=` on disable/add/remove (drawer stays on the now-updated entity) and strips it on delete (entity is gone).
  • When the Referer was a `/users/:dn` detail page of a deleted entity, collapses back to the parent list.
  • Rejects cross-origin and unparseable Referers — falls back to the hard-coded list path; defence in depth against open-redirect.
  • Uses `EscapedPath` so percent-encoded DNs round-trip verbatim.

Out of scope — deliberately deferred

  • Full htmx-ification of the drawer forms (the 303 redirect + filter preservation fixes bug 1 without restructuring the response model; htmx drawer updates can land alongside the graph-view work).
  • Re-enable button for already-disabled users (needs a new `bulkEnableUsers` + `EnableUserContext` wiring — small separate PR).
  • Deeper investigation into AD-replication timing characteristics (the optimistic-cache fix in commit 2 makes this moot for the UX bug).

Test plan

  • `go test ./internal/ldap_cache/` — 8 new subtests for `Cache.remove` + the 5 new Manager hooks
  • `go test ./internal/web/templates/` — 8 new subtests for the drawer Disable-button gating matrix
  • `go test ./internal/web/` (non-integration) — 10 new subtests for `bulkRedirectAfter`
  • `go vet ./...` clean
  • `golangci-lint run ./internal/...` clean
  • CI: lint + test + coverage on branch push
  • Manual smoke on dev stack: disable/delete from drawer, confirm filters + drawer state preserved, confirm cache reflects op immediately (no delay)

Each commit is signed (ED25519) and carries a `Signed-off-by` trailer.

CybotTM added 3 commits April 24, 2026 15:12
The drawer Actions section in both users_v2.templ and computers_v2.templ
previously rendered the Disable form whenever the backend was Active
Directory, regardless of the entity's current enabled state. An
already-disabled user kept seeing a "Disable" button that, when
clicked, asked the directory to re-disable an already-disabled account.

Wrap the form in the combined guard (IsAD && Enabled) so the action
only surfaces when it can do something. Comment on the IsAD field
updated to call out the Enabled requirement.

Coverage: new drawer_disable_gating_test.go asserts the IsAD × Enabled
matrix for both User and Computer drawer fragments.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Bug symptom: after clicking Delete or Disable on a user/group/computer,
the list+drawer still showed the target entry for several seconds
("took longer than expected"), even though the LDAP op succeeded. Cause
is AD replication delay — Refresh() runs against the readonly-bind DC
which hasn't yet received the mutation from the writing DC.

Solution: bypass the replication round-trip by applying the mutation to
the in-memory cache directly, the moment the per-entity LDAP op returns
without error. Follows the existing OnAddUserToGroup /
OnRemoveUserFromGroup pattern.

New Manager hooks (wired into finaliseBulk* in bulk_handlers.go):
  - OnDeleteUser(dn)    — drops entry, scrubs from Groups.Members
  - OnDeleteGroup(dn)   — drops entry, scrubs from User.Groups /
                          Computer.Groups
  - OnDeleteComputer(dn) — drops entry
  - OnDisableUser(dn)   — flips Enabled=false on the user
  - OnDisableComputer(dn) — flips Enabled=false on the computer

Supporting change: new unexported Cache[T].remove(dn) that deletes a
single entry by DN and rebuilds the O(1) indexes. No-op on miss so
callers can call it optimistically.

The trailing full Refresh() in finaliseBulk* is kept as a reconciliation
pass but correctness of the redirected list no longer depends on it
winning the race against replication.

Coverage: hooks_test.go (8 subtests): Cache.remove happy/miss/empty,
OnDeleteUser member-scrub, OnDeleteGroup memberOf-scrub on both users
and computers, OnDeleteComputer idempotence, OnDisable{User,Computer}
Enabled-flip.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Bug symptom: clicking Disable or Delete on a user from the drawer
(which is reached via /users?ou=Eng&enabled=true&panel=1) redirected
to a bare /users, discarding the OU filter, the enabled filter, and
closing the drawer. The user had to re-apply their context on every
action.

All twelve 303 redirects in bulk_handlers.go now route through a new
bulkRedirectAfter helper that:

  - Reads the Referer header (the URL that served the form) instead
    of a hard-coded list path.
  - Rejects cross-origin and unparseable values, falling back to the
    hard-coded list — defence in depth against open-redirect via a
    crafted Referer.
  - Only honours Referers pointing at the same list surface
    (fallbackList or a detail route beneath it).
  - Uses EscapedPath so percent-encoded DNs round-trip verbatim.
  - When dropPanel=true (delete actions): strips ?panel= and, if the
    Referer was a /users/:dn detail page of the now-deleted entity,
    collapses back to /users so the user doesn't land on a dangling
    detail route.
  - When dropPanel=false (disable, add-to-group, remove-from-group,
    add-members-to-groups): keeps the Referer verbatim so the drawer
    reopens on the same entity (now in its updated state) with all
    filter chips intact.

Coverage: bulk_redirect_test.go exercises 10 scenarios including
empty Referer, list-with-query, detail-page-with-query for both
dropPanel modes, cross-origin rejection, unparseable rejection,
and the panel-only-param edge case.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Copilot AI review requested due to automatic review settings April 24, 2026 13:24
@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

github-actions[bot]
github-actions Bot previously approved these changes Apr 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Mutation Testing Results

Mutation Score: 0% (threshold: 60%)

⚠️ Score is below threshold. Consider improving test coverage or test quality.

What is mutation testing?

Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness.

  • Killed mutants: Tests caught the mutation (good!)
  • Survived mutants: Tests missed the mutation (needs improvement)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces optimistic cache updates and improved redirect logic for bulk LDAP operations. It adds methods to the Manager to immediately reflect deletions and account disabling in the local cache, reducing the impact of AD replication delays. It also enhances the web UI by preserving filter parameters after actions and gating the 'Disable' button based on the current account status. Feedback focuses on performance optimizations for bulk updates, specifically regarding redundant index rebuilding and linear scans where indexed lookups are possible, as well as a potential memory leak in slice manipulation and a race condition where synchronous refreshes might overwrite optimistic updates with stale data.

Comment thread internal/web/bulk_handlers.go
Comment thread internal/ldap_cache/cache.go Outdated
Comment thread internal/ldap_cache/manager.go
Comment thread internal/ldap_cache/manager.go
@codecov

codecov Bot commented Apr 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.00000% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.42%. Comparing base (f20888f) to head (d6c253b).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
internal/web/bulk_handlers.go 55.00% 25 Missing and 2 partials ⚠️
internal/ldap_cache/manager.go 80.00% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #579      +/-   ##
==========================================
+ Coverage   67.15%   67.42%   +0.26%     
==========================================
  Files          29       29              
  Lines        2780     2864      +84     
==========================================
+ Hits         1867     1931      +64     
- Misses        783      799      +16     
- Partials      130      134       +4     
Flag Coverage Δ
e2e 58.99% <ø> (ø)
unittests 67.85% <70.00%> (+0.26%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes three bulk-action regressions introduced/observed after the UI revamp: (1) hide meaningless Disable actions for already-disabled entities, (2) make cache/UI reflect disable/delete immediately despite AD replication lag, and (3) preserve list filters + drawer state across bulk-action redirects.

Changes:

  • Gate Disable drawer actions on both backend being AD and the entity being currently enabled.
  • Add optimistic cache hooks for delete/disable to avoid stale list/drawer state while replication catches up.
  • Route bulk-action redirects through a helper that preserves the originating query string and conditionally retains ?panel=.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/web/templates/users_v2.templ Hide “Disable” action unless IsAD and user is enabled.
internal/web/templates/computers_v2.templ Hide “Disable” action unless IsAD and computer is enabled.
internal/web/templates/drawer_disable_gating_test.go Adds tests covering the IsAD × Enabled gating matrix for drawers.
internal/web/bulk_handlers.go Adds bulkRedirectAfter and wires redirects through it; adds optimistic cache hooks for delete/disable.
internal/web/bulk_redirect_test.go Unit tests for redirect helper behavior (filters/panel preservation + safety fallbacks).
internal/ldap_cache/manager.go Adds Manager hooks for OnDelete*/OnDisable* optimistic cache updates.
internal/ldap_cache/cache.go Adds Cache.remove(dn) for single-entity eviction + index rebuild.
internal/ldap_cache/hooks_test.go Tests for Cache.remove and the new Manager hooks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/web/bulk_handlers.go Outdated
Comment thread internal/ldap_cache/manager.go Outdated
Comment thread internal/web/bulk_redirect_test.go Outdated
CybotTM added 3 commits April 24, 2026 15:44
…ter scrub

Post-review follow-up on PR #579. Three correctness/efficiency fixes
flagged by Gemini and Copilot:

1. Cache.remove now uses the dnIndex for O(1) location and slices.Delete
   for the slice shrink. slices.Delete zeroes the vacated slot so pointer
   fields inside T (e.g. ldap.User.Mail *string) don't keep the deleted
   entry's data alive indefinitely — the previous append-based removal
   left the vacated slot pointing at the removed element until another
   append overwrote it.

2. New Cache.updateByDN(dn, fn) does a dnIndex-backed O(1) targeted
   mutation. Callers must only perform non-key mutations (the method
   does not rebuild indexes). OnDisableUser / OnDisableComputer use
   this instead of full-cache update() scans — flipping Enabled on a
   directory with tens of thousands of cached users no longer linearly
   walks the whole slice.

3. OnDeleteComputer now scrubs the computer DN from every cached
   group's Members list. Computers can be group members in AD (machine
   accounts in security groups), and PopulateGroupsForComputerFromData
   derives a computer's group memberships by scanning group Members at
   display time. Without the scrub, group member counts and drawer
   listings stayed stale until the next background Refresh picked up
   the change.

Supporting cleanups: OnDeleteUser / OnDeleteGroup now use
slices.DeleteFunc for the membership scrubs (same semantics, fewer
lines, idiomatic).

Test updates:
  - New TestCacheUpdateByDN covers the mutate-matching-entry and
    no-op-on-unknown-DN branches.
  - New TestManagerOnDeleteComputer_ScrubGroupMembership asserts the
    computer DN is removed from the group's Members list after
    OnDeleteComputer.
  - TestManagerOnDisableUser/Computer_FlipsEnabled replaced by
    TestManagerOnDisable_NoOpOnAbsentDN; the happy-path flip is now
    covered by TestCacheUpdateByDN above (seeding an ldap.User with a
    real DN requires reaching into simple-ldap-go's unexported Object
    fields, which we avoid).

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Addresses Gemini's high-priority review finding on PR #579.

Before this change, finaliseBulkDelete and finaliseBulkDisable called
a.ldapCache.Refresh() immediately after the optimistic OnDelete*/
OnDisable* hooks had applied the mutation locally. Refresh() queries
the readonly-bind DC, which under typical AD replication delay still
sees the pre-mutation state and overwrites the optimistic update via
setAll(). Result: the list page briefly showed the correct state,
then flipped back to the pre-op state until the next 30 s background
refresh picked up the replicated mutation.

That race IS the user-visible "took longer than expected" symptom
that motivated the whole optimistic-cache effort. Removing the sync
Refresh completes the fix.

Correctness is now covered by:
  - Per-entity optimistic hooks (OnDelete{User,Group,Computer},
    OnDisable{User,Computer}) applied in the success branch of the
    bulk loops — immediate, authoritative, immune to replication lag.
  - The existing 30 s background Refresh loop in ldap_cache.Manager
    — picks up anything the hooks don't cover (unrelated mutations,
    concurrent admin changes on another DC).
  - The existing templateCache.Clear() in each finalise* — still done
    so cached HTML responses don't linger.

Existing tests continue to pass; no new tests needed since this is
removal of a behaviour that was always racing with replication.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Two follow-up nits from the Copilot review on PR #579:

- bulkRedirectAfter's same-origin guard compared refURL.Host
  (hostname:port) against c.Hostname() (hostname only). A same-origin
  Referer whose URL included the port — common in dev (localhost:3000)
  and behind some reverse proxies — was incorrectly flagged as cross-
  origin, falling back to the bare list path and losing filters. Fixed
  to refURL.Hostname() vs c.Hostname(). New subtest
  "same-origin with explicit port preserved" locks in the behaviour.

- bulk_redirect_test.go read the response body with a single
  Read into a fixed 256-byte buffer. Read is allowed to return partial
  data; if the helper output ever grew past 256 bytes or the reader
  fragmented the response, the assertion would silently truncate and
  either pass on a partial match or false-positive on a trailing
  diff. Replaced with io.ReadAll.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM
CybotTM enabled auto-merge April 24, 2026 15:18
@CybotTM
CybotTM disabled auto-merge April 24, 2026 15:25
@CybotTM
CybotTM enabled auto-merge April 24, 2026 15:25

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM
CybotTM requested a review from Copilot April 24, 2026 15:29
@CybotTM
CybotTM merged commit 724cd2e into main Apr 24, 2026
29 checks passed
@CybotTM
CybotTM deleted the fix/bulk-action-ux branch April 24, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/ldap_cache/cache.go
CybotTM added a commit that referenced this pull request Apr 25, 2026
Follow-up to #579 review feedback.

The doc comment on `Cache[T].remove()` claimed "O(1) location lookup via
dnIndex" but the implementation:
1. Uses dnIndex only for **presence** detection (the cached pointer)
2. Then **linearly scans** `c.items` to find the matching slice element
by pointer comparison
3. Calls `buildIndexes()` afterwards, which is also O(n)

Comment now reflects the true O(n) complexity. No behavior change.

Resolves the unresolved review thread on #579
(#579 (comment)).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants