From d0a70f1a03130b98a7fa65b82fae2e63c073cd75 Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Fri, 19 Jun 2026 08:18:33 -0400 Subject: [PATCH 1/8] Fix fatal in admin password-reset when profile sanitizes to empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `profile` REST arg registered `sanitize_title` directly as its sanitize_callback. WordPress invokes sanitize callbacks as `call_user_func( $cb, $value, $request, $key )`, so the request object landed in sanitize_title's `$fallback_title` parameter. When the input sanitizes to an empty string — as it does for the common `profile: ""` payload — sanitize_title returns the fallback, handing back the WP_REST_Request object. The subsequent `(string)` cast in send_reset() then fataled with "Object of class WP_REST_Request could not be converted to string", surfacing as a 500. Wrap the sanitizer in a closure so only the value reaches sanitize_title. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/WorkOS/Auth/PasswordResetAdmin/RestApi.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ) ], ], ] ); From eddbfa7a3bddb14d6d898468b38ac67b6072b1b5 Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Fri, 19 Jun 2026 13:19:26 -0400 Subject: [PATCH 2/8] feat: change a user's email from the WorkOS Users admin page Add a "Change email" action to admin.php?page=workos-users, alongside "Open in WorkOS" and "Send password reset". The change-email REST flow now branches on who is acting. A privileged admin acting on another account (edit_users && initiator !== target) commits the change immediately to WorkOS + WordPress, with no token and no verification email; self-service callers keep the emailed-token verified flow unchanged. The shared commit logic (WorkOS update, local mirror, rollback, webhook race-guard, sync-hash refresh) is extracted from confirm() into commit_change() so both paths share one implementation. Admins acting on another account also see the real "already in use" conflict instead of the enumeration-safe response, since they can already enumerate accounts; self-service stays enumeration-safe. Forced admin changes are recorded as a distinct email_change.admin_changed audit event and send no user notification. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/WorkOS/Admin/Users/AdminPage.php | 26 ++- src/WorkOS/Auth/ChangeEmail/Assets.php | 10 +- src/WorkOS/Auth/ChangeEmail/RestApi.php | 292 +++++++++++++++++------- src/js/admin-change-email/index.ts | 18 +- src/js/admin-users/index.tsx | 131 +++++++++++ tests/wpunit/ChangeEmailRestApiTest.php | 93 +++++++- 6 files changed, 472 insertions(+), 98 deletions(-) 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..41f6e87 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -270,19 +270,30 @@ 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; + // A privileged admin acting on another account commits the change + // immediately and skips the verification round-trip entirely; the + // capability gate is the trust boundary. Self-service callers go + // through the emailed-token flow below. + $is_admin_action = $this->is_admin_action( (int) $user->ID ); + + // Rate limits exist to throttle anonymous-ish self-service abuse; + // they'd only get in an admin's way during a legitimate cleanup + // sweep, so the capability-gated admin path bypasses them. + 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 +324,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 +346,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 +405,63 @@ public function initiate( WP_REST_Request $request ) { ); } + /** + * Commit an admin-initiated change immediately, bypassing verification. + * + * Reuses the shared {@see commit_change()} (WorkOS + WP, with rollback + * and the webhook race-guard) but skips the token round-trip and the + * user-facing notification — admins act on accounts directly, and the + * change is recorded in the activity log for audit. The new address is + * echoed back unmasked so the admin UI can refresh the row in place; + * the caller already typed it, so there's nothing to hide. + * + * @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 ) { + $commit = $this->commit_change( $user, $new_email ); + 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 +543,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 +648,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/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..9a12e8f 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,99 @@ 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; + } + + 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 + ), + } ); + } 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 +408,17 @@ function App(): JSX.Element { ) } + { actionNotice && ( +
+

{ actionNotice.text }

+
+ ) } +
@@ -401,6 +516,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..7b7442b 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,48 @@ 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 ); + } + // ----------------------------------------------------------------- confirm public function test_confirm_commits_change_and_clears_meta(): void { From 902c05ddb325ec783452b1faf5b91d8fbb106c20 Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Fri, 19 Jun 2026 13:28:41 -0400 Subject: [PATCH 3/8] fix: honor commit vs. verified response on the Users-page change-email The admin Users page assumed the change-email response was always an immediate commit, but an admin acting on their *own* account falls into the verified (emailed-token) flow and returns no `committed` flag. The page would rewrite the row and report "Email changed" for a change that hadn't landed yet. Branch on `committed`/`no_op` instead, and only mutate the row on an actual commit; otherwise report that verification was sent. Also announce error notices assertively (role="alert") so screen readers catch a failed action, and add wpunit coverage for the admin-acting-on- self verified path and the admin-direct commit on a WorkOS-unlinked user. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/js/admin-users/index.tsx | 68 +++++++++++++++++-------- tests/wpunit/ChangeEmailRestApiTest.php | 46 +++++++++++++++++ 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index 9a12e8f..0a9a3d7 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -309,25 +309,53 @@ function App(): JSX.Element { return; } - 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 - ), - } ); + // The server decides immediate-commit vs. verified flow by who + // is acting: an admin on another account commits now; an admin + // acting on their *own* account still goes through emailed + // verification. Honor that discriminator rather than assuming a + // commit — otherwise we'd rewrite the row for a change that + // hasn't landed yet. + 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', @@ -413,7 +441,7 @@ function App(): JSX.Element { className={ `notice notice-${ actionNotice.kind === 'success' ? 'success' : 'error' } is-dismissible workos-users-notice` } - role="status" + role={ actionNotice.kind === 'error' ? 'alert' : 'status' } >

{ actionNotice.text }

diff --git a/tests/wpunit/ChangeEmailRestApiTest.php b/tests/wpunit/ChangeEmailRestApiTest.php index 7b7442b..90242aa 100644 --- a/tests/wpunit/ChangeEmailRestApiTest.php +++ b/tests/wpunit/ChangeEmailRestApiTest.php @@ -380,6 +380,52 @@ public function test_initiate_admin_commits_immediately_without_verification(): $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 { From b183ccaa883eec6158760e20f14f714c7c5557e5 Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Fri, 19 Jun 2026 13:33:37 -0400 Subject: [PATCH 4/8] fix: silence WP core email-change notice on admin-direct change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp_update_user() mails WordPress core's "Notice of Email Change" to the old address whenever the email changes. The admin-direct path skipped our own notifier but not this one, so the "silent" admin change wasn't silent — and the test asserting no mail failed. Suppress it via the send_email_change_email filter, scoped to the admin commit so the self-service confirm path keeps its core notice. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/WorkOS/Auth/ChangeEmail/RestApi.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php index 41f6e87..8c4db28 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -421,7 +421,17 @@ public function initiate( WP_REST_Request $request ) { * @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; } From fdfa2fcefcd5dff98b1d2e93e6513a64a75dbd9d Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Fri, 19 Jun 2026 13:42:22 -0400 Subject: [PATCH 5/8] docs: trim restated docblocks/comments to the why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the initiate() intro comment that restated is_admin_action()'s own docblock, and tighten the commit_admin_change docblock and the React discriminator comment. Keep the comments that carry non-obvious why — the commit_change invariants, the rate-limit bypass, and the WP-core email-notice suppression. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/WorkOS/Auth/ChangeEmail/RestApi.php | 19 ++++++------------- src/js/admin-users/index.tsx | 10 ++++------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php index 8c4db28..24f34c0 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -270,15 +270,10 @@ public function initiate( WP_REST_Request $request ) { } $new_email = strtolower( $new_email ); - // A privileged admin acting on another account commits the change - // immediately and skips the verification round-trip entirely; the - // capability gate is the trust boundary. Self-service callers go - // through the emailed-token flow below. $is_admin_action = $this->is_admin_action( (int) $user->ID ); - // Rate limits exist to throttle anonymous-ish self-service abuse; - // they'd only get in an admin's way during a legitimate cleanup - // sweep, so the capability-gated admin path bypasses them. + // 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 ); @@ -408,12 +403,10 @@ public function initiate( WP_REST_Request $request ) { /** * Commit an admin-initiated change immediately, bypassing verification. * - * Reuses the shared {@see commit_change()} (WorkOS + WP, with rollback - * and the webhook race-guard) but skips the token round-trip and the - * user-facing notification — admins act on accounts directly, and the - * change is recorded in the activity log for audit. The new address is - * echoed back unmasked so the admin UI can refresh the row in place; - * the caller already typed it, so there's nothing to hide. + * 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. diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index 0a9a3d7..7b91bec 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -309,12 +309,10 @@ function App(): JSX.Element { return; } - // The server decides immediate-commit vs. verified flow by who - // is acting: an admin on another account commits now; an admin - // acting on their *own* account still goes through emailed - // verification. Honor that discriminator rather than assuming a - // commit — otherwise we'd rewrite the row for a change that - // hasn't landed yet. + // 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', From c45f0954f4afa9038ad7e28a92c8124c3f13418b Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Tue, 30 Jun 2026 10:23:35 -0400 Subject: [PATCH 6/8] docs: document Users-page change-email + admin-direct commits, cut 1.0.8 Add documentation, README/AGENTS, and changelog coverage for the WorkOS-Users-page change-email action and the admin-direct immediate-commit behavior, and bump to 1.0.8. - change-email.md: new "Admin-direct vs. self-service" matrix; admin response shape and 409 on initiate; Users-page surface; admin_changed event; drop the removed admin_bypass setting; test count 13 -> 19. - README.md / AGENTS.md: reflect the new surface, admin-direct path, and admin_changed event. - CHANGELOG.md / readme.txt: 1.0.8 entries for the change-email work (#33) and the password-reset empty-profile 500 fix (#32). - Remove the now-unused change_email_admin_bypass_verification option. - Bump Version / Stable tag / package.json to 1.0.8. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 8 ++-- CHANGELOG.md | 15 ++++++++ README.md | 9 +++-- docs/change-email.md | 61 ++++++++++++++++++++++++++----- integration-workos.php | 2 +- package.json | 2 +- readme.txt | 11 +++++- src/WorkOS/Options/Production.php | 1 - src/WorkOS/Options/Staging.php | 1 - 9 files changed, 87 insertions(+), 23 deletions(-) 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..3afec10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.0.8] - 2026-06-30 + +### Added + +- **Change email from the WorkOS Users page + immediate admin commits** (#33) — extends the change-email flow with a third admin surface and a forced-commit path for privileged callers. + - A native "Change email" action on the **WorkOS → Users** admin page (`admin.php?page=workos-users`), beside "Open in WorkOS" and "Send password reset" — own modal, immediate POST, in-place row refresh, success/error notice, per-row busy state. + - Admin-of-other changes (caller has `edit_users` and is not the target) now **commit immediately**, with no emailed verification round-trip. Self-service email changes keep the hashed-token verified flow. The forced path skips rate limiting and sends no user notification — including suppressing WP core's "Notice of Email Change" to the old address — and logs a distinct `email_change.admin_changed` event (`verified: false`). + - Admins acting on another account now see the real `409` conflict (`This email is already in use by another account.`); self-service initiates stay enumeration-safe (same-shape `200`). + - The shared commit logic (WorkOS `update_user` → `wp_update_user` → rollback → webhook race-guard → sync-hash refresh) is extracted into one `commit_change()` used by both the verified-confirm and admin-direct paths. + - Removes the unused `change_email_admin_bypass_verification` option — the immediate-commit path is now gated purely by the `edit_users` capability, not a setting. + +### Fixed + +- **500 in admin password-reset when `profile` is empty** (#32) — registering `sanitize_title` bare as the `profile` arg's `sanitize_callback` let WordPress pass the `WP_REST_Request` into its `$fallback_title` parameter, so an empty `profile` (the common case — the UI posts `profile: ""`) came back as the request object and fataled on the `(string)` cast in `send_reset()`. The callback now drops the extra args, so an empty profile resolves to the default login profile as intended. + ## [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..26f055e 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 straight from the WorkOS → Users admin page. A native "Change email" action (beside "Open in WorkOS" and "Send password reset") opens a modal, posts the change, and refreshes the row in place. (#33) +* New: Admin-direct email changes commit immediately. When a privileged user (`edit_users`) changes *another* account's email — from any admin surface — the change now lands at once with no emailed verification step; self-service changes keep the hashed-token verified flow. Forced changes skip rate limiting, send no notification (including WP core's "Notice of Email Change"), and log a distinct `email_change.admin_changed` event. Admins also see the real "already in use" conflict, while self-service stays enumeration-safe. The immediate-commit path is gated by the `edit_users` capability and the unused `change_email_admin_bypass_verification` option has been removed. (#33) +* Fix: Admin password reset no longer 500s when the `profile` field is empty. Registering `sanitize_title` bare let WordPress hand the request object back as the sanitized value, which fataled on a string cast; the callback now resolves an empty profile to the default login profile as intended. (#32) + = 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 (no emailed verification), while self-service keeps the verified token flow. Removes the unused `change_email_admin_bypass_verification` option — admin-direct commits are now gated by the `edit_users` capability. 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/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, From 7368a715781b130e2962def70b95ff18f457b02e Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Tue, 30 Jun 2026 10:30:01 -0400 Subject: [PATCH 7/8] docs: attribute the password-reset fix to #33 in the 1.0.8 changelog PR #32 is folded into #33, so #33 is the PR of record for the password-reset empty-profile fix in this release. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- readme.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afec10..551ca22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ ### Fixed -- **500 in admin password-reset when `profile` is empty** (#32) — registering `sanitize_title` bare as the `profile` arg's `sanitize_callback` let WordPress pass the `WP_REST_Request` into its `$fallback_title` parameter, so an empty `profile` (the common case — the UI posts `profile: ""`) came back as the request object and fataled on the `(string)` cast in `send_reset()`. The callback now drops the extra args, so an empty profile resolves to the default login profile as intended. +- **500 in admin password-reset when `profile` is empty** (#33) — registering `sanitize_title` bare as the `profile` arg's `sanitize_callback` let WordPress pass the `WP_REST_Request` into its `$fallback_title` parameter, so an empty `profile` (the common case — the UI posts `profile: ""`) came back as the request object and fataled on the `(string)` cast in `send_reset()`. The callback now drops the extra args, so an empty profile resolves to the default login profile as intended. ## [1.0.7] - 2026-06-23 diff --git a/readme.txt b/readme.txt index 26f055e..740427e 100644 --- a/readme.txt +++ b/readme.txt @@ -179,7 +179,7 @@ WorkOS is provided by WorkOS, Inc. * New: Change a user's email straight from the WorkOS → Users admin page. A native "Change email" action (beside "Open in WorkOS" and "Send password reset") opens a modal, posts the change, and refreshes the row in place. (#33) * New: Admin-direct email changes commit immediately. When a privileged user (`edit_users`) changes *another* account's email — from any admin surface — the change now lands at once with no emailed verification step; self-service changes keep the hashed-token verified flow. Forced changes skip rate limiting, send no notification (including WP core's "Notice of Email Change"), and log a distinct `email_change.admin_changed` event. Admins also see the real "already in use" conflict, while self-service stays enumeration-safe. The immediate-commit path is gated by the `edit_users` capability and the unused `change_email_admin_bypass_verification` option has been removed. (#33) -* Fix: Admin password reset no longer 500s when the `profile` field is empty. Registering `sanitize_title` bare let WordPress hand the request object back as the sanitized value, which fataled on a string cast; the callback now resolves an empty profile to the default login profile as intended. (#32) +* Fix: Admin password reset no longer 500s when the `profile` field is empty. Registering `sanitize_title` bare let WordPress hand the request object back as the sanitized value, which fataled on a string cast; the callback now resolves an empty profile to the default login profile as intended. (#33) = 1.0.7 - 2026-06-23 = From 058ac0762b43e874c6e0b3ba7f17381457a1b5d8 Mon Sep 17 00:00:00 2001 From: Brian Krane Date: Tue, 30 Jun 2026 10:32:59 -0400 Subject: [PATCH 8/8] docs: tighten the 1.0.8 changelog/readme entries Trim the change-email and password-reset entries to a lead line plus a sentence or two, matching the surrounding changelog style; the PR carries the full detail. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++------- readme.txt | 8 ++++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 551ca22..28f3352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,11 @@ ### Added -- **Change email from the WorkOS Users page + immediate admin commits** (#33) — extends the change-email flow with a third admin surface and a forced-commit path for privileged callers. - - A native "Change email" action on the **WorkOS → Users** admin page (`admin.php?page=workos-users`), beside "Open in WorkOS" and "Send password reset" — own modal, immediate POST, in-place row refresh, success/error notice, per-row busy state. - - Admin-of-other changes (caller has `edit_users` and is not the target) now **commit immediately**, with no emailed verification round-trip. Self-service email changes keep the hashed-token verified flow. The forced path skips rate limiting and sends no user notification — including suppressing WP core's "Notice of Email Change" to the old address — and logs a distinct `email_change.admin_changed` event (`verified: false`). - - Admins acting on another account now see the real `409` conflict (`This email is already in use by another account.`); self-service initiates stay enumeration-safe (same-shape `200`). - - The shared commit logic (WorkOS `update_user` → `wp_update_user` → rollback → webhook race-guard → sync-hash refresh) is extracted into one `commit_change()` used by both the verified-confirm and admin-direct paths. - - Removes the unused `change_email_admin_bypass_verification` option — the immediate-commit path is now gated purely by the `edit_users` capability, not a setting. +- **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) — registering `sanitize_title` bare as the `profile` arg's `sanitize_callback` let WordPress pass the `WP_REST_Request` into its `$fallback_title` parameter, so an empty `profile` (the common case — the UI posts `profile: ""`) came back as the request object and fataled on the `(string)` cast in `send_reset()`. The callback now drops the extra args, so an empty profile resolves to the default login profile as intended. +- **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 diff --git a/readme.txt b/readme.txt index 740427e..128d1fd 100644 --- a/readme.txt +++ b/readme.txt @@ -177,9 +177,9 @@ WorkOS is provided by WorkOS, Inc. = 1.0.8 - 2026-06-30 = -* New: Change a user's email straight from the WorkOS → Users admin page. A native "Change email" action (beside "Open in WorkOS" and "Send password reset") opens a modal, posts the change, and refreshes the row in place. (#33) -* New: Admin-direct email changes commit immediately. When a privileged user (`edit_users`) changes *another* account's email — from any admin surface — the change now lands at once with no emailed verification step; self-service changes keep the hashed-token verified flow. Forced changes skip rate limiting, send no notification (including WP core's "Notice of Email Change"), and log a distinct `email_change.admin_changed` event. Admins also see the real "already in use" conflict, while self-service stays enumeration-safe. The immediate-commit path is gated by the `edit_users` capability and the unused `change_email_admin_bypass_verification` option has been removed. (#33) -* Fix: Admin password reset no longer 500s when the `profile` field is empty. Registering `sanitize_title` bare let WordPress hand the request object back as the sanitized value, which fataled on a string cast; the callback now resolves an empty profile to the default login profile as intended. (#33) +* 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 = @@ -273,7 +273,7 @@ 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 (no emailed verification), while self-service keeps the verified token flow. Removes the unused `change_email_admin_bypass_verification` option — admin-direct commits are now gated by the `edit_users` capability. Also fixes a 500 in the admin password-reset endpoint when the profile field is empty. +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.