diff --git a/AGENTS.md b/AGENTS.md index 39b4498..73321f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Per-environment constants (take priority over generic): ### Capabilities -WorkOS user-management checks use plugin capabilities (`workos_view_users`, `workos_send_password_reset`, `workos_change_email`, `workos_manage_lockouts`) mapped through `map_meta_cap` in `src/WorkOS/Capabilities.php`. Each maps to the core capability the check used before, so default roles behave the same; sites grant them to custom roles for narrower access. Settings pages stay on `manage_options`. New user-management checks should use a plugin capability, not a core one. See [`docs/capabilities.md`](docs/capabilities.md). +WorkOS user-management checks use plugin capabilities (`workos_view_users`, `workos_send_password_reset`, `workos_change_email`, `workos_manage_lockouts`, `workos_verify_email`) mapped through `map_meta_cap` in `src/WorkOS/Capabilities.php`. Each maps to the core capability the check used before, so default roles behave the same; sites grant them to custom roles for narrower access. Settings pages stay on `manage_options`. New user-management checks should use a plugin capability, not a core one. See [`docs/capabilities.md`](docs/capabilities.md). ## Key Files @@ -73,7 +73,7 @@ WorkOS user-management checks use plugin capabilities (`workos_view_users`, `wor | `src/WorkOS/App.php` | Static facade for the DI container | | `src/WorkOS/Config.php` | Centralized config with constant overrides | | `src/WorkOS/User.php` | Public helper for WorkOS metadata on WP users (`is_sso`, `has_active_session`, `get_workos_id`, `get_access_token`, `snapshot`), plus two session helpers: `get_valid_access_token` (refreshes through `Api\Client::refresh_session()` + `Login::store_tokens()` when the token expires within `ACCESS_TOKEN_REFRESH_LEEWAY`) and `get_logout_url` (WorkOS `sessions/logout` URL for the stored session, else `wp_logout_url()`). See [`docs/php-helpers.md`](docs/php-helpers.md) | -| `src/WorkOS/Capabilities.php` | Plugin capabilities (`workos_view_users`, `workos_send_password_reset`, `workos_change_email`, `workos_manage_lockouts`) resolved through `map_meta_cap`. Each maps to the core capability its check used before (`manage_options`, `edit_user`, `edit_user`, `edit_users`) unless the user holds the capability directly, in which case that grant decides (`false` denies). Registered first in the main `Controller`. See [`docs/capabilities.md`](docs/capabilities.md). | +| `src/WorkOS/Capabilities.php` | Plugin capabilities (`workos_view_users`, `workos_send_password_reset`, `workos_change_email`, `workos_manage_lockouts`, `workos_verify_email`) resolved through `map_meta_cap`. Each maps to the core capability its check used before (`manage_options`, `edit_user`, `edit_user`, `edit_users`, `edit_users`) unless the user holds the capability directly, in which case that grant decides (`false` denies). Registered first in the main `Controller`. See [`docs/capabilities.md`](docs/capabilities.md). | | `src/WorkOS/Controller.php` | Main controller, registers all feature controllers | | `src/WorkOS/Contracts/Container.php` | DI container (di52-based) | | `src/WorkOS/Contracts/Controller.php` | Abstract base controller with `isActive()` | @@ -82,7 +82,7 @@ WorkOS user-management checks use plugin capabilities (`workos_view_users`, `wor | `src/WorkOS/Admin/Controller.php` | Admin controller, registers settings/user list/onboarding/diagnostics | | `src/WorkOS/Admin/Settings.php` | Admin settings page (tabs: Settings, Organization, Users) | | `src/WorkOS/Admin/UserList.php` | Admin user list integration (WorkOS column). Exposes `workos_user_list_column_actions` filter so subsystems can add per-row WorkOS actions (slug-keyed; joined with pipe separators) — see PasswordResetAdmin `RowActions`. | -| `src/WorkOS/Admin/UserProfile.php` | User profile page WorkOS metadata | +| `src/WorkOS/Admin/UserProfile.php` | User profile page WorkOS metadata. The Email row shows a "Mark email verified" button (`.workos-verify-email-trigger`, handled by `src/js/admin-password-reset/index.ts`) when WorkOS has the email unverified and the viewer has `workos_verify_email`. | | `src/WorkOS/Admin/AdminBar.php` | Admin bar environment badge | | `src/WorkOS/Admin/DiagnosticsPage.php` | System diagnostics page | | `src/WorkOS/Admin/OnboardingPage.php` | Onboarding wizard UI | @@ -90,8 +90,8 @@ WorkOS user-management checks use plugin capabilities (`workos_view_users`, `wor | **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, `workos_view_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 plus per-row `can_send_password_reset` / `can_change_email` flags (the caller's plugin capability on that WP user; false for unlinked rows) so the React side only shows actions the caller can use. Gated by `workos_view_users`. | -| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link + per-row Send-password-reset button when `can_send_password_reset` + native "Change email" button (when `can_change_email`) with its own modal, immediate POST, and in-place row refresh) | +| `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 plus per-row `can_send_password_reset` / `can_change_email` flags (the caller's plugin capability on that WP user; false for unlinked rows) so the React side only shows actions the caller can use. Gated by `workos_view_users`. Also `POST /wp-json/workos/v1/admin/users/{id}/verify-email` ({id} is the WP user ID; `workos_verify_email`, maps to `edit_users`): fetches the WorkOS user, no-ops when already verified, else `update_user( [ email_verified => true ] )`, clears the user-edit panel cache, logs `email.admin_verified` with `initiator_id`; WorkOS failures return `502 workos_api_error`. Rows also carry `can_verify_email`. | +| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link + per-row Send-password-reset button when `can_send_password_reset` + native "Change email" button (when `can_change_email`) with its own modal, immediate POST, and in-place row refresh + "Mark email verified" button when the row is unverified and `can_verify_email`, with a confirm modal and in-place row update) | | **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 | @@ -111,7 +111,7 @@ WorkOS user-management checks use plugin capabilities (`workos_view_users`, `wor | `src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php` | "Password Reset" panel + trigger button on the WP user-edit screen (`edit_user_profile` / `show_user_profile` at priority 20 so it renders after the existing read-only WorkOS panel). | | `src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php` | `[workos:password-reset]` — toggles between admin-of-other (`user="id-or-email"`) and self-service (no `user`) modes based on its attributes. | | `src/WorkOS/Auth/PasswordResetAdmin/Assets.php` | Registers `workos-admin-password-reset` JS/CSS handles + localizes `workosPasswordReset` (REST URL, `wp_rest` nonce, masked-email-aware UI strings). Auto-enqueues on `users.php` / `user-edit.php` / `profile.php`. | -| `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). | +| `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). Also handles `.workos-verify-email-trigger` (the user-edit panel's "Mark email verified" button: confirm, POST `…/verify-email`, swap in a check mark). | | **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, `workos_change_email` cap, maps to `edit_user`), `…/email-change/confirm` (token-gated, race re-check), `…/email-change/cancel` (cancel-token *or* `workos_change_email` 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. | @@ -339,6 +339,7 @@ tests/ ├── AuthKitRestPasswordTest.php # password/{authenticate,reset/start,reset/confirm} ├── AuthKitRestSignupInvitationOAuthTest.php # signup/{create,verify}, invitation/{token,accept}, oauth/authorize-url ├── CapabilitiesTest.php # Plugin capabilities: role × self/other matrix matches the old core checks, direct grants, `false` denies, surfaces follow their capability + ├── AdminUsersVerifyEmailTest.php # `POST /admin/users/{id}/verify-email` — capability (incl. no self-service), 404/409, already-verified no-op, success + activity log, WorkOS errors; panel button visibility ├── ConfigTest.php # Config class tests ├── DirectorySyncTest.php # Directory sync tests ├── EntitlementGateTest.php # Entitlement gate tests diff --git a/README.md b/README.md index 5d63995..ea5af43 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **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 `workos_send_password_reset`, which maps to `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 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). +- **Mark email verified** — for a customer whose sign-in code never arrives, support can mark the linked WorkOS email verified (after confirming who they are another way) from the **WorkOS → Users** screen or the WorkOS panel on the user-edit screen. `POST /wp-json/workos/v1/admin/users/{id}/verify-email`, gated by `workos_verify_email` (maps to `edit_users`), asks for confirmation, and is logged as `email.admin_verified` with who did it. See [`docs/capabilities.md`](docs/capabilities.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 diff --git a/docs/capabilities.md b/docs/capabilities.md index d4ca25d..d05e076 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -12,6 +12,7 @@ Code: [`src/WorkOS/Capabilities.php`](../src/WorkOS/Capabilities.php). Tests: [` | `workos_send_password_reset` | `edit_user` | Yes | `POST /wp-json/workos/v1/admin/users/{id}/password-reset`, the "Send password reset" row action, the user-edit panel button, the `[workos:password-reset]` shortcode, and the button on the WorkOS Users screen | | `workos_change_email` | `edit_user` | Yes | `POST /wp-json/workos/v1/users/{id}/email-change` (initiate), the capability path of `…/email-change/cancel`, the "Change email" row action, the user-edit panel, the `[workos:change-email]` shortcode, and the button on the WorkOS Users screen | | `workos_manage_lockouts` | `edit_users` | No | Reserved for clearing sign-in lockouts. Nothing checks it yet | +| `workos_verify_email` | `edit_users` | No | `POST /wp-json/workos/v1/admin/users/{id}/verify-email` and the "Mark email verified" buttons on the WorkOS Users screen and the user-edit WorkOS panel. Not self-service: nobody can verify their own email unless they have `edit_users` or are granted it | `edit_user` is per user: WordPress gives every logged-in user `edit_user` on their own account. That is what keeps the self-service password reset and email change working for subscribers, and it still does. @@ -20,7 +21,7 @@ The PHP helper `workos_send_password_reset()` runs the same code as the route bu ## What stays on core capabilities - **Settings and the other WorkOS admin pages** (Settings, Organization, Login Profiles, Activity Log, Diagnostics, Onboarding) stay on `manage_options`. None of the plugin capabilities opens them. -- **The WorkOS panel on the user-edit screen** stays on `edit_users`. +- **The WorkOS panel on the user-edit screen** stays on `edit_users`. Its "Mark email verified" button also needs `workos_verify_email`, so a support role without `edit_users` marks emails verified from **WorkOS → Users**. - **Sync and re-sync** on `wp-admin/users.php` stay on `edit_users`. - **Immediate email change.** An email change for another account commits immediately only when the caller has `edit_users`. A role granted just `workos_change_email` can start a change for someone else, but it goes through the emailed verification step, like self-service. See [change-email.md](change-email.md#admin-direct-vs-self-service). @@ -74,4 +75,4 @@ get_role( 'administrator' )->add_cap( 'workos_change_email', false ); - The **WorkOS** top-level menu stays registered on `manage_options`. A user who has `workos_view_users` but not `manage_options` sees the menu with only the **Users** entry, and the menu link goes straight to it. - The row actions on `wp-admin/users.php` only show to users who can open that screen, which needs `list_users`. Without it, a support role works from **WorkOS → Users** instead. -- The WorkOS Users screen shows each row's "Send password reset" and "Change email" buttons only when the current user has the matching capability for that row's WordPress user. +- The WorkOS Users screen shows each row's "Send password reset", "Change email" and "Mark email verified" buttons only when the current user has the matching capability for that row's WordPress user. Rows with no linked WordPress user have none of them. diff --git a/src/WorkOS/Admin/UserProfile.php b/src/WorkOS/Admin/UserProfile.php index 8976c6e..dd3b019 100644 --- a/src/WorkOS/Admin/UserProfile.php +++ b/src/WorkOS/Admin/UserProfile.php @@ -10,6 +10,7 @@ namespace WorkOS\Admin; +use WorkOS\Capabilities; use WorkOS\Config; use WorkOS\Sync\UserSync; @@ -93,7 +94,7 @@ public function render( \WP_User $user ): void { $workos_user = $this->get_cached_workos_user( $workos_user_id ); } - $this->render_user_info( $workos_user, $local_meta, $environment_id ); + $this->render_user_info( $workos_user, $local_meta, $environment_id, $user->ID ); // Organization Memberships. if ( $plugin_enabled ) { @@ -176,8 +177,9 @@ private function render_dashboard_link( string $workos_user_id, string $environm * @param array|null $workos_user WorkOS user data from the API (null if unavailable). * @param array $local_meta Local user meta. * @param string $environment_id WorkOS environment ID. + * @param int $wp_user_id WP user being edited. */ - private function render_user_info( ?array $workos_user, array $local_meta, string $environment_id ): void { + private function render_user_info( ?array $workos_user, array $local_meta, string $environment_id, int $wp_user_id ): void { echo '

' . esc_html__( 'User Information', 'integration-workos' ) . '

'; if ( null === $workos_user ) { @@ -216,6 +218,13 @@ private function render_user_info( ?array $workos_user, array $local_meta, strin $email_html = esc_html( $workos_user['email'] ?? '' ); if ( ! empty( $workos_user['email_verified'] ) ) { $email_html .= ' ✓'; + } elseif ( current_user_can( Capabilities::VERIFY_EMAIL ) ) { + // Handled by the shared admin script (admin-password-reset). + $email_html .= sprintf( + ' ', + $wp_user_id, + esc_html__( 'Mark email verified', 'integration-workos' ) + ); } $this->render_form_row( __( 'Email', 'integration-workos' ), $email_html ); diff --git a/src/WorkOS/Admin/Users/RestApi.php b/src/WorkOS/Admin/Users/RestApi.php index 010b186..52b9e10 100644 --- a/src/WorkOS/Admin/Users/RestApi.php +++ b/src/WorkOS/Admin/Users/RestApi.php @@ -7,6 +7,7 @@ namespace WorkOS\Admin\Users; +use WorkOS\ActivityLog\EventLogger; use WorkOS\Capabilities; use WorkOS\Config; use WorkOS\Sync\UserLinks; @@ -17,7 +18,8 @@ defined( 'ABSPATH' ) || exit; /** - * Admin-only REST routes under /wp-json/workos/v1/admin/users. + * Admin-only REST routes under /wp-json/workos/v1/admin/users: the user + * list, and `POST {id}/verify-email` to mark a WorkOS email verified. * * Thin proxy over `Api\Client::list_users()` — the WorkOS upstream already * paginates, filters, and authorizes. Responsibilities here are: @@ -95,6 +97,130 @@ public function register_routes(): void { ], ] ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/(?P\d+)/verify-email', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'verify_email' ], + 'permission_callback' => [ $this, 'verify_email_permission_check' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + ], + ] + ); + } + + /** + * Permission check for verify-email — `workos_verify_email` (maps to + * `edit_users`, so nobody can verify their own email by default). + * + * @return true|WP_Error + */ + public function verify_email_permission_check() { + if ( ! current_user_can( Capabilities::VERIFY_EMAIL ) ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to mark this email verified.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * POST /admin/users/{id}/verify-email — mark the linked WorkOS user's + * email verified. + * + * For support cases where the sign-in code never arrives and the person + * has been confirmed another way. A no-op when WorkOS already has the + * email verified. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function verify_email( WP_REST_Request $request ) { + $user = get_userdata( absint( $request['id'] ) ); + if ( ! $user instanceof \WP_User ) { + return new WP_Error( + 'workos_user_not_found', + __( 'User not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_user_not_linked', + __( 'This user is not linked to a WorkOS account.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $workos_user = workos()->api()->get_user( $workos_user_id ); + if ( is_wp_error( $workos_user ) ) { + return $this->upstream_error( $workos_user ); + } + + if ( ! empty( $workos_user['email_verified'] ) ) { + return new WP_REST_Response( + [ + 'ok' => true, + 'email_verified' => true, + 'already_verified' => true, + ], + 200 + ); + } + + $result = workos()->api()->update_user( $workos_user_id, [ 'email_verified' => true ] ); + if ( is_wp_error( $result ) ) { + return $this->upstream_error( $result ); + } + + // The user-edit panel caches the WorkOS user for a few minutes. + delete_transient( 'workos_profile_user_' . md5( $workos_user_id ) ); + + EventLogger::log( + 'email.admin_verified', + [ + 'user_id' => $user->ID, + 'workos_user_id' => $workos_user_id, + 'metadata' => [ + 'email' => (string) ( $workos_user['email'] ?? '' ), + 'initiator_id' => get_current_user_id(), + ], + ] + ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'email_verified' => true, + 'already_verified' => false, + ], + 200 + ); + } + + /** + * Wrap a WorkOS API failure as a 502 so the client doesn't read the + * upstream status (a WorkOS 401, say) as its own. + * + * @param WP_Error $error Error from the API client. + * + * @return WP_Error + */ + private function upstream_error( WP_Error $error ): WP_Error { + return new WP_Error( + 'workos_api_error', + $error->get_error_message(), + [ 'status' => 502 ] + ); } /** @@ -280,6 +406,7 @@ private function shape_user( array $user, string $environment_id, array $wp_user // actions they can use. A row with no linked WP user has none. 'can_send_password_reset' => $wp_user_id > 0 && current_user_can( Capabilities::SEND_PASSWORD_RESET, $wp_user_id ), 'can_change_email' => $wp_user_id > 0 && current_user_can( Capabilities::CHANGE_EMAIL, $wp_user_id ), + 'can_verify_email' => $wp_user_id > 0 && current_user_can( Capabilities::VERIFY_EMAIL ), 'email' => $email, 'email_verified' => ! empty( $user['email_verified'] ), 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Assets.php b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php index 71ca485..a24a867 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/Assets.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php @@ -95,14 +95,23 @@ public function register_assets(): void { 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/admin/users/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'strings' => [ - 'modalTitle' => __( 'Send password reset?', 'integration-workos' ), - 'modalMessage' => __( 'The user will receive a link from WorkOS to set a new password.', 'integration-workos' ), - 'modalConfirm' => __( 'Send reset email', 'integration-workos' ), - 'modalCancel' => __( 'Cancel', 'integration-workos' ), - 'sending' => __( 'Sending…', 'integration-workos' ), + 'modalTitle' => __( 'Send password reset?', 'integration-workos' ), + 'modalMessage' => __( 'The user will receive a link from WorkOS to set a new password.', 'integration-workos' ), + 'modalConfirm' => __( 'Send reset email', 'integration-workos' ), + 'modalCancel' => __( 'Cancel', 'integration-workos' ), + 'sending' => __( 'Sending…', 'integration-workos' ), /* translators: %s: masked email address (e.g. "j•••@e•••.com"). */ - 'success' => __( 'Password reset email sent to %s.', 'integration-workos' ), - 'errorGeneric' => __( 'Could not send the reset email. Please try again.', 'integration-workos' ), + 'success' => __( 'Password reset email sent to %s.', 'integration-workos' ), + 'errorGeneric' => __( 'Could not send the reset email. Please try again.', 'integration-workos' ), + // "Mark email verified" on the user-edit WorkOS panel. + 'verifyTitle' => __( 'Mark email verified?', 'integration-workos' ), + 'verifyMessage' => __( 'Only do this after confirming who the person is another way. WorkOS will treat the email as verified, so they can sign in with a password without a sign-in code.', 'integration-workos' ), + 'verifyConfirm' => __( 'Mark verified', 'integration-workos' ), + 'verifying' => __( 'Marking…', 'integration-workos' ), + 'verifySuccess' => __( 'Email marked verified in WorkOS.', 'integration-workos' ), + 'verifyAlready' => __( 'This email was already verified in WorkOS.', 'integration-workos' ), + 'verifyError' => __( 'Could not mark the email verified. Please try again.', 'integration-workos' ), + 'verifiedLabel' => __( 'Verified', 'integration-workos' ), ], ] ); diff --git a/src/WorkOS/Capabilities.php b/src/WorkOS/Capabilities.php index 2c13b76..881b58d 100644 --- a/src/WorkOS/Capabilities.php +++ b/src/WorkOS/Capabilities.php @@ -49,6 +49,9 @@ class Capabilities { /** Clear sign-in lockouts. */ public const MANAGE_LOCKOUTS = 'workos_manage_lockouts'; + /** Mark a user's WorkOS email as verified. No self-service: maps to edit_users. */ + public const VERIFY_EMAIL = 'workos_verify_email'; + /** * Plugin capability → the core capability it stands in for. * @@ -63,6 +66,7 @@ class Capabilities { self::SEND_PASSWORD_RESET => 'edit_user', self::CHANGE_EMAIL => 'edit_user', self::MANAGE_LOCKOUTS => 'edit_users', + self::VERIFY_EMAIL => 'edit_users', ]; /** diff --git a/src/js/admin-password-reset/index.ts b/src/js/admin-password-reset/index.ts index db81225..46bbe65 100644 --- a/src/js/admin-password-reset/index.ts +++ b/src/js/admin-password-reset/index.ts @@ -7,6 +7,10 @@ * shortcode. POSTs to `POST /workos/v1/admin/users/{id}/password-reset` * with the per-element `data-user-id`, `data-redirect-url`, and * `data-profile` attributes. + * + * Also handles the "Mark email verified" button (`.workos-verify-email-trigger`) + * on the user-edit WorkOS panel, which POSTs to + * `POST /workos/v1/admin/users/{id}/verify-email`. */ import { __, sprintf } from '@wordpress/i18n'; @@ -32,6 +36,14 @@ interface PasswordResetConfig { sending: string; success: string; errorGeneric: string; + verifyTitle: string; + verifyMessage: string; + verifyConfirm: string; + verifying: string; + verifySuccess: string; + verifyAlready: string; + verifyError: string; + verifiedLabel: string; }; } @@ -189,6 +201,85 @@ async function sendReset( button: HTMLElement ): Promise< void > { } } +/** + * Mark the WorkOS email of the user on a `.workos-verify-email-trigger` + * button verified. + * + * Lives here because this script is already loaded on the user-edit + * screen, where the button sits in the WorkOS panel's Email row. POSTs to + * `POST /workos/v1/admin/users/{id}/verify-email` and, on success, swaps + * the button for the same check mark the panel shows for a verified email. + * + * @param button Trigger element clicked by the operator. + */ +async function verifyEmail( button: HTMLElement ): Promise< void > { + const config = getConfig(); + if ( ! config ) { + return; + } + + const userId = Number( button.getAttribute( 'data-user-id' ) || '0' ); + if ( ! userId ) { + return; + } + + const confirmed = await confirmModal( { + title: config.strings.verifyTitle, + message: config.strings.verifyMessage, + confirmLabel: config.strings.verifyConfirm, + cancelLabel: config.strings.modalCancel, + } ); + if ( ! confirmed ) { + return; + } + + const original = button.innerHTML; + button.setAttribute( 'disabled', 'disabled' ); + button.classList.add( 'is-busy' ); + button.textContent = config.strings.verifying; + + let done = false; + try { + const response = await fetch( `${ config.restUrl }${ userId }/verify-email`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': config.nonce, + }, + } ); + + const data = ( await response.json() ) as { + already_verified?: boolean; + } & ErrorResponse; + + if ( ! response.ok ) { + showNotice( data.message || config.strings.verifyError, 'error' ); + return; + } + + const mark = document.createElement( 'span' ); + mark.style.color = '#00a32a'; + mark.title = config.strings.verifiedLabel; + mark.textContent = '✓'; + button.replaceWith( mark ); + done = true; + + showNotice( + data.already_verified ? config.strings.verifyAlready : config.strings.verifySuccess, + 'success' + ); + } catch ( _ ) { + showNotice( config.strings.verifyError, 'error' ); + } finally { + if ( ! done ) { + button.removeAttribute( 'disabled' ); + button.classList.remove( 'is-busy' ); + button.innerHTML = original; + } + } +} + /** * Document-level click listener — delegates to {@link sendReset} when the * click originates inside a `.workos-pwreset-trigger` element. @@ -213,3 +304,18 @@ document.addEventListener( 'click', ( event ) => { event.preventDefault(); void sendReset( trigger ); } ); + +/** + * Same delegation for the "Mark email verified" button. + */ +document.addEventListener( 'click', ( event ) => { + const trigger = ( event.target as HTMLElement | null )?.closest< HTMLElement >( + '.workos-verify-email-trigger' + ); + if ( ! trigger ) { + return; + } + + event.preventDefault(); + void verifyEmail( trigger ); +} ); diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index 2b316a2..c5a9017 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -16,7 +16,7 @@ import { useState, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { promptModal } from '../shared/modal'; +import { confirmModal, promptModal } from '../shared/modal'; import './styles.css'; interface AdminConfig { @@ -46,6 +46,8 @@ interface WorkosUser { can_send_password_reset: boolean; /** Whether the current admin may change this user's email. */ can_change_email: boolean; + /** Whether the current admin may mark this user's WorkOS email verified. */ + can_verify_email: boolean; email: string; email_verified: boolean; first_name: string; @@ -165,6 +167,7 @@ function App(): JSX.Element { text: string; } | null >( null ); const [ busyUserId, setBusyUserId ] = useState< number >( 0 ); + const [ verifyingUserId, setVerifyingUserId ] = useState< number >( 0 ); const [ searchInput, setSearchInput ] = useState< string >( '' ); const [ search, setSearch ] = useState< string >( '' ); const [ limit, setLimit ] = useState< number >( cfg.defaultLimit || 25 ); @@ -373,6 +376,75 @@ function App(): JSX.Element { [ cfg.changeEmailUrl, cfg.nonce ] ); + const handleVerifyEmail = useCallback( + async ( user: WorkosUser ): Promise< void > => { + setActionNotice( null ); + + const confirmed = await confirmModal( { + title: __( 'Mark email verified?', 'integration-workos' ), + message: sprintf( + /* translators: %s: the user's email address. */ + __( + 'Only do this after confirming who the person is another way. WorkOS will treat %s as verified, so they can sign in with a password without a sign-in code.', + 'integration-workos' + ), + user.email || __( 'this email', 'integration-workos' ) + ), + confirmLabel: __( 'Mark verified', 'integration-workos' ), + cancelLabel: __( 'Cancel', 'integration-workos' ), + } ); + + if ( ! confirmed ) { + return; + } + + setVerifyingUserId( user.wp_user_id ); + try { + const res = await fetch( `${ cfg.restUrl }/${ user.wp_user_id }/verify-email`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': cfg.nonce, + }, + } ); + const data = await res.json().catch( () => ( {} ) ); + + if ( ! res.ok ) { + setActionNotice( { + kind: 'error', + text: + data?.message || + __( 'Could not mark the email verified. Please try again.', 'integration-workos' ), + } ); + return; + } + + setUsers( ( prev ) => + prev.map( ( u ) => ( u.id === user.id ? { ...u, email_verified: true } : u ) ) + ); + setActionNotice( { + kind: 'success', + text: data.already_verified + ? __( 'This email was already verified in WorkOS.', 'integration-workos' ) + : sprintf( + /* translators: %s: the user's email address. */ + __( 'Marked %s verified in WorkOS.', 'integration-workos' ), + user.email + ), + } ); + } catch ( _err ) { + setActionNotice( { + kind: 'error', + text: __( 'Could not mark the email verified. Please try again.', 'integration-workos' ), + } ); + } finally { + setVerifyingUserId( 0 ); + } + }, + [ cfg.restUrl, cfg.nonce ] + ); + const hasPrev = cursorStack.current.length > 0; const hasNext = Boolean( metadata.after ); @@ -563,6 +635,22 @@ function App(): JSX.Element { : __( 'Change email', 'integration-workos' ) } ) } + { user.can_verify_email && ! user.email_verified && ( + + ) } diff --git a/tests/wpunit/AdminUsersRestApiTest.php b/tests/wpunit/AdminUsersRestApiTest.php index 7ce161f..4eed807 100644 --- a/tests/wpunit/AdminUsersRestApiTest.php +++ b/tests/wpunit/AdminUsersRestApiTest.php @@ -332,9 +332,11 @@ public function test_rows_carry_the_callers_action_permissions(): void { $this->assertTrue( $rows[0]['can_send_password_reset'] ); $this->assertTrue( $rows[0]['can_change_email'] ); + $this->assertTrue( $rows[0]['can_verify_email'] ); // No linked WP user, so there is nothing to act on. $this->assertFalse( $rows[1]['can_send_password_reset'] ); $this->assertFalse( $rows[1]['can_change_email'] ); + $this->assertFalse( $rows[1]['can_verify_email'] ); } public function test_rows_hide_actions_the_caller_lacks(): void { @@ -367,6 +369,7 @@ public function test_rows_hide_actions_the_caller_lacks(): void { $this->assertSame( $alice, $row['wp_user_id'] ); $this->assertTrue( $row['can_send_password_reset'] ); $this->assertFalse( $row['can_change_email'] ); + $this->assertFalse( $row['can_verify_email'] ); } finally { remove_role( 'workos_test_support' ); } diff --git a/tests/wpunit/AdminUsersVerifyEmailTest.php b/tests/wpunit/AdminUsersVerifyEmailTest.php new file mode 100644 index 0000000..a0e2e22 --- /dev/null +++ b/tests/wpunit/AdminUsersVerifyEmailTest.php @@ -0,0 +1,417 @@ +workos_user`, PUTs return `$this->update_response`. + * + * @covers \WorkOS\Admin\Users\RestApi + */ +class AdminUsersVerifyEmailTest extends WPTestCase { + + /** + * Captured outbound HTTP calls. + * + * @var array + */ + private array $captured = []; + + /** + * What WorkOS returns for GET /user_management/users/{id}. + * + * @var array{code: int, body: array} + */ + private array $workos_user = []; + + /** + * What WorkOS returns for PUT /user_management/users/{id}. + * + * @var array{code: int, body: array} + */ + private array $update_response = []; + + /** + * Linked subscriber, the target. + * + * @var int + */ + private int $linked_user_id = 0; + + /** + * WP user with no WorkOS link. + * + * @var int + */ + private int $unlinked_user_id = 0; + + /** + * Administrator, the usual caller. + * + * @var int + */ + private int $admin_user_id = 0; + + /** + * Set up credentials, routes, users and the HTTP mock. + */ + public function setUp(): void { + parent::setUp(); + + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'api_key' => 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + 'enable_activity_log' => true, + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + new RestApi(); + do_action( 'rest_api_init', rest_get_server() ); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + $this->linked_user_id = $this->create_user( 'subscriber' ); + $this->unlinked_user_id = $this->create_user( 'subscriber' ); + $this->admin_user_id = $this->create_user( 'administrator' ); + update_user_meta( $this->linked_user_id, '_workos_user_id', 'user_verify_01' ); + // The user-edit panel caches the WorkOS user; start each test cold. + delete_transient( 'workos_profile_user_' . md5( 'user_verify_01' ) ); + delete_transient( 'workos_profile_memberships_' . md5( 'user_verify_01' ) ); + + $this->workos_user = [ + 'code' => 200, + 'body' => [ + 'id' => 'user_verify_01', + 'email' => 'target@example.test', + 'email_verified' => false, + ], + ]; + $this->update_response = [ + 'code' => 200, + 'body' => [ + 'id' => 'user_verify_01', + 'email' => 'target@example.test', + 'email_verified' => true, + ], + ]; + + $this->captured = []; + } + + /** + * Tear down. + */ + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + remove_role( 'workos_test_support' ); + + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}workos_activity_log" ); + + wp_set_current_user( 0 ); + delete_option( 'workos_production' ); + delete_option( 'workos_active_environment' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + /** + * Insert a user. + * + * @param string $role Role slug. + * + * @return int + */ + private function create_user( string $role ): int { + $user_id = wp_insert_user( + [ + 'user_login' => 've_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 've-' . uniqid( '', true ) . '@example.test', + 'role' => $role, + ] + ); + + $this->assertIsInt( $user_id ); + + return $user_id; + } + + /** + * Route WorkOS calls to the canned responses. + * + * @param false|array $preempt Filter input. + * @param array $args Request args. + * @param string $url Request URL. + * + * @return array + */ + public function intercept_http( $preempt, array $args, string $url ): array { + $method = $args['method'] ?? 'GET'; + $this->captured[] = [ + 'url' => $url, + 'method' => $method, + 'body' => (string) ( $args['body'] ?? '' ), + ]; + + $canned = [ + 'code' => 200, + 'body' => [], + ]; + if ( str_contains( $url, '/user_management/users/user_verify_01' ) ) { + $canned = 'PUT' === $method ? $this->update_response : $this->workos_user; + } + + return [ + 'response' => [ + 'code' => $canned['code'], + 'message' => 'OK', + ], + 'body' => wp_json_encode( $canned['body'] ), + ]; + } + + /** + * POST to the endpoint. + * + * @param int $user_id WP user ID in the route. + * + * @return WP_REST_Response + */ + private function dispatch( int $user_id ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/workos/v1/admin/users/' . $user_id . '/verify-email' ); + return rest_get_server()->dispatch( $request ); + } + + /** + * Captured PUTs to WorkOS. + * + * @return array + */ + private function updates(): array { + return array_values( + array_filter( + $this->captured, + static fn( array $call ): bool => 'PUT' === $call['method'] + ) + ); + } + + /** + * Activity-log rows for the event. + * + * @return array> + */ + private function log_rows(): array { + global $wpdb; + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'email.admin_verified' + ), + ARRAY_A + ); + } + + // --------------------------------------------------------------------- + // Capability. + // --------------------------------------------------------------------- + + public function test_logged_out_is_forbidden(): void { + $this->assertSame( 403, $this->dispatch( $this->linked_user_id )->get_status() ); + $this->assertSame( [], $this->captured ); + } + + public function test_editor_is_forbidden(): void { + wp_set_current_user( $this->create_user( 'editor' ) ); + $this->captured = []; + + $this->assertSame( 403, $this->dispatch( $this->linked_user_id )->get_status() ); + $this->assertSame( [], $this->captured ); + } + + public function test_user_cannot_verify_their_own_email(): void { + wp_set_current_user( $this->linked_user_id ); + + $this->assertSame( 403, $this->dispatch( $this->linked_user_id )->get_status() ); + $this->assertSame( [], $this->captured ); + } + + public function test_role_granted_workos_verify_email_can_verify(): void { + add_role( 'workos_test_support', 'WorkOS Support', [ 'read' => true, 'workos_verify_email' => true ] ); + wp_set_current_user( $this->create_user( 'workos_test_support' ) ); + $this->captured = []; + + $this->assertSame( 200, $this->dispatch( $this->linked_user_id )->get_status() ); + $this->assertCount( 1, $this->updates() ); + } + + // --------------------------------------------------------------------- + // Target checks. + // --------------------------------------------------------------------- + + public function test_unknown_user_is_404(): void { + wp_set_current_user( $this->admin_user_id ); + + $this->assertSame( 404, $this->dispatch( 999999 )->get_status() ); + } + + public function test_unlinked_user_is_409(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( $this->unlinked_user_id ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'workos_user_not_linked', $response->get_data()['code'] ?? '' ); + $this->assertSame( [], $this->captured ); + } + + // --------------------------------------------------------------------- + // Behavior. + // --------------------------------------------------------------------- + + public function test_already_verified_is_a_no_op(): void { + wp_set_current_user( $this->admin_user_id ); + $this->workos_user['body']['email_verified'] = true; + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['email_verified'] ); + $this->assertTrue( $response->get_data()['already_verified'] ); + $this->assertSame( [], $this->updates(), 'No update when WorkOS already has it verified.' ); + $this->assertSame( [], $this->log_rows() ); + } + + public function test_marks_the_email_verified_and_logs_who_did_it(): void { + wp_set_current_user( $this->admin_user_id ); + set_transient( 'workos_profile_user_' . md5( 'user_verify_01' ), [ 'email_verified' => false ], 300 ); + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + [ + 'ok' => true, + 'email_verified' => true, + 'already_verified' => false, + ], + $response->get_data() + ); + + $updates = $this->updates(); + $this->assertCount( 1, $updates ); + $this->assertStringContainsString( '/user_management/users/user_verify_01', $updates[0]['url'] ); + $this->assertSame( [ 'email_verified' => true ], json_decode( $updates[0]['body'], true ) ); + + $rows = $this->log_rows(); + $this->assertCount( 1, $rows ); + $this->assertSame( (string) $this->linked_user_id, (string) $rows[0]['user_id'] ); + $this->assertSame( 'user_verify_01', $rows[0]['workos_user_id'] ); + $metadata = json_decode( (string) $rows[0]['metadata'], true ); + $this->assertSame( $this->admin_user_id, $metadata['initiator_id'] ?? null ); + $this->assertSame( 'target@example.test', $metadata['email'] ?? null ); + + // The user-edit panel's cached copy is dropped so it shows the change. + $this->assertFalse( get_transient( 'workos_profile_user_' . md5( 'user_verify_01' ) ) ); + } + + public function test_workos_error_on_update_is_reported(): void { + wp_set_current_user( $this->admin_user_id ); + $this->update_response = [ + 'code' => 422, + 'body' => [ 'message' => 'Email verification could not be updated.' ], + ]; + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 502, $response->get_status() ); + $this->assertSame( 'workos_api_error', $response->get_data()['code'] ?? '' ); + $this->assertStringContainsString( 'could not be updated', (string) ( $response->get_data()['message'] ?? '' ) ); + $this->assertSame( [], $this->log_rows() ); + } + + public function test_workos_error_on_lookup_is_reported(): void { + wp_set_current_user( $this->admin_user_id ); + $this->workos_user = [ + 'code' => 404, + 'body' => [ 'message' => 'User not found.' ], + ]; + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 502, $response->get_status() ); + $this->assertSame( [], $this->updates() ); + $this->assertSame( [], $this->log_rows() ); + } + + // --------------------------------------------------------------------- + // User-edit panel button. + // --------------------------------------------------------------------- + + /** + * Render the WorkOS panel for the linked user. + * + * @return string + */ + private function render_panel(): string { + ob_start(); + ( new UserProfile() )->render( get_userdata( $this->linked_user_id ) ); + return (string) ob_get_clean(); + } + + public function test_panel_shows_the_button_when_unverified(): void { + wp_set_current_user( $this->admin_user_id ); + + $html = $this->render_panel(); + + $this->assertStringContainsString( 'workos-verify-email-trigger', $html ); + $this->assertStringContainsString( 'data-user-id="' . $this->linked_user_id . '"', $html ); + } + + public function test_panel_hides_the_button_when_verified(): void { + wp_set_current_user( $this->admin_user_id ); + $this->workos_user['body']['email_verified'] = true; + + $html = $this->render_panel(); + + $this->assertStringContainsString( 'target@example.test', $html ); + $this->assertStringNotContainsString( 'workos-verify-email-trigger', $html ); + } + + public function test_panel_hides_the_button_without_the_capability(): void { + // Keeps edit_users (so the panel renders) but loses the action. + get_role( 'administrator' )->add_cap( 'workos_verify_email', false ); + + try { + wp_set_current_user( $this->admin_user_id ); + $html = $this->render_panel(); + } finally { + get_role( 'administrator' )->remove_cap( 'workos_verify_email' ); + } + + $this->assertStringContainsString( 'target@example.test', $html ); + $this->assertStringNotContainsString( 'workos-verify-email-trigger', $html ); + } +} diff --git a/tests/wpunit/CapabilitiesTest.php b/tests/wpunit/CapabilitiesTest.php index 149c148..21a8af7 100644 --- a/tests/wpunit/CapabilitiesTest.php +++ b/tests/wpunit/CapabilitiesTest.php @@ -131,6 +131,7 @@ private function checks( int $self ): array { return [ 'view users' => [ Capabilities::VIEW_USERS, 'manage_options', null ], 'manage lockouts' => [ Capabilities::MANAGE_LOCKOUTS, 'edit_users', null ], + 'verify email' => [ Capabilities::VERIFY_EMAIL, 'edit_users', null ], 'password reset, self' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $self ], 'password reset, other' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $this->other_subscriber ], 'password reset, other admin' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $this->other_admin ], @@ -162,6 +163,7 @@ public function role_provider(): array { $self_service_only = [ 'view users' => false, 'manage lockouts' => false, + 'verify email' => false, 'password reset, self' => true, 'password reset, other' => false, 'password reset, other admin' => false, @@ -242,7 +244,7 @@ public function test_logged_out_visitor_has_no_capability(): void { } /** - * The documented set is exactly these four, each with its core mapping. + * The documented set, each with its core mapping. */ public function test_map_lists_every_capability(): void { $this->assertSame( @@ -251,6 +253,7 @@ public function test_map_lists_every_capability(): void { 'workos_send_password_reset' => 'edit_user', 'workos_change_email' => 'edit_user', 'workos_manage_lockouts' => 'edit_users', + 'workos_verify_email' => 'edit_users', ], Capabilities::MAP ); @@ -282,6 +285,7 @@ public function test_role_with_only_send_password_reset(): void { $this->assertTrue( current_user_can( Capabilities::CHANGE_EMAIL, $support ), 'Self-service still comes from edit_user.' ); $this->assertFalse( current_user_can( Capabilities::VIEW_USERS ) ); $this->assertFalse( current_user_can( Capabilities::MANAGE_LOCKOUTS ) ); + $this->assertFalse( current_user_can( Capabilities::VERIFY_EMAIL ) ); // No core rights came along with it. $this->assertFalse( current_user_can( 'manage_options' ) );