Change email (WorkOS-verified, conflict-guarded) — closes #22#24
Open
bordoni wants to merge 8 commits into
Open
Change email (WorkOS-verified, conflict-guarded) — closes #22#24bordoni wants to merge 8 commits into
bordoni wants to merge 8 commits into
Conversation
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.
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.
vodanh1213
approved these changes
May 26, 2026
Collaborator
vodanh1213
left a comment
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Files
Security checklist (from issue)
Test plan
Out of scope (tracked separately)