Skip to content

Feature: Change email address (WorkOS-verified, WP conflict-guarded) #22

@bordoni

Description

@bordoni

Summary

Add a self-service and admin-triggered "Change email" feature, mirroring the existing PasswordResetAdmin flow. WorkOS verifies ownership of the new address; WordPress guards against collisions with existing local accounts. Behavior on conflict is configurable — default is block.

Future "claim & merge" handling for the conflict case is tracked separately (see follow-up issue).

Goals

  • Let a user change their primary email with WorkOS-driven verification (double opt-in to the new address).
  • Let an admin initiate the same change for another user (edit_user capability), mirroring the password-reset admin pattern.
  • Prevent silently overwriting another local WP user's email; expose the conflict policy as an option.
  • Keep WorkOS ⇄ WP email state consistent (no double-processing when the WorkOS webhook fires).
  • Provide hooks/events so other features (and Issue 2's merge flow) can extend the behavior.

Non-goals

  • Account merging / claiming an existing account. That's the follow-up issue.
  • Changing the WorkOS organization, profile attributes other than email, or username.
  • Bulk email changes.

User flows

Self-service (logged-in user)

  1. User opens profile/shortcode page, enters new email.
  2. WP validates format, runs conflict check, applies rate limit.
  3. WP stores a pending change (token + hash + expiry) in user meta.
  4. Verification email is sent to the new address with a confirm link.
  5. (Optional, default on) Notification email is sent to the old address: "An email change was requested. If this wasn't you…" with a cancel link.
  6. User clicks confirm → WP re-checks conflict (race guard) → WP calls WorkOS update_user(['email' => ...]) → WP calls wp_update_user() → pending meta is cleared → logged.
  7. (Optional, default on) Confirmation notice is sent to the old address: "Your email was changed to ✱✱✱@example.com."

Admin-initiated

  • "Change email" row action under the existing WorkOS column on wp-admin/users.php.
  • "Change email" panel on user-edit.php (mirrors PasswordResetAdmin\UserProfilePanel).
  • Admin enters the new email; verification still goes to the new address (admins do not bypass verification by default — gated by a filter, see Hooks).

WorkOS API touchpoints

The plugin already has what we need in src/WorkOS/Api/Client.php:

  • update_user( $workos_user_id, ['email' => $new_email] ) — commits the change in WorkOS.
  • Verification is performed on the WP side via our own signed token (same pattern as PasswordResetAdmin), because WorkOS's email_verification endpoints verify the current email on the WorkOS user, not a pending change.

(Open question: if WorkOS's update_user triggers an automatic user.updated webhook, Sync\UserSync::handle_user_updated() must not race with our confirmation handler. Plan: set a short-lived transient _workos_email_change_in_progress_<user_id> and have UserSync skip if set.)

Settings / options

Stored under the active environment (workos()->option(...)), with sensible defaults:

Option Default Purpose
change_email_enabled true Master switch
change_email_conflict_policy 'block' block | allow_orphan | merge_request (see below)
change_email_token_lifetime 3600 Seconds the confirm token is valid
change_email_rate_limit_user 3 per hour Per-user initiate cap
change_email_rate_limit_ip 10 per hour Per-IP initiate cap
change_email_notify_old_address true Send "change requested" + "change confirmed" notices to old address
change_email_require_reauth true Require password / fresh session before initiating self-service
change_email_admin_bypass_verification false When true, an admin with edit_users may commit the change without email verification (audit-logged)

Conflict policy values

  • block (default): hard reject. The user sees: "An account with that email already exists."
  • allow_orphan: allowed only if the conflicting WP user has no _workos_user_id and no posts/comments/logins in the last N days (filterable). Audit-logged as a takeover.
  • merge_request: rejected for now, but emits workos/change_email/merge_requested for the future merge feature (Issue 2) to handle. Until Issue 2 ships this behaves like block plus a user-facing message: "An account with this email exists. Merging is not yet available."

Architecture (mirrors PasswordResetAdmin)

src/WorkOS/Auth/ChangeEmail/
├── Controller.php          DI wiring (registered from src/WorkOS/Controller.php)
├── RestApi.php             POST /workos/v1/users/{id}/email-change
│                           POST /workos/v1/users/{id}/email-change/confirm
│                           POST /workos/v1/users/{id}/email-change/cancel
├── PendingChange.php       Storage helper (user_meta `_workos_pending_email_change`)
│                           — fields: new_email, token_hash, expires_at, initiated_by, initiated_at
├── ConflictResolver.php    Local WP + WorkOS conflict checks + policy enforcement
├── Notifier.php            Sends verification / cancel-link / confirmation emails
├── RowActions.php          Admin row action on users.php
├── UserProfilePanel.php    Admin panel on user-edit.php
├── Shortcode.php           [workos:change-email] for self-service
├── Assets.php              JS/CSS registration
└── RedirectValidator.php   Reuse Auth\PasswordResetAdmin\RedirectValidator if generic enough

src/js/admin-change-email/
└── index.ts                Delegated click handler (mirrors admin-password-reset)

templates/change-email/
├── verification-email.php
├── old-address-notice.php
└── confirmation-page.php

tests/wpunit/
├── ChangeEmailRestApiTest.php
├── ChangeEmailConflictResolverTest.php
├── ChangeEmailPendingChangeTest.php
└── ChangeEmailNotifierTest.php

REST endpoints

All under /wp-json/workos/v1/.

POST users/{id}/email-change

  • Auth: current user is {id} OR has edit_user cap for {id}.
  • Body: { new_email, redirect_url? }.
  • Capability + rate-limit checks (reuse RateLimiter).
  • Email format + conflict checks.
  • Stores pending change, sends verification, returns enumeration-safe success.

POST users/{id}/email-change/confirm

  • Body: { token }.
  • Validates token + expiry, re-runs conflict check.
  • Calls WorkOS update_user → on success calls wp_update_user → clears meta → logs event.
  • Returns the post-change redirect_url (validated).

POST users/{id}/email-change/cancel

  • Body: { token } (from the notice-to-old-address link) or capability edit_user.
  • Clears pending meta, logs event.

Activity events

Logged via the existing EventLogger:

  • email_change.initiated
  • email_change.confirmed
  • email_change.cancelled
  • email_change.expired
  • email_change.conflict_blocked
  • email_change.admin_bypass (when change_email_admin_bypass_verification triggers)

Each entry stores { user_id, initiated_by, masked_new_email, masked_old_email, policy }.

Hooks

Filters:

  • workos/change_email/enabled — bool
  • workos/change_email/conflict_policy — string (overrides setting)
  • workos/change_email/token_lifetime — int
  • workos/change_email/can_initiate — bool (with $user_id, $initiator_id)
  • workos/change_email/notification_recipients — array (extend old/new address logic)

Actions:

  • workos/change_email/initiated( int $user_id, string $new_email, int $initiated_by )
  • workos/change_email/confirmed( int $user_id, string $old_email, string $new_email )
  • workos/change_email/cancelled( int $user_id, string $reason )
  • workos/change_email/conflict_detected( int $user_id, string $new_email, int $conflicting_user_id ) (the hook Issue 2's merge flow listens on)

Security checklist

  • Token hashing on storage (no plaintext in DB).
  • Constant-time comparison on confirm.
  • Token single-use; expiry enforced.
  • Enumeration-safe responses on initiate.
  • Rate limiting per user + per IP.
  • Notify old address by default, with a one-click cancel link (mitigates account-takeover via session hijack).
  • CSRF / nonce on admin row action; capability check on REST.
  • Audit-log every state transition.
  • WorkOS webhook race guard via in-progress transient.
  • HTML-escape new email everywhere (it's user input).

Tests (slic / WPUnit)

  • Initiate: valid input → pending meta written, verification email captured via pre_http_request/wp_mail mock.
  • Initiate: invalid email format → WP_Error.
  • Initiate: conflict with local user → WP_Error under block; success-but-no-email under merge_request.
  • Initiate: rate limit per user / per IP.
  • Initiate: capability rejection when admin tries on another user without edit_user.
  • Confirm: valid token → WP user email updated, WorkOS update_user called, pending meta cleared, event logged.
  • Confirm: expired token → error, no API call.
  • Confirm: tampered token → error.
  • Confirm: race — conflict appears between initiate and confirm → reject and roll back cleanly.
  • Cancel: token from old-address notice clears pending state.
  • Notifier: sends to new address on initiate, to old address on initiate (when enabled) and on confirm.
  • Webhook race: UserSync::handle_user_updated() for the same email is a no-op while in-progress transient is set.

Documentation

  • Add docs/change-email.md (full feature reference).
  • Update AGENTS.md with a "ChangeEmail" section mirroring "PasswordResetAdmin".
  • Update README.md user-facing section.
  • Add shortcode docs.
  • Update readme.txt (wp.org changelog).

Acceptance criteria

  • Self-service flow works end-to-end with WorkOS verification.
  • Admin row action + profile panel work end-to-end.
  • All settings honored; defaults are safe.
  • Conflict policy block is the default and cannot be bypassed without explicit admin opt-in.
  • No regressions in UserSync (existing tests pass).
  • All new code covered by WPUnit tests.
  • AGENTS.md + README.md + readme.txt + docs/change-email.md updated.
  • CHANGELOG entry under 1.0.6.

Out of scope (tracked separately)

  • Account merging / claiming an existing account when the new email collides.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions