Skip to content

fix(auth/users): persist email change in admin update path (closes #892)#893

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/admin-email-edit-not-persisted
May 31, 2026
Merged

fix(auth/users): persist email change in admin update path (closes #892)#893
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/admin-email-edit-not-persisted

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 30, 2026

Summary

Fixes #892. Editing another user's email via the admin Users page reported success but never persisted the change — the new email was silently dropped on the service-layer boundary, so the user remained signed in (or locked out) with the old address.

Root cause

internal/auth/service_api.go:222-239. Service.UpdateUserAPI built an internal UpdateUserRequest from the API payload but only copied Role and Groups. req.Email was read off the JSON (it's a real field on APIUpdateUserRequest), then thrown away. Downstream Service.UpdateUser fetched the existing user and called store.UpdateUser with the unchanged user.Email value, so the SQL UPDATE users SET email = $2 ... wrote the OLD email back. The handler returned 200, the frontend's success toast fired, the database row was unchanged.

Why the toast lied

The frontend's updateUser (frontend/src/api/users.ts:48) keys success off HTTP 200. The backend returned 200 with the (unchanged) APIUser JSON. Without a per-field comparison against the request, the frontend has no way to detect that the server silently ignored a field — and there's no good reason it should need to. The contract has to hold server-side.

Why existing tests didn't catch it

TestService_UpdateUserAPI/successful user update (service_api_test.go:183) exercised only Role and Groups. No prior test passed Email: in an APIUpdateUserRequest, so the silent drop was never observed.

Fix shape

Minimal: wire the field through the layer that was dropping it. Reuses the existing s.updateUserEmail helper (already used by the self-edit profile path at service_user.go:376), which performs format validation (TLD constraint per #868) and uniqueness check. No duplication, no new validation logic.

  • internal/auth/types.go: add Email *string to UpdateUserRequest (pointer matches the Role *string pattern so callers can distinguish "not sending" from "sending empty").
  • internal/auth/service_api.go: copy req.Email into authReq.Email when non-empty.
  • internal/auth/service_user.go: call s.updateUserEmail in UpdateUser when req.Email != nil.

mapAuthError already maps ErrInvalidEmail → 400 and ErrEmailInUse → 409, so error surfacing is correct for free.

Regression test

TestService_UpdateUserAPI gains two subtests:

  • successful email update persists to store — assertion on the User captured by store.UpdateUser (must carry the NEW email), not on the HTTP status. Without the fix, this subtest fails because the captured User still carries the old email.
  • email update rejects duplicate address — asserts the uniqueness check surfaces ErrEmailInUse (handler maps to 409).

Stash-verified: both subtests fail against pre-fix code, pass with fix applied. The pre-existing role-only subtest is unchanged and still passes.

Test plan

  • gofmt -l internal/auth/ (clean)
  • go vet ./internal/auth/... (clean)
  • go build ./... (clean)
  • go test ./internal/auth/... ./internal/api/... -count=1 (1854 pass)
  • Stash-verified the regression test fails on pre-fix code and passes with fix.
  • Post-merge: manual smoke via admin UI — edit another user's email, confirm DB row updated and the user can sign in with the new address.

Scope discipline

Only the admin email-edit path is touched. UpdateUserProfile (self-edit) is unchanged — already worked. No drive-by refactors. Other update paths (Active, Role) keep their existing semantics.

closes #892

UpdateUserAPI accepted req.Email on the wire (APIUpdateUserRequest)
but dropped it during conversion to the internal UpdateUserRequest,
which had no Email field. The handler returned 200, the success toast
fired, but users.email was never UPDATEd. An admin attempting to edit
another user's address saw the UI confirm success while the user
remained locked into the old address (sign-in lockout risk).

Wire the field through:

- types.go: add Email *string to UpdateUserRequest (pointer so callers
  can distinguish "not sending email" from "explicitly setting email",
  matching the existing Role pointer pattern).
- service_api.go: copy req.Email into authReq.Email when non-empty.
- service_user.go: in UpdateUser, route email changes through the
  existing s.updateUserEmail helper so admin edits get the same
  format/TLD validation and uniqueness check as the self-edit
  profile path. No new code path; reuses #868's helper.

Regression test (TestService_UpdateUserAPI):
  - "successful email update persists to store" asserts the new
    email value reaches store.UpdateUser, not just that HTTP 200
    returns. Fails on pre-fix code.
  - "email update rejects duplicate address" asserts the uniqueness
    check surfaces "email already in use" via mapAuthError -> 409.
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint labels May 30, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 15 minutes and 40 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce791e1b-2f9d-4200-8816-5687c27c701a

📥 Commits

Reviewing files that changed from the base of the PR and between 4956d66 and 949c0c9.

📒 Files selected for processing (4)
  • internal/auth/service_api.go
  • internal/auth/service_api_test.go
  • internal/auth/service_user.go
  • internal/auth/types.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/admin-email-edit-not-persisted

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added severity/high Significant harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/s Hours type/bug Defect labels May 30, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 30, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim cristim merged commit 550610b into feat/multicloud-web-frontend May 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant