diff --git a/AGENTS.md b/AGENTS.md index 989551b..78c7844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,9 +82,9 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Admin/OnboardingAjax.php` | Onboarding wizard AJAX handlers | | **Admin — Users** | | | `src/WorkOS/Admin/Users/Controller.php` | Wires the WorkOS Users admin submenu + REST endpoint | -| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list and enqueues the shared `workos-admin-password-reset` JS/CSS so the per-row trigger button works. | +| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list and enqueues the shared `workos-admin-password-reset` JS/CSS so the per-row trigger button works. Also localizes `changeEmailUrl` + `changeEmailEnabled` for the React-native "Change email" action (handled in-bundle, so it does *not* enqueue the shared change-email handler). | | `src/WorkOS/Admin/Users/RestApi.php` | `GET /wp-json/workos/v1/admin/users` — proxies `Api\Client::list_users()` with sanitized pagination + filters, a server-computed `dashboard_url` per row, and a `wp_user_id` resolved via `_workos_user_id` meta so the React side can show the "Send password reset" trigger only for linked rows. | -| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link + per-row Send-password-reset button when `wp_user_id > 0`) | +| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link + per-row Send-password-reset button when `wp_user_id > 0` + native "Change email" button with its own modal, immediate POST, and in-place row refresh) | | **Admin — Login Profiles (Custom AuthKit)** | | | `src/WorkOS/Admin/LoginProfiles/Controller.php` | Wires Login Profile admin page + CRUD REST | | `src/WorkOS/Admin/LoginProfiles/AdminPage.php` | Admin submenu that mounts the React editor | @@ -106,7 +106,7 @@ Per-environment constants (take priority over generic): | `src/js/admin-password-reset/index.ts` | Delegated `.workos-pwreset-trigger` click handler — POSTs to the admin endpoint and surfaces a transient admin notice. Same handler powers every trigger surface (WP Users row, user-edit panel, shortcode, WorkOS Users admin page row). | | **Auth — Change Email** ([`docs/change-email.md`](docs/change-email.md)) | | | `src/WorkOS/Auth/ChangeEmail/Controller.php` | DI controller — wires REST endpoints, JS/CSS, row action, profile panel, shortcode, and the frontend confirm route. Honors the `change_email_enabled` setting + `workos_change_email_enabled` filter. | -| `src/WorkOS/Auth/ChangeEmail/RestApi.php` | Three endpoints: `POST /workos/v1/users/{id}/email-change` (initiate, `edit_user` cap, per-IP + per-user rate limits, enumeration-safe conflict response), `…/email-change/confirm` (token-gated, race re-check, transient-guarded WorkOS + WP commit), `…/email-change/cancel` (cancel-token *or* `edit_user` cap). Logs `email_change.*` events. | +| `src/WorkOS/Auth/ChangeEmail/RestApi.php` | Three endpoints: `POST /workos/v1/users/{id}/email-change` (initiate, `edit_user` cap), `…/email-change/confirm` (token-gated, race re-check), `…/email-change/cancel` (cancel-token *or* `edit_user` cap). Initiate branches on `is_admin_action()` (`edit_users` + initiator ≠ target): self-service is rate-limited, enumeration-safe, and verified via emailed token; admin-of-other commits immediately (no rate limit, no notification, WP core email-change notice suppressed), gets a real `409` conflict, and logs `email_change.admin_changed`. Shared `commit_change()` (WorkOS `update_user` → `wp_update_user` → rollback → transient → sync-hash refresh) backs both the confirm and admin-direct paths. Logs `email_change.*` events. | | `src/WorkOS/Auth/ChangeEmail/PendingChange.php` | Stores the pending record as `_workos_pending_email_change` user_meta — only hashes (HMAC-SHA256 + `wp_salt('auth')`), never plaintext tokens. Single-use: cleared on confirm/cancel/expiry. | | `src/WorkOS/Auth/ChangeEmail/TokenFactory.php` | `wp_generate_password()`-backed token generator + `hash_hmac()` storage + `hash_equals()` verify. | | `src/WorkOS/Auth/ChangeEmail/ConflictResolver.php` | Enforces `change_email_conflict_policy`: `block` (default), `allow_orphan` (gated by `_workos_user_id`, posts, comments, last-login window — defaults to 90 days, filterable via `workos_change_email_orphan_max_inactive_days`), `merge_request` (rejects + fires `workos_change_email_merge_requested` for Issue 2). Fires `workos_change_email_conflict_detected` on any collision. | @@ -116,7 +116,7 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Auth/ChangeEmail/Shortcode.php` | `[workos:change-email]` — self-service form when no `user` attribute; admin-of-other form when `user="id-or-email"`. | | `src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php` | Rewrite rule for the configurable confirm path (default `/workos/change-email/`, settable via `change_email_confirm_path`). `template_redirect` renders `templates/change-email/confirm-page.php`, which enqueues the JS bundle that POSTs to the confirm/cancel endpoint. | | `src/WorkOS/Auth/ChangeEmail/Assets.php` | Registers `workos-admin-change-email` (admin row/panel/shortcode trigger) + `workos-change-email-confirm` (frontend confirm page) JS/CSS handles. | -| `src/js/admin-change-email/index.ts` | Delegated `.workos-change-email-trigger` click handler — prompts for the new email (standalone button) or reads from `.workos-change-email-input` (shortcode form), POSTs to the initiate endpoint. | +| `src/js/admin-change-email/index.ts` | Delegated `.workos-change-email-trigger` click handler — prompts for the new email (standalone button) or reads from `.workos-change-email-input` (shortcode form), POSTs to the initiate endpoint. Success messaging is response-driven: an immediate admin commit (`committed: true`) shows "Email changed", a self-service initiate shows "verification sent". | | `src/js/change-email-confirm/index.ts` | Frontend confirm-page logic: reads token + user_id from URL, picks confirm vs cancel based on `?action=cancel`, POSTs, renders success / error. | | `src/WorkOS/Email/Mailer.php` | Small `wp_mail()` wrapper shared by the change-email Notifier — locates templates (with theme override at `wp-content/themes/{theme}/integration-workos/{template}.php`), sets HTML headers + a filterable from-address, and exposes `workos_email_body`, `workos_email_subject`, `workos_email_headers` filters. | | `templates/change-email/*.php` | Email + confirm-page templates (verification, old-address notice, confirmation notice, confirm page). | diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a54fb..28f3352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.0.8] - 2026-06-30 + +### Added + +- **Change email from the WorkOS Users page** (#33) — adds a "Change email" action to the WorkOS → Users admin page, alongside "Open in WorkOS" and "Send password reset". When a privileged user (`edit_users`) changes *another* account's email it now commits immediately, skipping the emailed verification step that self-service still uses; the admin path also skips rate limiting, sends no notification, surfaces real conflicts, and logs `email_change.admin_changed`. Removes the now-unused `change_email_admin_bypass_verification` option. + +### Fixed + +- **500 in admin password-reset when `profile` is empty** (#33) — an empty `profile` (the common case) fataled the endpoint instead of resolving to the default login profile. The `profile` sanitize callback no longer hands the request object back as the sanitized value. + ## [1.0.7] - 2026-06-23 ### Fixed diff --git a/README.md b/README.md index 0bfbb26..b0a379d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **Sign-in methods**: email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), passkey - **In-app flows**: self-serve sign-up with email verification, invitation acceptance, password reset (two-field new-password form with `wp.passwordStrength.meter` gating, optional auto-login on success, post-reset `redirect_url` validated same-host) - **Admin-triggered password reset** — send a WorkOS reset email on behalf of any linked user via `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (gated by `edit_user($id)` so the same route covers self-service). Surfaced as a row action under the **WorkOS column** on `wp-admin/users.php`, a button on the user-edit screen, a per-row button on the WorkOS Users admin page, and a `[workos:password-reset]` shortcode. Successful sends are audited as `password_reset.admin_sent` in the activity log; emails point at the in-site React shell (`/workos/login/{slug}?token=…&redirect_to=…`) and the new password is mirrored to the linked WP user so `?fallback=1` / `wp_authenticate` / REST app passwords stay in sync. See [`docs/password-reset.md`](docs/password-reset.md). -- **Change email** — WorkOS-verified, conflict-guarded email-change flow. Self-service via `[workos:change-email]` shortcode; admin via a row action under the **WorkOS column** on `wp-admin/users.php` and a panel on the user-edit screen. WP-side hashed token sent to the new address; cancel-link notice sent to the old address; configurable conflict policy (`block`, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Confirm commits to WorkOS first (via `update_user`) and then mirrors into WP, with a 60-second in-progress transient that short-circuits the webhook fan-back. See [`docs/change-email.md`](docs/change-email.md). +- **Change email** — WorkOS-verified, conflict-guarded email-change flow. Self-service via `[workos:change-email]` shortcode confirms a WP-side hashed token sent to the new address (with a cancel-link notice to the old address) before committing; an admin acting on *another* account commits immediately (gated by `edit_users`, no verification round-trip). Admin surfaces are a row action under the **WorkOS column** on `wp-admin/users.php`, a panel on the user-edit screen, and a "Change email" action on the **WorkOS → Users** admin page. A configurable conflict policy (`block`, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Commits to WorkOS first (via `update_user`) then mirrors into WP, with a 60-second in-progress transient that short-circuits the webhook fan-back. See [`docs/change-email.md`](docs/change-email.md). - **Skeleton placeholders** on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, shortcode) — pre-hydration markup from PHP plus a React `FlowSkeleton` during bootstrap, mirroring the real card heights so swap-in is a flicker not a jump. - **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist - **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request @@ -179,9 +179,10 @@ per-profile CSS/JS, and the available PHP filters. For password-reset integrations (admin-triggered, self-service, shortcode, redirect_url policy, what-not-to-do), see [`docs/password-reset.md`](docs/password-reset.md). For the -WorkOS-verified change-email flow (self-service + admin row action + -configurable conflict policy + WP-side token verification + cancel -link to old address), see +WorkOS-verified change-email flow (self-service token verification + +admin-direct immediate commits + the WorkOS → Users / row-action / +user-edit surfaces + configurable conflict policy + cancel link to old +address), see [`docs/change-email.md`](docs/change-email.md). ### Custom paths diff --git a/docs/change-email.md b/docs/change-email.md index ceaada1..824986d 100644 --- a/docs/change-email.md +++ b/docs/change-email.md @@ -6,7 +6,7 @@ This document covers every way to start, confirm, or cancel a WorkOS-backed emai It is written for two audiences in parallel: a developer integrating against the plugin, and an LLM agent reading it as the source of truth for code-gen. The patterns and gotchas below are exhaustive — if your integration deviates from them, you are almost certainly hitting one of the **Don't do this** sections near the bottom. -> **Plugin requirement:** integration-workos **1.0.6** or later. Earlier versions do not expose the `email-change` endpoints, the `[workos:change-email]` shortcode, or the admin row action. +> **Plugin requirement:** integration-workos **1.0.6** or later. Earlier versions do not expose the `email-change` endpoints, the `[workos:change-email]` shortcode, or the admin row action. The **WorkOS → Users** admin-page action and the admin-direct immediate-commit behavior (see [Admin-direct vs. self-service](#admin-direct-vs-self-service)) require **1.0.8** or later. ## Why this exists @@ -15,12 +15,14 @@ WordPress lets an admin overwrite a user's `user_email` directly in `wp-admin/us This feature adds: - A self-service `[workos:change-email]` shortcode that prompts the user for a new address and starts the flow. -- An admin "Change email" row action + user-edit panel that mirrors the existing "Send password reset" surfaces. -- A WP-side hashed token + pending-state record so the new address must be confirmed (clicked on) before the change commits. +- An admin "Change email" row action + user-edit panel + **WorkOS → Users** admin-page action that mirror the existing "Send password reset" surfaces. +- A WP-side hashed token + pending-state record so a self-service user's new address must be confirmed (clicked on) before the change commits. - An old-address notice with a one-click cancel link (so a session-hijack victim can stop a change in progress). - A configurable conflict policy that prevents the change from silently overwriting another local WP user's email. - A WorkOS sync race guard so the `user.updated` webhook fan-back can't re-trigger the very mutation we just made. +The emailed-token verification protects **self-service**. An admin acting on *another* account can already manage every user, so they commit the change immediately — but routing it through this path is the point: it's conflict-checked, mirrored to WorkOS, race-guarded, and audit-logged, rather than the raw `users.php` field edit that does none of that. See [Admin-direct vs. self-service](#admin-direct-vs-self-service). + Verification is owned WP-side because WorkOS's `email_verification` endpoints verify the *current* address on a WorkOS user, not a pending change. --- @@ -30,14 +32,15 @@ Verification is owned WP-side because WorkOS's `email_verification` endpoints ve | Surface | Endpoint | Auth | Audience | | --- | --- | --- | --- | | Initiate (self-service) | `POST /wp-json/workos/v1/users/{id}/email-change` | WP REST nonce (`X-WP-Nonce`) + `edit_user($id)` | A logged-in user changing their own address | -| Initiate (admin-of-other) | Same endpoint | WP REST nonce + `edit_user($id)` | Editors / admins acting on another account | +| Initiate (admin-of-other) | Same endpoint | WP REST nonce + `edit_users` (and `initiator ≠ target`) | Editors / admins acting on another account — **commits immediately** | | Confirm | `POST /wp-json/workos/v1/users/{id}/email-change/confirm` | The confirm **token** (no capability) | The person who clicked the verification link | | Cancel | `POST /wp-json/workos/v1/users/{id}/email-change/cancel` | Cancel **token** *or* `edit_user($id)` | Old-address recipient, or an admin | | WP Users list (row action under the WorkOS column) | Posts to the initiate endpoint | WP REST nonce + `edit_user($id)` | Admins, in the linked-user row only | +| WorkOS → Users admin page ("Change email" action) | Posts to the initiate endpoint | WP REST nonce + `edit_user($id)` | Admins, on the WorkOS user list | | User-edit / profile panel | Posts to the initiate endpoint | WP REST nonce + `edit_user($id)` | Admins / the user on their own profile | | Shortcode | `[workos:change-email]` | Rendered server-side; posts to the initiate endpoint | Page authors | -All entry points converge on the same commit path: a WorkOS `update_user` call followed by `wp_update_user()`, guarded by the conflict resolver, rate limiter, and in-progress transient. +All entry points commit through the same shared path — a WorkOS `update_user` call followed by `wp_update_user()`, guarded by the conflict resolver and the in-progress transient. **Self-service** initiates email a hashed-token verification link and only commit on confirm (and are rate-limited); an **admin acting on another account** commits immediately, with no token, no rate limit, and no notification. See [Admin-direct vs. self-service](#admin-direct-vs-self-service). > **Note:** Unlike the password-reset flow — whose *public* endpoints use a profile-scoped `X-WorkOS-Nonce` — **every** change-email endpoint uses the standard WordPress `X-WP-Nonce`. See [Don't do this](#dont-do-this). @@ -73,6 +76,31 @@ sequenceDiagram --- +## Admin-direct vs. self-service + +The initiate endpoint branches on **who is acting**, decided by `is_admin_action()`: + +> The caller holds the `edit_users` capability **and** is not the target user. + +That single condition is the trust boundary. A caller who clears it can already manage every account, so the flow drops the ceremony that exists to protect a self-service user (or a hijacked session) from an unverified change: + +| Behavior | Self-service (or admin editing self) | Admin acting on another account | +| --- | --- | --- | +| Commit timing | On confirm, after the emailed token is clicked | **Immediately**, in the initiate request | +| Verification email | Sent to the new address | Not sent | +| Rate limiting | Per-IP + per-user windows enforced | Skipped | +| Old-address cancel notice | Sent (unless opted out) | Not sent | +| WP core "Notice of Email Change" | Sent | Suppressed for this commit | +| Conflict response | Enumeration-safe `200` (same shape as success) | Real `409 workos_change_email_conflict` | +| Success response | `{ ok, masked_new_email, expires_at }` | `{ ok: true, committed: true, email }` (unmasked — the admin typed it) | +| Activity-log event | `email_change.initiated` (then `…confirmed` on confirm) | `email_change.admin_changed` (`verified: false`) | + +Editing *your own* address from an admin screen still counts as self-service — `is_admin_action()` is false when initiator == target — so an admin changing their own email gets the verified flow, not an immediate commit. + +This is gated purely by capability; there is no setting to toggle it. It supersedes the `change_email_admin_bypass_verification` option from earlier 1.0.x builds, which has been removed. + +--- + ## Endpoint reference All endpoints live under `/wp-json/workos/v1/`. The initiate path requires the WP REST nonce **and** `edit_user($id)`; confirm and cancel are publicly routable (the token is the gatekeeper), but the shipped client still sends `X-WP-Nonce` on every request. @@ -109,7 +137,15 @@ When the requested address equals the user's current address, no email is sent a { "ok": true, "masked_new_email": "j•••@e•••.com", "no_op": true } ``` -> **Note:** A conflict-blocked request returns this same `{ ok: true, masked_new_email }` shape (no `expires_at`) and writes an `email_change.conflict_blocked` row to the activity log. The block is **not** surfaced in the response — that's deliberate, so the endpoint can't be used to probe which addresses are taken. +> **Note:** A conflict-blocked request returns this same `{ ok: true, masked_new_email }` shape (no `expires_at`) and writes an `email_change.conflict_blocked` row to the activity log. The block is **not** surfaced in the response — that's deliberate, so the endpoint can't be used to probe which addresses are taken. This applies to **self-service** callers; an admin acting on another account gets a real `409` instead (see below). + +**Admin-direct 200 response (1.0.8+)** — when the caller is an admin acting on another account, the change is committed *in this request* (no confirm step). The body carries `committed: true` and the **unmasked** new address (the admin just typed it), with no `expires_at`: + +```json +{ "ok": true, "committed": true, "email": "jane.new@example.com" } +``` + +See [Admin-direct vs. self-service](#admin-direct-vs-self-service) for the full behavior matrix. **Errors** @@ -119,7 +155,10 @@ When the requested address equals the user's current address, no email is sent a | 400 | `workos_invalid_email` | `new_email` is empty or fails `is_email()` | | 403 | `workos_forbidden` | Caller lacks `edit_user` on the target, or `workos_change_email_can_initiate` returned `false` | | 404 | `workos_user_not_found` | No WP user with that ID | -| 429 | (rate-limit) | Per-IP or per-user initiate window exhausted (defaults: 10/IP/hr, 3/user/hr) | +| 409 | `workos_change_email_conflict` | New address already belongs to another account — **admin-of-other only**; self-service gets the enumeration-safe `200` instead | +| 429 | (rate-limit) | Per-IP or per-user initiate window exhausted (defaults: 10/IP/hr, 3/user/hr) — **self-service only**; admin-of-other actions bypass rate limiting | + +> **Note:** An admin-of-other initiate commits in-request, so it can also return the `502` / `500` `workos_commit_failed` errors documented under [confirm](#post-wp-jsonworkosv1usersidemail-changeconfirm--confirm). ### `POST /wp-json/workos/v1/users/{id}/email-change/confirm` — confirm @@ -215,9 +254,10 @@ Stored under the active environment (`workos()->option(...)`); defaults are list | `change_email_rate_limit_ip_window` | `3600` | Window in seconds. | | `change_email_notify_old_address` | `true` | Send the "change requested" + "change confirmed" notices to the old address. | | `change_email_require_reauth` | `true` | Reserved for the AuthKit step-up flow. | -| `change_email_admin_bypass_verification` | `false` | When true, an admin with `edit_users` can commit without email verification (audit-logged via `email_change.admin_bypass`). | | `change_email_confirm_path` | `'workos/change-email'` | Rewrite path for the confirm route. Slash-trimmed; restricted to `[a-zA-Z0-9/_-]`. | +> An admin acting on another account commits without email verification by default — that's [admin-direct behavior](#admin-direct-vs-self-service), gated by the `edit_users` capability, not a setting. The earlier `change_email_admin_bypass_verification` option has been removed. + ## Conflict policies - **`block`** (default): a hard reject. The user-facing message is intentionally vague ("That email cannot be used for this account.") so the response can't be used to enumerate which addresses are taken. Logged as `email_change.conflict_blocked`. @@ -553,7 +593,7 @@ The shortcode silently renders nothing when: - `email_change.expired` - `email_change.conflict_blocked` - `email_change.commit_failed` -- `email_change.admin_bypass` (only when `change_email_admin_bypass_verification=true`) +- `email_change.admin_changed` (an admin committing another account's change directly; metadata carries `verified: false` and `initiator_id`) Each row records `{ user_id, user_email, workos_user_id, ip_address, metadata: { masked_new_email, masked_old_email, policy, initiator_id, self_service } }`. @@ -659,7 +699,7 @@ ChangeEmailTokenFactoryTest.php # entropy + hashing + constant-time verify ChangeEmailPendingChangeTest.php # storage invariants + expiry + clear() ChangeEmailConflictResolverTest.php # block / allow_orphan / merge_request matrix ChangeEmailNotifierTest.php # recipient routing + opt-out gate -ChangeEmailRestApiTest.php # 13 tests covering initiate / confirm / cancel +ChangeEmailRestApiTest.php # 19 tests — initiate (self-service + admin-direct), confirm, cancel ChangeEmailUserSyncRaceGuardTest.php # the transient short-circuit ``` @@ -688,6 +728,7 @@ slic run wpunit --filter ChangeEmail | Email notifier (verification / old-address / confirmation) | [`src/WorkOS/Auth/ChangeEmail/Notifier.php`](../src/WorkOS/Auth/ChangeEmail/Notifier.php) | | Shortcode | [`src/WorkOS/Auth/ChangeEmail/Shortcode.php`](../src/WorkOS/Auth/ChangeEmail/Shortcode.php) | | Admin row action | [`src/WorkOS/Auth/ChangeEmail/RowActions.php`](../src/WorkOS/Auth/ChangeEmail/RowActions.php) | +| WorkOS → Users admin-page action (native button + modal) | [`src/js/admin-users/index.tsx`](../src/js/admin-users/index.tsx), config from [`src/WorkOS/Admin/Users/AdminPage.php`](../src/WorkOS/Admin/Users/AdminPage.php) | | User-edit / profile panel | [`src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php`](../src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php) | | Frontend confirm route | [`src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php`](../src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php) | | Asset / localized-config registration | [`src/WorkOS/Auth/ChangeEmail/Assets.php`](../src/WorkOS/Auth/ChangeEmail/Assets.php) | diff --git a/integration-workos.php b/integration-workos.php index 644e572..6d56ba6 100644 --- a/integration-workos.php +++ b/integration-workos.php @@ -3,7 +3,7 @@ * Plugin Name: Integration with WorkOS * Plugin URI: https://github.com/bordoni/integration-workos * Description: Enterprise identity management for WordPress powered by WorkOS. SSO, directory sync, MFA, and user management. - * Version: 1.0.7 + * Version: 1.0.8 * Author: Gustavo Bordoni * Author URI: https://github.com/bordoni * License: GPL-2.0-or-later diff --git a/package.json b/package.json index d831cc0..c38ea7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "integration-workos", - "version": "1.0.7", + "version": "1.0.8", "description": "Enterprise identity management for WordPress powered by WorkOS.", "private": true, "scripts": { diff --git a/readme.txt b/readme.txt index 47b9ee5..128d1fd 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: sso, identity, workos, authentication, directory-sync Requires at least: 6.2 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.7 +Stable tag: 1.0.8 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -175,6 +175,12 @@ WorkOS is provided by WorkOS, Inc. == Changelog == += 1.0.8 - 2026-06-30 = + +* New: Change a user's email from the WorkOS → Users admin page, alongside "Open in WorkOS" and "Send password reset". (#33) +* New: Admin-direct email changes commit immediately. A privileged user (`edit_users`) changing *another* account's email now commits without the emailed verification step that self-service still uses; the admin path skips rate limiting, sends no notification, and surfaces real conflicts. Removes the unused `change_email_admin_bypass_verification` option. (#33) +* Fix: Admin password reset no longer 500s when the `profile` field is empty — it now resolves to the default login profile. (#33) + = 1.0.7 - 2026-06-23 = * Fix: Active environment no longer reverts on settings save. Saving WorkOS settings no longer resets `workos_active_environment` to `staging` when the active environment field is absent from the form. The sanitizer now preserves the current active environment, including legacy `workos_global['active_environment']` fallback state. (#34) @@ -266,6 +272,9 @@ Base platform: == Upgrade Notice == += 1.0.8 = +Adds a "Change email" action to the WorkOS → Users admin page and makes admin-of-other email changes commit immediately, while self-service keeps the verified token flow. Also fixes a 500 in the admin password-reset endpoint when the profile field is empty. + = 1.0.7 = Fixes settings saves resetting the active WorkOS environment to Staging when the environment field is absent from the form. The saved active environment is now preserved, including legacy fallback state. diff --git a/src/WorkOS/Admin/Users/AdminPage.php b/src/WorkOS/Admin/Users/AdminPage.php index 97ae3f5..f6b6c67 100644 --- a/src/WorkOS/Admin/Users/AdminPage.php +++ b/src/WorkOS/Admin/Users/AdminPage.php @@ -109,17 +109,24 @@ public function maybe_enqueue_assets( string $hook ): void { $asset['version'] ?? WORKOS_VERSION ); + $container = workos()->getContainer(); + $change_email_enabled = $container + ? $container->get( \WorkOS\Auth\ChangeEmail\Controller::class )->isActive() + : false; + wp_localize_script( self::SCRIPT_HANDLE, 'workosUsersAdmin', [ - 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . RestApi::BASE ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'environment' => Config::get_active_environment(), - 'environmentId' => Config::get_environment_id(), - 'dashboardBaseUrl' => 'https://dashboard.workos.com', - 'defaultLimit' => 25, - 'pluginEnabled' => workos()->is_enabled(), + 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . RestApi::BASE ) ), + 'changeEmailUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/users/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'environment' => Config::get_active_environment(), + 'environmentId' => Config::get_environment_id(), + 'dashboardBaseUrl' => 'https://dashboard.workos.com', + 'defaultLimit' => 25, + 'pluginEnabled' => workos()->is_enabled(), + 'changeEmailEnabled' => $change_email_enabled, ] ); @@ -129,5 +136,10 @@ public function maybe_enqueue_assets( string $hook ): void { // this page only needs to enqueue them. wp_enqueue_script( \WorkOS\Auth\PasswordResetAdmin\Assets::SCRIPT_HANDLE ); wp_enqueue_style( \WorkOS\Auth\PasswordResetAdmin\Assets::STYLE_HANDLE ); + + // The change-email action is handled natively by the React bundle + // (own modal + in-place row refresh), so unlike password reset it + // does not enqueue the shared ChangeEmail admin handler — it just + // needs `changeEmailUrl` + `changeEmailEnabled` from the config above. } } diff --git a/src/WorkOS/Auth/ChangeEmail/Assets.php b/src/WorkOS/Auth/ChangeEmail/Assets.php index ebcec58..9a070dc 100644 --- a/src/WorkOS/Auth/ChangeEmail/Assets.php +++ b/src/WorkOS/Auth/ChangeEmail/Assets.php @@ -109,15 +109,17 @@ private function register_admin_assets(): void { 'nonce' => wp_create_nonce( 'wp_rest' ), 'strings' => [ 'modalTitle' => __( 'Change email address', 'integration-workos' ), - 'modalMessage' => __( 'A verification link will be sent to the new address. The change only commits once that link is clicked.', 'integration-workos' ), + 'modalMessage' => __( 'Enter the new email address for this account.', 'integration-workos' ), 'modalInputLabel' => __( 'New email address', 'integration-workos' ), 'modalPlaceholder' => __( 'name@example.com', 'integration-workos' ), - 'modalConfirm' => __( 'Send verification', 'integration-workos' ), + 'modalConfirm' => __( 'Change email', 'integration-workos' ), 'modalCancel' => __( 'Cancel', 'integration-workos' ), - 'sending' => __( 'Sending verification…', 'integration-workos' ), + 'sending' => __( 'Saving…', 'integration-workos' ), /* translators: %s: masked email (e.g. "j•••@e•••.com"). */ 'success' => __( 'Verification email sent to %s.', 'integration-workos' ), - 'errorGeneric' => __( 'Could not start the email change. Please try again.', 'integration-workos' ), + /* translators: %s: the new email address. */ + 'successImmediate' => __( 'Email changed to %s.', 'integration-workos' ), + 'errorGeneric' => __( 'Could not change the email. Please try again.', 'integration-workos' ), 'invalidEmail' => __( 'Please enter a valid email address.', 'integration-workos' ), ], ] diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php index 351e78c..24f34c0 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -270,19 +270,25 @@ public function initiate( WP_REST_Request $request ) { } $new_email = strtolower( $new_email ); - $ip = $this->rate_limiter->client_ip(); - $user_count = (int) workos()->option( 'change_email_rate_limit_user_count', self::RATE_LIMIT_DEFAULT_USER ); - $user_window = (int) workos()->option( 'change_email_rate_limit_user_window', self::RATE_LIMIT_DEFAULT_WIN ); - $ip_count = (int) workos()->option( 'change_email_rate_limit_ip_count', self::RATE_LIMIT_DEFAULT_IP ); - $ip_window = (int) workos()->option( 'change_email_rate_limit_ip_window', self::RATE_LIMIT_DEFAULT_WIN ); - - $rate_ok = $this->rate_limiter->attempt( 'change_email_init_ip', $ip, $ip_count, $ip_window ); - if ( is_wp_error( $rate_ok ) ) { - return $rate_ok; - } - $rate_ok = $this->rate_limiter->attempt( 'change_email_init_user', (string) $user->ID, $user_count, $user_window ); - if ( is_wp_error( $rate_ok ) ) { - return $rate_ok; + $is_admin_action = $this->is_admin_action( (int) $user->ID ); + + // Rate limits throttle self-service abuse; the capability-gated admin + // path bypasses them so a legitimate cleanup sweep isn't blocked. + if ( ! $is_admin_action ) { + $ip = $this->rate_limiter->client_ip(); + $user_count = (int) workos()->option( 'change_email_rate_limit_user_count', self::RATE_LIMIT_DEFAULT_USER ); + $user_window = (int) workos()->option( 'change_email_rate_limit_user_window', self::RATE_LIMIT_DEFAULT_WIN ); + $ip_count = (int) workos()->option( 'change_email_rate_limit_ip_count', self::RATE_LIMIT_DEFAULT_IP ); + $ip_window = (int) workos()->option( 'change_email_rate_limit_ip_window', self::RATE_LIMIT_DEFAULT_WIN ); + + $rate_ok = $this->rate_limiter->attempt( 'change_email_init_ip', $ip, $ip_count, $ip_window ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + $rate_ok = $this->rate_limiter->attempt( 'change_email_init_user', (string) $user->ID, $user_count, $user_window ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } } // Treat "change to the address I already have" as a benign no-op @@ -313,6 +319,17 @@ public function initiate( WP_REST_Request $request ) { ] ); + // A privileged user acting on someone else's account may see the + // real reason — they can already enumerate accounts, so nothing + // leaks. Self-service callers keep the enumeration-safe shape. + if ( $is_admin_action ) { + return new WP_Error( + 'workos_change_email_conflict', + __( 'This email is already in use by another account.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + // Enumeration-safe: same shape as success. The conflict is // surfaced in the activity log, not in the response. return new WP_REST_Response( @@ -324,6 +341,11 @@ public function initiate( WP_REST_Request $request ) { ); } + // Admin-direct path: commit now, no token, no verification email. + if ( $is_admin_action ) { + return $this->commit_admin_change( $user, $new_email ); + } + $lifetime = $this->token_lifetime(); $expires = time() + $lifetime; @@ -378,6 +400,71 @@ public function initiate( WP_REST_Request $request ) { ); } + /** + * Commit an admin-initiated change immediately, bypassing verification. + * + * Skips the token round-trip and the user notification — the capability + * is the trust boundary, and the change is audit-logged instead. Echoes + * the new address back unmasked (the admin typed it) so the UI can + * refresh the row in place. + * + * @param WP_User $user Target user (pre-update). + * @param string $new_email Lowercased, validated new address. + * + * @return WP_REST_Response|WP_Error + */ + private function commit_admin_change( WP_User $user, string $new_email ) { + // Admin-direct changes are silent. Beyond skipping our own notifier, + // suppress WP core's "Notice of Email Change" that wp_update_user() + // would otherwise mail to the old address. Scoped to this commit so + // the self-service confirm path keeps its core notice. + $suppress_core_notice = static function () { + return false; + }; + add_filter( 'send_email_change_email', $suppress_core_notice ); + $commit = $this->commit_change( $user, $new_email ); + remove_filter( 'send_email_change_email', $suppress_core_notice ); + + if ( is_wp_error( $commit ) ) { + return $commit; + } + + $old_email = $commit['old_email']; + + EventLogger::log( + 'email_change.admin_changed', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->masker->mask( $new_email ), + 'masked_old_email' => $this->masker->mask( $old_email ), + 'initiator_id' => get_current_user_id(), + 'verified' => false, + ], + ] + ); + + /** + * Fires after an email change is committed to WorkOS + WP. Shared + * with the verified-confirm path so downstream observers don't have + * to special-case how the change was authorized. + * + * @param int $user_id Target WP user ID. + * @param string $old_email Previous email. + * @param string $new_email New email. + */ + do_action( 'workos_change_email_confirmed', (int) $user->ID, $old_email, $new_email ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'committed' => true, + 'email' => $new_email, + ], + 200 + ); + } + /** * Confirm a pending email change. * @@ -459,74 +546,12 @@ public function confirm( WP_REST_Request $request ) { return $conflict; } - $old_email = (string) $user->user_email; - $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); - - // Set the in-progress transient BEFORE we touch WorkOS so the - // user.updated webhook fan-back is a no-op while we own the - // transition. {@see UserSync::handle_user_updated()}. - set_transient( self::TRANSIENT_PREFIX . (int) $user->ID, 1, self::TRANSIENT_TTL ); - - if ( '' !== $workos_user_id ) { - $workos_response = workos()->api()->update_user( $workos_user_id, [ 'email' => $new_email ] ); - if ( is_wp_error( $workos_response ) ) { - delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); - EventLogger::log( - 'email_change.commit_failed', - [ - 'user_id' => $user->ID, - 'metadata' => [ - 'masked_new_email' => $this->masker->mask( $new_email ), - 'reason' => $workos_response->get_error_message(), - ], - ] - ); - return new WP_Error( - 'workos_commit_failed', - __( 'Could not update the email at WorkOS. Please try again.', 'integration-workos' ), - [ 'status' => 502 ] - ); - } - } - - $wp_update = wp_update_user( - [ - 'ID' => (int) $user->ID, - 'user_email' => $new_email, - ] - ); - - if ( is_wp_error( $wp_update ) ) { - // Rollback WorkOS so the two stores don't drift. - if ( '' !== $workos_user_id && '' !== $old_email ) { - workos()->api()->update_user( $workos_user_id, [ 'email' => $old_email ] ); - } - delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); - EventLogger::log( - 'email_change.commit_failed', - [ - 'user_id' => $user->ID, - 'metadata' => [ - 'masked_new_email' => $this->masker->mask( $new_email ), - 'reason' => $wp_update->get_error_message(), - 'rolled_back' => true, - ], - ] - ); - return new WP_Error( - 'workos_commit_failed', - __( 'Could not update the email locally. Please try again.', 'integration-workos' ), - [ 'status' => 500 ] - ); + $commit = $this->commit_change( $user, $new_email ); + if ( is_wp_error( $commit ) ) { + return $commit; } - $this->pending->clear( (int) $user->ID ); - delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); - - // Keep the sync change-detection hash in step with the new email so - // the next user.updated webhook doesn't see a phantom diff and - // re-mirror a change we already committed. - \WorkOS\Sync\UserSync::refresh_profile_hash( (int) $user->ID ); + $old_email = $commit['old_email']; // Refresh user object now that the email is committed. $user = get_userdata( (int) $user->ID ); @@ -626,6 +651,118 @@ public function cancel( WP_REST_Request $request ) { return new WP_REST_Response( [ 'ok' => true ], 200 ); } + /** + * Commit an email change to WorkOS and WordPress. + * + * Shared by the verified-confirm path and the admin-direct path. The + * caller is responsible for permission, conflict, and token checks + * *before* calling this — by the time we're here the change is approved + * and we just need it to land atomically across both stores. + * + * Sets the in-progress transient before touching WorkOS so the + * `user.updated` webhook fan-back is a no-op while we own the + * transition, rolls WorkOS back if the local write fails so the two + * stores can't drift, then refreshes the sync hash so the next webhook + * doesn't see a phantom diff. Does NOT send notifications or fire the + * `confirmed` action — those are the caller's concern. + * + * @param WP_User $user Target user (pre-update). + * @param string $new_email Lowercased, validated new address. + * + * @return array{old_email:string,workos_user_id:string}|WP_Error + */ + private function commit_change( WP_User $user, string $new_email ) { + $old_email = (string) $user->user_email; + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + + set_transient( self::TRANSIENT_PREFIX . (int) $user->ID, 1, self::TRANSIENT_TTL ); + + if ( '' !== $workos_user_id ) { + $workos_response = workos()->api()->update_user( $workos_user_id, [ 'email' => $new_email ] ); + if ( is_wp_error( $workos_response ) ) { + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + EventLogger::log( + 'email_change.commit_failed', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->masker->mask( $new_email ), + 'reason' => $workos_response->get_error_message(), + ], + ] + ); + return new WP_Error( + 'workos_commit_failed', + __( 'Could not update the email at WorkOS. Please try again.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + } + + $wp_update = wp_update_user( + [ + 'ID' => (int) $user->ID, + 'user_email' => $new_email, + ] + ); + + if ( is_wp_error( $wp_update ) ) { + // Rollback WorkOS so the two stores don't drift. + if ( '' !== $workos_user_id && '' !== $old_email ) { + workos()->api()->update_user( $workos_user_id, [ 'email' => $old_email ] ); + } + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + EventLogger::log( + 'email_change.commit_failed', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->masker->mask( $new_email ), + 'reason' => $wp_update->get_error_message(), + 'rolled_back' => true, + ], + ] + ); + return new WP_Error( + 'workos_commit_failed', + __( 'Could not update the email locally. Please try again.', 'integration-workos' ), + [ 'status' => 500 ] + ); + } + + $this->pending->clear( (int) $user->ID ); + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + + // Keep the sync change-detection hash in step with the new email so + // the next user.updated webhook doesn't see a phantom diff and + // re-mirror a change we already committed. + \WorkOS\Sync\UserSync::refresh_profile_hash( (int) $user->ID ); + + return [ + 'old_email' => $old_email, + 'workos_user_id' => $workos_user_id, + ]; + } + + /** + * Whether the request is a privileged admin acting on *another* account. + * + * This is the trust boundary for the admin-direct behavior: such a + * caller can already manage every account, so we (a) commit the change + * immediately without an emailed verification step and (b) surface the + * real reason a change was rejected instead of the enumeration-safe + * shape. A self-service change (initiator === target) — or any caller + * without `edit_users` — gets neither: it goes through the verified + * token flow and never learns which addresses are already registered. + * + * @param int $target_id Target user ID. + * + * @return bool + */ + private function is_admin_action( int $target_id ): bool { + return get_current_user_id() !== $target_id && current_user_can( 'edit_users' ); + } + /** * Resolve the configured token lifetime (clamped to [300, 86400]). * diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php index d4c2484..c9e17e4 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -111,7 +111,7 @@ public function register_routes(): void { 'args' => [ 'id' => [ 'sanitize_callback' => 'absint' ], 'redirect_url' => [ 'sanitize_callback' => 'sanitize_text_field' ], - 'profile' => [ 'sanitize_callback' => 'sanitize_title' ], + 'profile' => [ 'sanitize_callback' => static fn( $value ) => sanitize_title( (string) $value ) ], ], ] ); diff --git a/src/WorkOS/Options/Production.php b/src/WorkOS/Options/Production.php index e1df238..1b2aca2 100644 --- a/src/WorkOS/Options/Production.php +++ b/src/WorkOS/Options/Production.php @@ -51,7 +51,6 @@ protected function defaults(): array { 'change_email_rate_limit_ip_window' => 3600, 'change_email_notify_old_address' => true, 'change_email_require_reauth' => true, - 'change_email_admin_bypass_verification' => false, 'change_email_confirm_path' => 'workos/change-email', 'allow_magic_code_registration' => true, 'allow_legacy_magic_code_registration' => true, diff --git a/src/WorkOS/Options/Staging.php b/src/WorkOS/Options/Staging.php index 0ccded4..efc426b 100644 --- a/src/WorkOS/Options/Staging.php +++ b/src/WorkOS/Options/Staging.php @@ -51,7 +51,6 @@ protected function defaults(): array { 'change_email_rate_limit_ip_window' => 3600, 'change_email_notify_old_address' => true, 'change_email_require_reauth' => true, - 'change_email_admin_bypass_verification' => false, 'change_email_confirm_path' => 'workos/change-email', 'allow_magic_code_registration' => true, 'allow_legacy_magic_code_registration' => true, diff --git a/src/js/admin-change-email/index.ts b/src/js/admin-change-email/index.ts index 59d3f50..a513a0b 100644 --- a/src/js/admin-change-email/index.ts +++ b/src/js/admin-change-email/index.ts @@ -34,6 +34,7 @@ interface ChangeEmailConfig { modalCancel: string; sending: string; success: string; + successImmediate: string; errorGeneric: string; invalidEmail: string; }; @@ -44,6 +45,10 @@ interface SuccessResponse { masked_new_email?: string; expires_at?: number; no_op?: boolean; + /** Set when an admin change committed immediately (no verification). */ + committed?: boolean; + /** New address, returned only on an immediate admin commit. */ + email?: string; } interface ErrorResponse { @@ -211,8 +216,17 @@ async function sendChange( trigger: HTMLElement ): Promise< void > { } const ok = data as SuccessResponse; - const masked = ok.masked_new_email || __( 'the new address', 'integration-workos' ); - const msg = sprintf( config.strings.success, masked ); + // An admin acting on another account commits immediately; a + // self-service change is still pending a verification click. + let msg: string; + if ( ok.committed ) { + const addr = ok.email || __( 'the new address', 'integration-workos' ); + msg = sprintf( config.strings.successImmediate, addr ); + } else { + const masked = + ok.masked_new_email || __( 'the new address', 'integration-workos' ); + msg = sprintf( config.strings.success, masked ); + } if ( form ) { showInlineStatus( form, msg, 'success' ); } else { diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index ff30b5a..7b91bec 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -16,16 +16,20 @@ import { useState, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { promptModal } from '../shared/modal'; import './styles.css'; interface AdminConfig { restUrl: string; + /** Base for the change-email endpoint: `${changeEmailUrl}{id}/email-change`. */ + changeEmailUrl: string; nonce: string; environment: string; environmentId: string; dashboardBaseUrl: string; defaultLimit: number; pluginEnabled: boolean; + changeEmailEnabled: boolean; } declare global { @@ -71,12 +75,14 @@ function getConfig(): AdminConfig { return ( window.workosUsersAdmin || { restUrl: '/wp-json/workos/v1/admin/users', + changeEmailUrl: '/wp-json/workos/v1/users/', nonce: '', environment: '', environmentId: '', dashboardBaseUrl: 'https://dashboard.workos.com', defaultLimit: 25, pluginEnabled: false, + changeEmailEnabled: false, } ); } @@ -150,6 +156,11 @@ function App(): JSX.Element { const [ metadata, setMetadata ] = useState< ListMetadata >( { before: null, after: null } ); const [ loading, setLoading ] = useState< boolean >( false ); const [ error, setError ] = useState< string >( '' ); + const [ actionNotice, setActionNotice ] = useState< { + kind: 'success' | 'error'; + text: string; + } | null >( null ); + const [ busyUserId, setBusyUserId ] = useState< number >( 0 ); const [ searchInput, setSearchInput ] = useState< string >( '' ); const [ search, setSearch ] = useState< string >( '' ); const [ limit, setLimit ] = useState< number >( cfg.defaultLimit || 25 ); @@ -239,6 +250,125 @@ function App(): JSX.Element { setLimit( value ); }; + const handleChangeEmail = useCallback( + async ( user: WorkosUser ): Promise< void > => { + setActionNotice( null ); + + const newEmail = await promptModal( { + title: __( 'Change email address', 'integration-workos' ), + message: sprintf( + /* translators: %s: the user's current email address. */ + __( + 'Set a new email for %s. This updates WorkOS and the portal immediately — no verification email is sent.', + 'integration-workos' + ), + user.email || __( 'this user', 'integration-workos' ) + ), + inputLabel: __( 'New email address', 'integration-workos' ), + inputType: 'email', + placeholder: __( 'name@example.com', 'integration-workos' ), + confirmLabel: __( 'Change email', 'integration-workos' ), + cancelLabel: __( 'Cancel', 'integration-workos' ), + variant: 'danger', + validate: ( value: string ) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( value ) + ? null + : __( 'Please enter a valid email address.', 'integration-workos' ), + } ); + + if ( ! newEmail ) { + return; + } + + setBusyUserId( user.wp_user_id ); + try { + const res = await fetch( + `${ cfg.changeEmailUrl }${ user.wp_user_id }/email-change`, + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': cfg.nonce, + }, + body: JSON.stringify( { new_email: newEmail } ), + } + ); + const data = await res.json().catch( () => ( {} ) ); + + if ( ! res.ok ) { + setActionNotice( { + kind: 'error', + text: + data?.message || + __( + 'Could not change the email. Please try again.', + 'integration-workos' + ), + } ); + return; + } + + // An admin acting on their own account falls into the verified + // flow (no `committed`), so branch on the response — don't + // assume a commit, or we'd rewrite the row for a change that + // hasn't landed. + if ( data.no_op ) { + setActionNotice( { + kind: 'success', + text: __( + 'That is already this user’s email.', + 'integration-workos' + ), + } ); + } else if ( data.committed ) { + const updatedEmail = ( data.email as string ) || newEmail; + // Reflect the committed change in place — the row's email is + // now the new address, and it's unverified until the user + // verifies it in WorkOS. + setUsers( ( prev ) => + prev.map( ( u ) => + u.id === user.id + ? { ...u, email: updatedEmail, email_verified: false } + : u + ) + ); + setActionNotice( { + kind: 'success', + text: sprintf( + /* translators: %s: the new email address. */ + __( 'Email changed to %s.', 'integration-workos' ), + updatedEmail + ), + } ); + } else { + setActionNotice( { + kind: 'success', + text: sprintf( + /* translators: %s: the masked new email address. */ + __( + 'Verification email sent to %s.', + 'integration-workos' + ), + ( data.masked_new_email as string ) || newEmail + ), + } ); + } + } catch ( _err ) { + setActionNotice( { + kind: 'error', + text: __( + 'Could not change the email. Please try again.', + 'integration-workos' + ), + } ); + } finally { + setBusyUserId( 0 ); + } + }, + [ cfg.changeEmailUrl, cfg.nonce ] + ); + const hasPrev = cursorStack.current.length > 0; const hasNext = Boolean( metadata.after ); @@ -304,6 +434,17 @@ function App(): JSX.Element { ) } + { actionNotice && ( +
+

{ actionNotice.text }

+
+ ) } +
@@ -401,6 +542,22 @@ function App(): JSX.Element { { __( 'Send password reset', 'integration-workos' ) } ) } + { user.wp_user_id > 0 && cfg.changeEmailEnabled && ( + + ) } ) ) } diff --git a/tests/wpunit/ChangeEmailRestApiTest.php b/tests/wpunit/ChangeEmailRestApiTest.php index 399aaf8..90242aa 100644 --- a/tests/wpunit/ChangeEmailRestApiTest.php +++ b/tests/wpunit/ChangeEmailRestApiTest.php @@ -182,7 +182,9 @@ private function dispatch( string $method, string $path, array $body = [], ?stri // ----------------------------------------------------------------- initiate public function test_initiate_writes_pending_meta_and_sends_verification(): void { - wp_set_current_user( $this->admin_user_id ); + // Self-service: the user changes their own email, so the verified + // (token + email) flow runs rather than the admin-direct commit. + wp_set_current_user( $this->linked_user_id ); $new_email = 'brand-new-' . uniqid() . '@example.test'; $response = $this->dispatch( @@ -238,7 +240,7 @@ public function test_initiate_returns_403_when_no_edit_user_cap(): void { $this->assertSame( 403, $response->get_status() ); } - public function test_initiate_conflict_block_is_enumeration_safe(): void { + public function test_initiate_conflict_is_revealed_to_admin_acting_on_other(): void { wp_set_current_user( $this->admin_user_id ); // Target the address owned by another local user. @@ -248,14 +250,49 @@ public function test_initiate_conflict_block_is_enumeration_safe(): void { [ 'new_email' => $this->other_email ] ); - // Enumeration-safe: response shape is the same as success. + // An admin acting on someone else's account can already enumerate + // users, so the real reason is surfaced rather than hidden. + $this->assertSame( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'workos_change_email_conflict', $data['code'] ?? '' ); + $this->assertStringContainsString( 'already in use', strtolower( $data['message'] ?? '' ) ); + + // Still no pending meta, and the block is still recorded. + $stored = get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ); + $this->assertTrue( '' === $stored || empty( $stored ) ); + + global $wpdb; + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'email_change.conflict_blocked' + ) + ); + $this->assertSame( '1', (string) $row ); + } + + public function test_initiate_conflict_is_enumeration_safe_for_self_service(): void { + // A non-privileged user changing their *own* email to an address + // owned by someone else must not learn that it's taken. + wp_set_current_user( $this->unlinked_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->unlinked_user_id . '/email-change', + [ 'new_email' => $this->other_email ] + ); + + // Enumeration-safe: response shape is identical to success. $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ok'] ?? false ); + $this->assertStringContainsString( '•', $data['masked_new_email'] ?? '' ); // But no pending meta was written. - $stored = get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ); + $stored = get_user_meta( $this->unlinked_user_id, PendingChange::META_KEY, true ); $this->assertTrue( '' === $stored || empty( $stored ) ); - // And the activity log records the block. + // And the activity log still records the block. global $wpdb; $row = $wpdb->get_var( $wpdb->prepare( @@ -284,7 +321,9 @@ public function test_initiate_same_email_is_noop(): void { } public function test_initiate_user_rate_limit(): void { - wp_set_current_user( $this->admin_user_id ); + // Rate limits only apply to self-service; the admin-direct path + // bypasses them, so this must run as the user acting on themselves. + wp_set_current_user( $this->linked_user_id ); $last = null; for ( $i = 0; $i < 4; $i++ ) { @@ -299,6 +338,94 @@ public function test_initiate_user_rate_limit(): void { $this->assertSame( 429, $last->get_status() ); } + public function test_initiate_admin_commits_immediately_without_verification(): void { + wp_set_current_user( $this->admin_user_id ); + + $new_email = 'admin-set-' . uniqid() . '@example.test'; + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => $new_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['committed'] ?? false ); + $this->assertSame( strtolower( $new_email ), $data['email'] ?? '' ); + + // Committed straight to WP — no pending meta, no verification email. + $updated = get_userdata( $this->linked_user_id ); + $this->assertSame( strtolower( $new_email ), strtolower( $updated->user_email ) ); + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + $this->assertEmpty( $this->mail_captured ); + + // WorkOS was updated for the linked user. + $hit = false; + foreach ( $this->http_captured as $req ) { + if ( str_contains( (string) $req['url'], 'user_linked_01' ) ) { + $hit = true; + break; + } + } + $this->assertTrue( $hit, 'WorkOS update_user must be called for a linked user.' ); + + // Audit trail records the admin-direct change. + global $wpdb; + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'email_change.admin_changed' + ) + ); + $this->assertSame( '1', (string) $row ); + } + + public function test_initiate_admin_acting_on_self_uses_verified_flow(): void { + // An admin changing *their own* email is not an "admin action" — it + // must go through emailed verification like any self-service change, + // not commit immediately. + wp_set_current_user( $this->admin_user_id ); + + $new_email = 'admin-own-' . uniqid() . '@example.test'; + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->admin_user_id . '/email-change', + [ 'new_email' => $new_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + // Verified-flow shape: a pending expiry, and crucially NOT committed. + $this->assertArrayNotHasKey( 'committed', $data ); + $this->assertArrayHasKey( 'expires_at', $data ); + + // The address is only pending — WP still holds the old email. + $this->assertNotEmpty( get_user_meta( $this->admin_user_id, PendingChange::META_KEY, true ) ); + $unchanged = get_userdata( $this->admin_user_id ); + $this->assertNotSame( strtolower( $new_email ), strtolower( $unchanged->user_email ) ); + } + + public function test_initiate_admin_direct_on_unlinked_user_skips_workos(): void { + // Admin-direct change for a user with no WorkOS link should mirror + // only into WordPress and make no upstream call. + wp_set_current_user( $this->admin_user_id ); + + $new_email = 'unlinked-new-' . uniqid() . '@example.test'; + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->unlinked_user_id . '/email-change', + [ 'new_email' => $new_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['committed'] ?? false ); + + // WP updated, but no WorkOS round-trip (user has no `_workos_user_id`). + $updated = get_userdata( $this->unlinked_user_id ); + $this->assertSame( strtolower( $new_email ), strtolower( $updated->user_email ) ); + $this->assertEmpty( $this->http_captured ); + } + // ----------------------------------------------------------------- confirm public function test_confirm_commits_change_and_clears_meta(): void {