Skip to content

Change email (WorkOS-verified, conflict-guarded) — closes #22#24

Open
bordoni wants to merge 8 commits into
mainfrom
feature/workos-change-email
Open

Change email (WorkOS-verified, conflict-guarded) — closes #22#24
bordoni wants to merge 8 commits into
mainfrom
feature/workos-change-email

Conversation

@bordoni
Copy link
Copy Markdown
Owner

@bordoni bordoni commented May 23, 2026

Summary

Implements the Change Email feature from issue #22: a self-service and admin-triggered email-change flow with WP-side hashed-token verification, configurable conflict guard against local-user collisions, WorkOS+WP transactional commit, and a webhook race guard.

Verification is owned WP-side because WorkOS's email_verification endpoints verify the current address on a WorkOS user — not a pending change. The confirm endpoint commits to WorkOS first (via Client::update_user), mirrors into WordPress, and only then clears the pending meta — with a 60-second in-progress transient that short-circuits UserSync::handle_user_updated() so the webhook fan-back can't re-trigger the mutation.

Closes #22.

What ships

  • Three new REST endpoints under /workos/v1/users/{id}/email-change: initiate, confirm, cancel.
  • Hashed (HMAC-SHA256 + wp_salt('auth')) confirm + cancel tokens stored as _workos_pending_email_change user_meta — single-use, expiry-bounded, constant-time compared.
  • Admin "Change email" row action under the existing WorkOS column on wp-admin/users.php and a panel on the user-edit/profile screen.
  • Self-service [workos:change-email] shortcode.
  • Frontend confirm route under a configurable path (default /workos/change-email/, settable via change_email_confirm_path). The page is a thin shell — JS POSTs to the REST confirm/cancel endpoint so an email-prefetch scanner can't consume the token via GET.
  • Three transactional emails via wp_mail() (verification to new address, cancel-link notice to old address, post-commit confirmation to old address), with theme-overridable templates under templates/change-email/.
  • Conflict policies: block (default), allow_orphan (only when conflicting WP user has no WorkOS link, no posts, no comments, no recent activity), merge_request (rejects today + fires workos/change_email/merge_requested for future Issue 2 merge feature).
  • UserSync::handle_user_updated() race guard.
  • 11 new change_email_* options on Production + Staging defaults.
  • 8 new filters + 5 new actions + 7 new activity-log event types.

Files

  • New PHP: src/WorkOS/Auth/ChangeEmail/{Controller,RestApi,PendingChange,TokenFactory,ConflictResolver,Notifier,RowActions,UserProfilePanel,Shortcode,Assets,FrontendConfirmRoute}.php plus src/WorkOS/Email/Mailer.php (4 templates under templates/change-email/).
  • New JS: src/js/admin-change-email/index.ts (delegated trigger handler) + src/js/change-email-confirm/index.ts (confirm-page POST).
  • Modified: src/WorkOS/Controller.php (register), src/WorkOS/Options/{Production,Staging}.php (defaults), src/WorkOS/Sync/UserSync.php (race guard), webpack.config.js (two new entries), AGENTS.md, README.md, readme.txt, CHANGELOG.md.
  • New docs: docs/change-email.md (full feature reference + flow diagram + security checklist).
  • New tests: 40 tests across 6 suites under tests/wpunit/ChangeEmail*Test.php.

Security checklist (from issue)

  • Token hashing on storage — only HMAC-SHA256 hashes hit the DB.
  • Constant-time comparison — hash_equals() in TokenFactory::verify().
  • Token single-use — pending meta deleted on confirm/cancel/expiry.
  • Expiry enforced — expires_at checked before the comparison.
  • Enumeration-safe initiate — conflict responses share the same shape as success.
  • Rate limiting per user + per IP — two RateLimiter::attempt() calls per initiate.
  • Notify old address by default — cancel link included.
  • CSRF / nonce — X-WP-Nonce required on every endpoint; capability checks on initiate.
  • Audit log every state transition — 7 email_change.* event types.
  • WorkOS webhook race guard — 60s in-progress transient.
  • HTML-escape new email everywhere — esc_html() + sanitize_email().

Test plan

  • slic run wpunit --filter ChangeEmail — all 40 new tests pass.
  • Full slic run wpunit — only 4 pre-existing failures on main remain (PasswordResetTest, RendererKsesTest, UserSyncPushTest, UserSyncResyncTest — all "when disabled" variants unrelated to this branch).
  • Manual self-service: [workos:change-email] shortcode → request change to a free email → confirm via emailed link → wp_users.user_email updated + WorkOS user email updated.
  • Manual admin row action on wp-admin/users.php → prompt → verification still goes to the new address.
  • Manual conflict under block → user-facing message; under allow_orphan → success only when conflicting user is truly orphaned.
  • Manual cancel flow from old-address notice link.
  • Manual race: change conflicting user's email between initiate and confirm → confirm fails 409.
  • Manual webhook race: after confirm, UserSync::handle_user_updated skipped while transient is set.

Out of scope (tracked separately)

  • Account merge / claiming an existing account when the new email collides — Issue 2. This PR emits workos/change_email/merge_requested so the merge feature can subscribe later.

bordoni added 4 commits May 22, 2026 21:04
Adds the full file skeleton for the Change Email feature (issue #22):
WP-side hashed token + pending-state stored as user_meta, three REST
endpoints (initiate/confirm/cancel), conflict resolver with three
policies, wp_mail()-driven verification/cancel/confirmation templates,
admin row action + profile panel, [workos:change-email] shortcode,
frontend confirm route under a configurable path, UserSync race guard
via in-progress transient, and the 11 new options keys.

Verification is owned WP-side because WorkOS's email_verification
endpoints verify the *current* address on the WorkOS user, not a
pending change. The confirm endpoint commits to WorkOS first (via
Client::update_user), mirrors into WP via wp_update_user(), and only
then clears the pending meta — with a 60s in-progress transient that
short-circuits the webhook fan-back.

Tests + docs follow in the next commits.

Refs #22
Six focused suites covering every seam of the feature:

- ChangeEmailTokenFactoryTest (7) — entropy, length, deterministic
  hashing, constant-time verify, tampered/empty rejection
- ChangeEmailPendingChangeTest (6) — hash-only storage, null-on-
  malformed, expiry, dual confirm/cancel verification channels, clear()
- ChangeEmailConflictResolverTest (8) — block/allow_orphan/merge_request
  matrix, same-user no-op, conflict_detected + merge_requested hooks,
  policy filter override
- ChangeEmailNotifierTest (4) — recipient routing, opt-out gate for the
  old-address notice
- ChangeEmailRestApiTest (13) — initiate (write meta, send verification,
  invalid email, 403 without cap, enumeration-safe conflict response,
  same-email no-op, per-user rate limit), confirm (commit to both WP +
  WorkOS, expired, tampered, race re-check), cancel (token + capability
  paths, 403 without either)
- ChangeEmailUserSyncRaceGuardTest (2) — handle_user_updated short-
  circuits while the transient is set, runs normally otherwise

All 40 new tests pass under slic. The 4 pre-existing failures on main
(PasswordResetTest, RendererKsesTest, UserSyncPushTest, UserSyncResyncTest
"when disabled" variants) are unrelated to this branch.

Refs #22
…xt entries

Full feature reference under docs/change-email.md (flow diagram,
endpoints, settings table, conflict-policy semantics, all hooks,
activity-log events, shortcode usage, email template overrides,
security checklist, test layout, out-of-scope).

AGENTS.md gets a "Change Email" subsection mirroring the existing
PasswordResetAdmin block, with each new file documented.

README.md gains a one-paragraph feature bullet pointing at the new
docs page.

readme.txt gets a 1.0.6 changelog entry under the existing Unreleased
section.

Refs #22
- Rename slash-style hook names (workos/change_email/*, workos/email/*)
  to underscore form to match the plugin's WordPress.NamingConventions.ValidHookName.UseUnderscores
  ruleset. Webhook event hooks keep dot notation per the existing
  Receiver/UserSync exclusion. Updated tests + AGENTS.md + README.md +
  readme.txt + CHANGELOG.md + docs/change-email.md to match.
- Mailer::from_address — drop the short ternary in favor of an
  explicit empty-check, and phpcs:ignore the wp_mail_from
  PrefixAllGlobals sniff (it's a WP core filter we intentionally honor).
- RestApi: drop the unused PasswordResetAdmin\RedirectValidator
  dependency that PHPStan flagged as never-read. Same-host redirect
  validation already happens inline in validate_redirect().
- Auto-fix array alignment + assorted whitespace via phpcbf.

Local: PHPCS + PHPStan both clean; all 13 ChangeEmailRestApiTest
tests still green after the constructor signature change.
@bordoni bordoni requested a review from vodanh1213 May 26, 2026 02:33
bordoni added 4 commits May 26, 2026 02:11
Both admin-triggered flows were leaning on the browser's native
window.confirm (password reset) and window.prompt (change email),
which look out of place inside wp-admin and can't be styled or
keyboard-trapped properly.

Adds src/js/shared/modal.ts — a tiny vanilla-DOM helper around the
native <dialog> element exposing confirmModal() and promptModal().
Native <dialog> gives us focus trap, backdrop dimming, and Esc-to-
close for free; the surrounding CSS matches WP admin (notice paddings,
button-primary / button-link-delete variants, 480px width).

- admin-password-reset/index.ts: confirm prompt is now a real modal
  with title + message + Cancel/Send-reset-email buttons.
- admin-change-email/index.ts: the standalone-button path (row action
  and profile panel) now opens a modal with an inline email input + a
  validator that surfaces the "invalid email" string beneath the field
  without closing the dialog. The shortcode's inline form path is
  unchanged.

The PHP-side Assets bundles ship the new split strings
(modalTitle / modalMessage / modalInputLabel / modalPlaceholder /
modalConfirm / modalCancel) so translators can localize each surface.
The Recent Events panel was 400'ing with
"Invalid range. Start date is not a valid ISO 8601 date." because
WorkOS's /events endpoint is pickier than the ISO 8601 spec:

  - YYYY-MM-DDTHH:MM:SSZ            → rejected
  - YYYY-MM-DDTHH:MM:SS+00:00       → rejected
  - YYYY-MM-DDTHH:MM:SS.000+00:00   → rejected
  - YYYY-MM-DDTHH:MM:SS.000Z        → accepted

(empirically tested against api.workos.com on 2026-05-26).

Also tightened the lookback window from 90 to 30 days — WorkOS caps
the date range at 30 days and returns "Date ranges cannot be longer
than 30 days." for anything wider. The user profile panel pages
through up to 3×100 events anyway, so 30 days of history is the
practical ceiling regardless.
Switch the `//` multi-line block to `/* … */` — the WordPress coding
standard's Squiz.Commenting.InlineComment.SpacingBefore sniff wants
exactly one space after `//`, which doesn't survive an indented
bullet list. Same content, conforming format.
WorkOS rejects requests that carry both `range_start` and `after`
with "Only one of range_start and after may be provided." — page 0
worked but every subsequent page 400'd, so the Recent Events panel
only ever saw the first slice.

The cursor implicitly carries the date position, so the fix is to
send `range_start` on the first request and switch to `after` (only)
for every page after that.
Copy link
Copy Markdown
Collaborator

@vodanh1213 vodanh1213 left a comment

Choose a reason for hiding this comment

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

Code looks good, flow looks reasonable to me — I haven't tested it locally though 😄
Also we will need to ensure all checks are passed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants