diff --git a/AGENTS.md b/AGENTS.md index b3793c3..39b4498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,10 @@ Per-environment constants (take priority over generic): - `WORKOS_PRODUCTION_API_KEY`, `WORKOS_PRODUCTION_CLIENT_ID`, etc. - `WORKOS_STAGING_API_KEY`, `WORKOS_STAGING_CLIENT_ID`, etc. +### 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). + ## Key Files | File | Purpose | @@ -68,7 +72,8 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Plugin.php` | Bootstrap, constants, container init, app boot | | `src/WorkOS/App.php` | Static facade for the DI container | | `src/WorkOS/Config.php` | Centralized config with constant overrides | -| `src/WorkOS/User.php` | Public read-only helper for WorkOS metadata on WP users (`is_sso`, `has_active_session`, `get_workos_id`, `get_access_token`, `snapshot`) | +| `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/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()` | @@ -84,9 +89,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. 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` + native "Change email" button with its own modal, immediate POST, and in-place row refresh) | +| `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) | | **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 | @@ -100,7 +105,7 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Auth/PasswordReset.php` | Legacy guard — blocks WP's native password-reset flow for WorkOS-linked users so they're funneled to the WorkOS-backed reset (`PasswordResetAdmin/*`) instead. | | **Auth — Password Reset Admin** ([`docs/password-reset.md`](docs/password-reset.md)) | | | `src/WorkOS/Auth/PasswordResetAdmin/Controller.php` | DI controller — wires the REST endpoint, JS/CSS assets, WP user-row trigger, user-edit panel, and the `[workos:password-reset]` shortcode. | -| `src/WorkOS/Auth/PasswordResetAdmin/RestApi.php` | `POST /wp-json/workos/v1/admin/users/{id}/password-reset`. Gated by `current_user_can('edit_user', $id)` — the same route covers admin-of-other and self-service (WP grants `edit_user($self)` to any logged-in user). Per-IP (10/min) and per-target (5/min) rate limits via `Auth\AuthKit\RateLimiter`. Writes `password_reset.admin_sent` to the activity log. Builds the email URL via `FrontendRoute::url_for_profile()` so reset emails land on the React shell. | +| `src/WorkOS/Auth/PasswordResetAdmin/RestApi.php` | `POST /wp-json/workos/v1/admin/users/{id}/password-reset`. Gated by `current_user_can('workos_send_password_reset', $id)`, which maps to `edit_user` — the same route covers admin-of-other and self-service (WP grants `edit_user($self)` to any logged-in user). Per-IP (10/min) and per-target (5/min) rate limits via `Auth\AuthKit\RateLimiter`. Writes `password_reset.admin_sent` to the activity log. Builds the email URL via `FrontendRoute::url_for_profile()` so reset emails land on the React shell. The work after the capability check lives in `send_for_user( $user_id, $redirect_url, $profile_slug )`, which `workos_send_password_reset()` also calls. | | `src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php` | Same-host validator for `redirect_url`. Rejects cross-origin, protocol-relative (`//host`), and non-`http(s)` schemes. Falls back to `Profile::get_post_login_redirect()` then `home_url('/')`. | | `src/WorkOS/Auth/PasswordResetAdmin/RowActions.php` | "Send password reset" row action under the **WorkOS column** on `wp-admin/users.php` (hooks `workos_user_list_column_actions` filter exposed by `Admin\UserList`). | | `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). | @@ -109,7 +114,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), `…/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/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. | | `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. | @@ -196,7 +201,7 @@ Per-environment constants (take priority over generic): | **Database** | | | `src/WorkOS/Database/Schema.php` | DB schema creation and upgrades | | **Helpers** | | -| `src/includes/functions-helpers.php` | Global helpers: `workos()`, `workos_log()`, `workos_is_sso_user()`, `workos_has_active_session()`, `workos_get_user_id()`, `workos_get_access_token()` — the latter four proxy to `WorkOS\User` | +| `src/includes/functions-helpers.php` | Global helpers: `workos()`, `workos_log()`, `workos_is_sso_user()`, `workos_has_active_session()`, `workos_get_user_id()`, `workos_get_access_token()` (as stored), `workos_get_valid_access_token()`, `workos_get_logout_url()` — these proxy to `WorkOS\User` — and `workos_send_password_reset()`, which calls `PasswordResetAdmin\RestApi::send_for_user()`. Documented with examples in [`docs/php-helpers.md`](docs/php-helpers.md) | | **Browser — Custom AuthKit (TypeScript + TSX)** | | | `src/js/authkit/index.tsx` | Entry + data-* hydration | | `src/js/authkit/App.tsx` | Top-level step machine. Renders `FlowSkeleton` while `client.bootstrap()` is in-flight (replaces the prior `return null;` blank window). The `reset_confirm` step receives the `onSignedIn` + `onMfa` callbacks so the auto-login-after-reset path can navigate to the validated redirect or hand off to the MFA challenge step. | @@ -333,6 +338,7 @@ tests/ ├── AuthKitRestMfaTest.php # mfa/{challenge,verify,factors,*/enroll,factor/delete} ├── 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 ├── ConfigTest.php # Config class tests ├── DirectorySyncTest.php # Directory sync tests ├── EntitlementGateTest.php # Entitlement gate tests @@ -351,6 +357,7 @@ tests/ ├── PasswordResetAdminRestApiTest.php # `POST /admin/users/{id}/password-reset` — capability matrix, missing user, unlinked user, masked-email response, redirect_url validation, rate limit, activity-log row content ├── PasswordResetAdminRowActionsTest.php # `workos_user_list_column_actions` filter + RowActions injection (capability check, self-service, defensive empty-workos_id) ├── PluginTest.php # Plugin singleton + constants tests + ├── PublicHelpersTest.php # workos_get_valid_access_token (fresh, unlinked, signed out, expired/expiring → refresh, refresh failure, concurrent rotation), workos_send_password_reset (shared path, rate limit, redirect validation), workos_get_logout_url ├── RedirectTest.php # Login redirect tests ├── RendererKsesTest.php # Shared renderer KSES allowlist tests ├── RoleMapperTest.php # Role mapper tests diff --git a/README.md b/README.md index e28ba64..5d63995 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **Login Profiles** — admin-defined presets (enabled methods, pinned organization, signup/invite/reset toggles, MFA policy, branding) managed through a React admin editor at **WorkOS → Login Profiles** - **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). +- **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). - **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 @@ -289,7 +289,9 @@ The token is verified using WorkOS JWKS and mapped to a WordPress user via their The plugin exposes a stable, read-only API for checking WorkOS state on a WP user so integrations don't need to know what meta keys the plugin stores. Everything lives in `WorkOS\User` (instance-free static class) -with matching global function shortcuts. +with matching global function shortcuts. A few session helpers (a valid +access token, the logout URL, sending a password reset) sit alongside +them so integrations never call the WorkOS API themselves. ### `WorkOS\User` methods @@ -303,10 +305,13 @@ with matching global function shortcuts. | `User::get_session_id( $user_id = 0 )` | `string` | WorkOS `sid` claim for the active session | | `User::get_organization_id( $user_id = 0 )` | `string` | WorkOS organization id pinned to the user | | `User::snapshot( $user_id = 0 )` | `array` | All of the above in one predictable payload | +| `User::get_valid_access_token( $user_id = 0 )` | `string\|WP_Error` | Access token, refreshed first when it has expired or expires within 60s | +| `User::get_logout_url( $user_id = 0, $return_to = '' )` | `string` | WorkOS logout URL for the stored session, or `wp_logout_url()` | All methods accept `0` (or omitted) to target the currently-authenticated -user. All return empty strings / `false` safely when no user is available, -so there's no need to null-check `get_current_user_id()` first. +user. The read methods return empty strings / `false` safely when no user +is available, so there's no need to null-check `get_current_user_id()` +first. `get_valid_access_token()` returns a `WP_Error` instead. The meta keys are also exposed as constants (`User::META_WORKOS_ID`, `META_ACCESS_TOKEN`, etc.) for callers that need them in SQL queries or @@ -318,9 +323,17 @@ REST schemas. workos_is_sso_user( $user_id = 0 ); // bool — is the user linked? workos_has_active_session( $user_id = 0 ); // bool — is a session stored? workos_get_user_id( $user_id = 0 ); // string — WorkOS user id -workos_get_access_token( $user_id = 0 ); // string — current access token +workos_get_access_token( $user_id = 0 ); // string — current access token, as stored + +workos_get_valid_access_token( $user_id = 0 ); // string|WP_Error — refreshed when needed +workos_send_password_reset( $user_id, $args = [] ); // true|WP_Error — same path as the admin action +workos_get_logout_url( $user_id = 0, $return_to = '' ); // string — WorkOS or WP logout URL ``` +The last three call WorkOS (or build WorkOS URLs) through the plugin's +own client and settings. See [`docs/php-helpers.md`](docs/php-helpers.md) +for examples and error codes. + ### Distinguishing "linked" vs "currently signed in" - **`is_sso()`** remains true after the user logs out. Use it when you diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000..d4ca25d --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,77 @@ +# Capabilities + +The plugin checks its own capabilities for WorkOS user management. Each one maps to the core capability the same check used before, so out of the box nothing changes: admins can do everything they could, and everyone else can do only what they could. A site that wants a narrower role, such as support staff who can look up WorkOS users and send a password reset without being able to edit users, grants the plugin capabilities to that role. + +Code: [`src/WorkOS/Capabilities.php`](../src/WorkOS/Capabilities.php). Tests: [`tests/wpunit/CapabilitiesTest.php`](../tests/wpunit/CapabilitiesTest.php). + +## The capabilities + +| Capability | Maps to | Checked per user | What it gates | +| --- | --- | --- | --- | +| `workos_view_users` | `manage_options` | No | The **WorkOS → Users** screen and `GET /wp-json/workos/v1/admin/users` | +| `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 | + +`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. + +The PHP helper `workos_send_password_reset()` runs the same code as the route but checks no capability. Check `workos_send_password_reset` before calling it from a request; see [php-helpers.md](php-helpers.md#workos_send_password_reset). + +## 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`. +- **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). + +## How the mapping works + +The plugin hooks `map_meta_cap`: + +- If the user's roles or their own account don't mention the capability, it resolves to the core capability in the table, with the same arguments. Default roles always take this path. +- If the user holds the capability (granted through a role or on the account), that grant decides. `true` allows the action for any user; `false` denies it, even when the core capability would allow it. +- On multisite, a per-user capability granted directly still can't target a super admin unless the caller is one, the same guard core applies to `edit_user`. + +The grant is read from stored role and user capabilities. Capabilities added on the fly through the `user_has_cap` filter don't switch a check to the grant path. + +## Granting them to a custom role + +Run this once, for example on plugin activation or from a one-off WP-CLI command. Roles are stored in the database, so there is no need to run it on every request. + +```php +add_role( + 'workos_support', + 'WorkOS Support', + [ + 'read' => true, + 'workos_view_users' => true, + 'workos_send_password_reset' => true, + ] +); +``` + +A user with this role can open **WorkOS → Users**, search for a WorkOS user, and send a password reset. They can't open the plugin settings, change anyone's email, or edit users. + +To add a capability to an existing role: + +```php +get_role( 'editor' )->add_cap( 'workos_send_password_reset' ); +``` + +Or with WP-CLI: + +```bash +wp cap add editor workos_send_password_reset +``` + +To take an action away from a role that would otherwise have it: + +```php +get_role( 'administrator' )->add_cap( 'workos_change_email', false ); +``` + +### Notes for support roles + +- 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. diff --git a/docs/change-email.md b/docs/change-email.md index a545635..4dce46f 100644 --- a/docs/change-email.md +++ b/docs/change-email.md @@ -31,13 +31,13 @@ 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 (self-service) | `POST /wp-json/workos/v1/users/{id}/email-change` | WP REST nonce (`X-WP-Nonce`) + `workos_change_email($id)` (maps to `edit_user($id)`; see [capabilities](capabilities.md)) | A logged-in user changing their own address | | 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 | +| Cancel | `POST /wp-json/workos/v1/users/{id}/email-change/cancel` | Cancel **token** *or* `workos_change_email($id)` | Old-address recipient, or an admin | +| WP Users list (row action under the WorkOS column) | Posts to the initiate endpoint | WP REST nonce + `workos_change_email($id)` | Admins, in the linked-user row only | +| WorkOS → Users admin page ("Change email" action) | Posts to the initiate endpoint | WP REST nonce + `workos_change_email($id)` | Admins, on the WorkOS user list | +| User-edit / profile panel | Posts to the initiate endpoint | WP REST nonce + `workos_change_email($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 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). @@ -82,6 +82,8 @@ 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. +This check stays on `edit_users`. A custom role granted only `workos_change_email` (see [capabilities](capabilities.md)) can start a change for another account, but it goes through the verified flow, the same as self-service. + 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 | @@ -103,7 +105,7 @@ This is gated purely by capability; there is no setting to toggle it. It superse ## 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. +All endpoints live under `/wp-json/workos/v1/`. The initiate path requires the WP REST nonce **and** `workos_change_email($id)` (maps to `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. ### `POST /wp-json/workos/v1/users/{id}/email-change` — initiate @@ -153,7 +155,7 @@ See [Admin-direct vs. self-service](#admin-direct-vs-self-service) for the full | --- | --- | --- | | 400 | `workos_invalid_user` | `id` path segment is not a positive integer | | 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` | +| 403 | `workos_forbidden` | Caller lacks `workos_change_email` on the target, or `workos_change_email_can_initiate` returned `false` | | 404 | `workos_user_not_found` | No WP user with that ID | | 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 (see [Rate limiting](#rate-limiting)) — **self-service only**; admin-of-other actions bypass rate limiting | @@ -216,11 +218,11 @@ X-WP-Nonce: ```jsonc { "token": "def456…" // cancel token from the old-address notice; - // omit when relying on the edit_user capability path + // omit when relying on the workos_change_email capability path } ``` -**Auth:** EITHER a valid cancel token (from the old-address notice link) OR `edit_user` on the target. +**Auth:** EITHER a valid cancel token (from the old-address notice link) OR `workos_change_email` on the target. **200 response** @@ -234,7 +236,7 @@ If there is no pending change to cancel, the endpoint still returns `200 { ok: t | Status | Code | Cause | | --- | --- | --- | -| 403 | `workos_forbidden` | Neither a valid cancel token nor `edit_user` on the target | +| 403 | `workos_forbidden` | Neither a valid cancel token nor `workos_change_email` on the target | | 404 | `workos_user_not_found` | No WP user with that ID | --- @@ -297,7 +299,7 @@ function my_plugin_start_workos_email_change( int $wp_user_id, string $new_email return new WP_Error( 'forbidden', 'Must be logged in.' ); } - if ( ! current_user_can( 'edit_user', $wp_user_id ) ) { + if ( ! current_user_can( 'workos_change_email', $wp_user_id ) ) { return new WP_Error( 'forbidden', 'You do not have permission to edit this user.' ); } @@ -560,7 +562,7 @@ wp_localize_script( [workos:change-email redirect_url="/welcome"] - + [workos:change-email user="42"] [workos:change-email user="jane@example.com"] @@ -572,7 +574,7 @@ The shortcode silently renders nothing when: - No `user` attribute and the viewer is logged out. - The target user is not linked to WorkOS (no `_workos_user_id` meta). -- The viewer lacks `edit_user` on the target. +- The viewer lacks `workos_change_email` on the target. ## Hooks diff --git a/docs/password-reset.md b/docs/password-reset.md index 3bb78de..32c6904 100644 --- a/docs/password-reset.md +++ b/docs/password-reset.md @@ -14,8 +14,8 @@ It is written for two audiences in parallel: a developer integrating against the | --- | --- | --- | --- | | Self-service start | `POST /wp-json/workos/v1/auth/password/reset/start` | Profile-scoped nonce | Anyone (anonymous OK) | | Self-service confirm | `POST /wp-json/workos/v1/auth/password/reset/confirm` | Profile-scoped nonce | Anyone with a valid reset token | -| Admin-triggered | `POST /wp-json/workos/v1/admin/users/{id}/password-reset` | WP REST nonce + `edit_user($id)` capability | Editors / admins (also covers self-service from a logged-in context) | -| WP Users list (row action under the WorkOS column) | Posts to the admin endpoint | WP REST nonce + `edit_user($id)` | Admins, in the linked-user row only | +| Admin-triggered | `POST /wp-json/workos/v1/admin/users/{id}/password-reset` | WP REST nonce + `workos_send_password_reset($id)` (maps to `edit_user($id)`; see [capabilities](capabilities.md)) | Editors / admins (also covers self-service from a logged-in context) | +| WP Users list (row action under the WorkOS column) | Posts to the admin endpoint | WP REST nonce + `workos_send_password_reset($id)` | Admins, in the linked-user row only | | Shortcode | `[workos:password-reset]` | Rendered server-side; uses admin endpoint | Page authors | All three converge on the same WorkOS API call (`POST /user_management/password_reset/send`), and all three honor the same `redirect_url` and profile policy (`password_reset_flow`, `auto_login_after_reset`). @@ -176,7 +176,7 @@ X-WorkOS-Nonce: ### Admin: `POST /wp-json/workos/v1/admin/users/{id}/password-reset` -Sends a reset email on behalf of any user the caller has `edit_user($id)` capability on. WordPress grants that capability on one's own user ID, so the same endpoint covers both **admin-of-another** and **self-service** call paths. +Sends a reset email on behalf of any user the caller has `workos_send_password_reset($id)` on. That capability maps to `edit_user($id)` (see [capabilities](capabilities.md)), which WordPress grants on one's own user ID, so the same endpoint covers both **admin-of-another** and **self-service** call paths. **Headers** @@ -213,7 +213,7 @@ X-WP-Nonce: | --- | --- | --- | | 400 | `workos_invalid_user` | `id` path segment is not a positive integer | | 400 | `workos_reset_disabled` | Profile has `password_reset_flow=false` | -| 403 | `workos_forbidden` | Caller lacks `edit_user` on the target | +| 403 | `workos_forbidden` | Caller lacks `workos_send_password_reset` on the target | | 404 | `workos_user_not_found` | No WP user with that ID | | 409 | `workos_user_not_linked` | WP user exists but has no `_workos_user_id` meta | | 409 | `workos_user_missing_email` | WP user has no usable email address | @@ -275,6 +275,8 @@ The validation runs on every endpoint that accepts `redirect_url` (start, confir Use this when your own plugin or theme needs to trigger a reset programmatically (e.g. from a custom REST endpoint, a WP-CLI command, or an admin action handler). +The shortest way is `workos_send_password_reset( $wp_user_id, [ 'redirect_url' => … ] )`. It runs the same code as the endpoint (rate limits, profile gating, `redirect_url` validation, activity log) without a REST round-trip, and returns `true` or the endpoint's `WP_Error`. It leaves the `workos_send_password_reset` check to you. See [`php-helpers.md`](php-helpers.md#workos_send_password_reset). The example below goes through the endpoint instead, which also does the capability check. + ```php api()->send_password_reset()` directly? Three reasons: -1. The endpoint enforces `edit_user`, rate limits, profile gating, and `redirect_url` validation in one place. Bypassing it duplicates that surface area in your code. +1. The endpoint enforces `workos_send_password_reset`, rate limits, profile gating, and `redirect_url` validation in one place. Bypassing it duplicates that surface area in your code. 2. It writes the `password_reset.admin_sent` activity-log row — so audit history stays consistent. 3. It builds the URL via `FrontendRoute::url_for_profile()`, picking up any host-side `home_url` filter that escapes ampersands. Skipping that machinery is exactly how broken reset links land in inboxes. @@ -549,7 +551,7 @@ wp_localize_script( **Modes** -- **Admin-of-other:** `user="42"` — renders only when the viewer has `edit_user(42)`. +- **Admin-of-other:** `user="42"` — renders only when the viewer has `workos_send_password_reset(42)`. - **Self-service:** no `user` attribute — renders for the current logged-in user only. In both cases the click POSTs to the admin endpoint with the WP REST nonce. @@ -583,7 +585,7 @@ This section catalogs the failure modes we've seen people hit. If your integrati workos()->api()->send_password_reset( $email, 'https://example.com/whatever' ); ``` -The URL you hand WorkOS gets emailed verbatim. Get the host wrong and the email link is dead. Skip the entity-decode and a host-side `home_url` filter will silently break it. Always route through `POST /admin/users/{id}/password-reset` or `POST /auth/password/reset/start`. +The URL you hand WorkOS gets emailed verbatim. Get the host wrong and the email link is dead. Skip the entity-decode and a host-side `home_url` filter will silently break it. Always route through `POST /admin/users/{id}/password-reset`, `POST /auth/password/reset/start`, or `workos_send_password_reset()` from PHP. ### ❌ Don't store reset tokens or replay them. diff --git a/docs/php-helpers.md b/docs/php-helpers.md new file mode 100644 index 0000000..9f1ffd2 --- /dev/null +++ b/docs/php-helpers.md @@ -0,0 +1,110 @@ +# PHP helpers + +Global functions for themes and plugins that build their own account pages on top of integration-workos. Each one is a thin wrapper over code the plugin already runs, so it uses the plugin's API client, keys and active environment. Don't call the WorkOS API yourself with the client secret, don't hard-code `https://api.workos.com`, and don't decode the access token to find its expiry or session ID. + +| Function | Returns | What it does | +| --- | --- | --- | +| `workos_get_user_id( int $user_id = 0 )` | `string` | WorkOS user ID (`user_…`) linked to the WP user, or `''` | +| `workos_get_access_token( int $user_id = 0 )` | `string` | Access token as stored. May be expired. Never calls WorkOS | +| `workos_get_valid_access_token( int $user_id = 0 )` | `string\|WP_Error` | Access token that is good for at least 60 more seconds, refreshed first if needed | +| `workos_send_password_reset( int $user_id, array $args = [] )` | `true\|WP_Error` | Sends the WorkOS reset email through the admin "Send password reset" path | +| `workos_get_logout_url( int $user_id = 0, string $return_to = '' )` | `string` | WorkOS logout URL for the current session, or `wp_logout_url()` | + +`$user_id = 0` (or omitted) means the current user. Each function has a `WorkOS\User` counterpart except `workos_send_password_reset()`, which calls `WorkOS\Auth\PasswordResetAdmin\RestApi::send_for_user()`. + +The other helpers (`workos_is_sso_user()`, `workos_has_active_session()`) are covered in the README under "Helpers for Third-Party Integrations". + +## `workos_get_valid_access_token()` + +Returns the stored access token while it has more than `WorkOS\User::ACCESS_TOKEN_REFRESH_LEEWAY` (60) seconds left. Otherwise it exchanges the stored refresh token with WorkOS and stores the new pair, the same calls `POST /wp-json/workos/v1/auth/session/refresh` makes. A refresh rotates both tokens, so any copy you kept of the old ones stops working. + +```php +$token = workos_get_valid_access_token(); +if ( is_wp_error( $token ) ) { + return $token; +} + +$response = wp_remote_get( + 'https://example.com/api/me', + [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] +); +``` + +Errors: + +| Code | When | +| --- | --- | +| `workos_user_not_linked` | No user, or the user has no WorkOS link | +| `workos_no_session` | The user signed out (no tokens stored), or the token expired and there is no refresh token | +| `workos_refresh_failed` | WorkOS answered the refresh without an access token | +| `workos_api_error` (or a transport error) | WorkOS refused the refresh, for example because the session was revoked. The stored tokens are left as they were | + +If two requests refresh at once, the second one's refresh token is already spent. When that happens and the first one has stored a fresh token, the helper returns that token instead of the error. + +The expiry is read from the stored token without checking its signature. That's fine here: the token was stored server-side from a WorkOS response, and the result only decides whether to refresh. Use `workos()->api()->verify_access_token()` when you need to trust a token, and only pass a user ID your own code has authenticated. + +## `workos_send_password_reset()` + +Runs the same code as `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (see [password-reset.md](password-reset.md)): + +- The user must exist, be linked to WorkOS and have a valid email. +- The Login Profile must have the password reset flow on. +- The per-IP (10 a minute) and per-user (5 a minute) limits apply, shared with the REST route. +- `redirect_url` goes through `RedirectValidator`: a URL on another host falls back to the profile's post-login redirect, then the home page. +- A `password_reset.admin_sent` row is written to the activity log. + +`$args`: + +| Key | Default | | +| --- | --- | --- | +| `redirect_url` | `''` | Where the user lands after setting the new password | +| `profile` | `'default'` | Login Profile slug whose reset page the email links to | + +The helper does not check capabilities; the REST route does. Check `workos_send_password_reset` yourself when the call comes from a request. It maps to `edit_user` unless a role is granted it directly (see [capabilities.md](capabilities.md)): + +```php +add_action( 'admin_post_my_account_reset_password', static function () { + check_admin_referer( 'my_account_reset_password' ); + + $user_id = get_current_user_id(); + if ( ! current_user_can( 'workos_send_password_reset', $user_id ) ) { + wp_die( 'Not allowed.', 403 ); + } + + $sent = workos_send_password_reset( $user_id, [ 'redirect_url' => home_url( '/account/' ) ] ); + if ( is_wp_error( $sent ) ) { + wp_die( esc_html( $sent->get_error_message() ), 400 ); + } + + wp_safe_redirect( home_url( '/account/?reset=sent' ) ); + exit; +} ); +``` + +It returns the REST route's errors: `workos_user_not_found`, `workos_user_not_linked`, `workos_user_missing_email`, `workos_profile_not_found`, `workos_reset_disabled`, `workos_rate_limited`, or the WorkOS API error. + +## `workos_get_logout_url()` + +With a stored WorkOS session, returns WorkOS's logout URL for it: + +``` +https://api.workos.com/user_management/sessions/logout?session_id=session_…&return_to=… +``` + +`return_to` is left out when empty. WorkOS only redirects to URLs on the environment's sign-out redirect list (WorkOS dashboard, Redirects). With no stored session (not linked, or already signed out) it returns `wp_logout_url( $return_to )`. + +The two URLs end different things: + +- `wp_logout_url()` signs the user out of WordPress. The plugin then revokes the WorkOS session server-side and clears the stored tokens, so this ends both. +- The WorkOS URL sends the browser to WorkOS to end the WorkOS session. It doesn't clear the WordPress auth cookie. + +```php +// Ends the WorkOS session, then comes back to the home page. +printf( + '%s', + esc_url( workos_get_logout_url( 0, home_url( '/' ) ) ), + esc_html__( 'Sign out', 'my-theme' ) +); +``` + +Building the URL never calls WorkOS. diff --git a/src/WorkOS/Admin/Users/AdminPage.php b/src/WorkOS/Admin/Users/AdminPage.php index f6b6c67..2e9d4c0 100644 --- a/src/WorkOS/Admin/Users/AdminPage.php +++ b/src/WorkOS/Admin/Users/AdminPage.php @@ -7,6 +7,7 @@ namespace WorkOS\Admin\Users; +use WorkOS\Capabilities; use WorkOS\Config; defined( 'ABSPATH' ) || exit; @@ -45,7 +46,7 @@ public function register_menu(): void { 'workos', __( 'WorkOS Users', 'integration-workos' ), __( 'Users', 'integration-workos' ), - 'manage_options', + Capabilities::VIEW_USERS, self::MENU_SLUG, [ $this, 'render' ] ); diff --git a/src/WorkOS/Admin/Users/Controller.php b/src/WorkOS/Admin/Users/Controller.php index 76f7501..cc2deed 100644 --- a/src/WorkOS/Admin/Users/Controller.php +++ b/src/WorkOS/Admin/Users/Controller.php @@ -14,7 +14,8 @@ * supporting REST endpoint. Always active — the REST routes need to be * registered during `rest_api_init` regardless of admin context, and the * AdminPage is gated internally via the `admin_menu` / `admin_enqueue_scripts` - * hooks plus a `manage_options` capability check. + * hooks plus a `workos_view_users` capability check (maps to + * `manage_options`; see {@see \WorkOS\Capabilities}). */ class Controller extends BaseController { diff --git a/src/WorkOS/Admin/Users/RestApi.php b/src/WorkOS/Admin/Users/RestApi.php index ce69400..010b186 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\Capabilities; use WorkOS\Config; use WorkOS\Sync\UserLinks; use WP_Error; @@ -21,7 +22,7 @@ * Thin proxy over `Api\Client::list_users()` — the WorkOS upstream already * paginates, filters, and authorizes. Responsibilities here are: * - * 1. `manage_options` enforcement, + * 1. `workos_view_users` enforcement (maps to `manage_options`), * 2. parameter sanitization, * 3. enrichment of each user record with a `dashboard_url` so the React * side doesn't reconstruct it, @@ -97,12 +98,12 @@ public function register_routes(): void { } /** - * Permission check — admin capability required. + * Permission check — `workos_view_users` (maps to `manage_options`). * * @return true|WP_Error */ public function permission_check() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( Capabilities::VIEW_USERS ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to manage WorkOS users.', 'integration-workos' ), @@ -273,16 +274,20 @@ private function shape_user( array $user, string $environment_id, array $wp_user $wp_user_id = '' !== $id ? (int) ( $wp_user_ids[ $id ] ?? 0 ) : 0; return [ - 'id' => $id, - 'wp_user_id' => $wp_user_id, - 'email' => $email, - 'email_verified' => ! empty( $user['email_verified'] ), - 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', - 'last_name' => isset( $user['last_name'] ) ? (string) $user['last_name'] : '', - 'last_sign_in_at' => isset( $user['last_sign_in_at'] ) ? (string) $user['last_sign_in_at'] : '', - 'created_at' => isset( $user['created_at'] ) ? (string) $user['created_at'] : '', - 'updated_at' => isset( $user['updated_at'] ) ? (string) $user['updated_at'] : '', - 'dashboard_url' => $dashboard_url, + 'id' => $id, + 'wp_user_id' => $wp_user_id, + // What the caller may do with this row, so the screen only shows + // 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 ), + 'email' => $email, + 'email_verified' => ! empty( $user['email_verified'] ), + 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', + 'last_name' => isset( $user['last_name'] ) ? (string) $user['last_name'] : '', + 'last_sign_in_at' => isset( $user['last_sign_in_at'] ) ? (string) $user['last_sign_in_at'] : '', + 'created_at' => isset( $user['created_at'] ) ? (string) $user['created_at'] : '', + 'updated_at' => isset( $user['updated_at'] ) ? (string) $user['updated_at'] : '', + 'dashboard_url' => $dashboard_url, ]; } } diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php index d2044c6..1f72dca 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -11,6 +11,7 @@ use WorkOS\RateLimit\TieredRateLimiter; use WorkOS\Email\AddressMask; use WorkOS\Http\ClientIp; +use WorkOS\Capabilities; use WP_Error; use WP_REST_Request; use WP_REST_Response; @@ -23,9 +24,10 @@ * * - `POST users/{id}/email-change` → initiate * - `POST users/{id}/email-change/confirm` → confirm (consumes token) - * - `POST users/{id}/email-change/cancel` → cancel (consumes cancel token OR uses edit_user) + * - `POST users/{id}/email-change/cancel` → cancel (consumes cancel token OR uses workos_change_email) * - * Auth model mirrors PasswordResetAdmin: `edit_user` on the target, + * Auth model mirrors PasswordResetAdmin: `workos_change_email` on the + * target, which maps to `edit_user` (see {@see \WorkOS\Capabilities}), * which the WP cap mapper grants both for self (the user's own ID) and * for admins acting on another account. * @@ -190,7 +192,7 @@ public function register_routes(): void { [ 'methods' => 'POST', 'callback' => [ $this, 'cancel' ], - // Permission may be granted via edit_user OR a valid cancel + // Permission may be granted via workos_change_email OR a valid cancel // token; the handler resolves both paths. 'permission_callback' => '__return_true', 'args' => [ @@ -202,7 +204,7 @@ public function register_routes(): void { } /** - * Permission callback for initiate — `edit_user` on the target. + * Permission callback for initiate — `workos_change_email` on the target. * * @param WP_REST_Request $request REST request. * @@ -218,7 +220,7 @@ public function check_permission_initiate( WP_REST_Request $request ) { ); } - if ( ! current_user_can( 'edit_user', $target_id ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $target_id ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to change this user’s email.', 'integration-workos' ), @@ -631,7 +633,7 @@ public function cancel( WP_REST_Request $request ) { $token = (string) $request->get_param( 'token' ); $by_token = '' !== $token && $this->pending->verify_cancel( $record, $token ); - $by_capability = current_user_can( 'edit_user', $user->ID ); + $by_capability = current_user_can( Capabilities::CHANGE_EMAIL, $user->ID ); if ( ! $by_token && ! $by_capability ) { return new WP_Error( diff --git a/src/WorkOS/Auth/ChangeEmail/RowActions.php b/src/WorkOS/Auth/ChangeEmail/RowActions.php index 6d07ce8..9f7cd5f 100644 --- a/src/WorkOS/Auth/ChangeEmail/RowActions.php +++ b/src/WorkOS/Auth/ChangeEmail/RowActions.php @@ -8,6 +8,8 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -44,7 +46,7 @@ public function add_action( array $actions, int $user_id, string $workos_id ): a return $actions; } - if ( ! current_user_can( 'edit_user', $user_id ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $user_id ) ) { return $actions; } diff --git a/src/WorkOS/Auth/ChangeEmail/Shortcode.php b/src/WorkOS/Auth/ChangeEmail/Shortcode.php index 3cbd2f2..84c5303 100644 --- a/src/WorkOS/Auth/ChangeEmail/Shortcode.php +++ b/src/WorkOS/Auth/ChangeEmail/Shortcode.php @@ -7,6 +7,8 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -15,7 +17,7 @@ * Modes: * * - `user=""` — admin-of-other mode. Visible only to viewers - * with `edit_user` on the target. + * with `workos_change_email` on the target (maps to `edit_user`). * - no `user` attribute — self-service: the form pre-targets the * logged-in user. * @@ -64,7 +66,7 @@ public function render( $atts ): string { return ''; } - if ( ! current_user_can( 'edit_user', $target ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $target ) ) { return ''; } diff --git a/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php index be3180e..7085d44 100644 --- a/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php +++ b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php @@ -7,6 +7,7 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; use WP_User; defined( 'ABSPATH' ) || exit; @@ -39,7 +40,7 @@ public function register(): void { * @return void */ public function render( WP_User $user ): void { - if ( ! current_user_can( 'edit_user', $user->ID ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $user->ID ) ) { return; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php index 2c4c079..696ceed 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -13,6 +13,7 @@ use WorkOS\RateLimit\TieredRateLimiter; use WorkOS\Email\AddressMask; use WorkOS\Http\ClientIp; +use WorkOS\Capabilities; use WP_Error; use WP_REST_Request; use WP_REST_Response; @@ -24,7 +25,8 @@ * `POST /workos/v1/admin/users/{id}/password-reset`. * * Triggers a WorkOS password-reset email on behalf of another user. - * Requires `edit_user` on the target — which is true for an admin acting + * Requires `workos_send_password_reset` on the target, which maps to + * `edit_user` (see {@see \WorkOS\Capabilities}) — true for an admin acting * on someone else and *also* true for any logged-in user acting on their * own ID (WP's default mapping). One endpoint, two valid call paths. * @@ -125,9 +127,10 @@ public function register_routes(): void { } /** - * Permission callback — `edit_user` on the target user. + * Permission callback — `workos_send_password_reset` on the target user. * - * Granular by design: this is the WP capability that maps to "can edit + * Maps to `edit_user` unless a role is granted the capability directly. + * Granular by design: `edit_user` is the WP capability for "can edit * the user with this ID", which is true for the user themselves (so * self-service via the shortcode works) and true for admins/editors * with the broader `edit_users` cap acting on others. @@ -146,7 +149,7 @@ public function check_permission( WP_REST_Request $request ) { ); } - if ( ! current_user_can( 'edit_user', $target_id ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $target_id ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to send a password reset for this user.', 'integration-workos' ), @@ -165,8 +168,36 @@ public function check_permission( WP_REST_Request $request ) { * @return WP_REST_Response|WP_Error */ public function send_reset( WP_REST_Request $request ) { - $target_id = absint( $request['id'] ); - $user = get_userdata( $target_id ); + $result = $this->send_for_user( + absint( $request['id'] ), + (string) $request->get_param( 'redirect_url' ), + (string) $request->get_param( 'profile' ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * Send a WorkOS reset email for a WP user. + * + * The work behind the REST route, minus the capability check: the route + * checks `workos_send_password_reset` before calling this, and `workos_send_password_reset()` + * leaves that to its caller. Everything else (linked-user and email + * checks, profile policy, rate limits, redirect validation, the activity + * log row) is shared. + * + * @param int $target_id WP user ID. + * @param string $redirect_url Optional post-reset redirect; checked by RedirectValidator. + * @param string $profile_slug Optional Login Profile slug; defaults to the default profile. + * + * @return array{ok: true, email_hint: string, profile: string, redirect_url: string}|WP_Error + */ + public function send_for_user( int $target_id, string $redirect_url = '', string $profile_slug = '' ) { + $user = get_userdata( $target_id ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'workos_user_not_found', @@ -193,7 +224,7 @@ public function send_reset( WP_REST_Request $request ) { ); } - $profile = $this->resolve_profile( (string) $request->get_param( 'profile' ) ); + $profile = $this->resolve_profile( $profile_slug ); if ( ! $profile instanceof Profile ) { return $profile; } @@ -207,9 +238,9 @@ public function send_reset( WP_REST_Request $request ) { } $ip = $this->client_ip->get(); - // No send policy here: this route needs `edit_user` on the target and - // mails that account's own address, so the recipient is never - // caller-chosen. + // No send policy here: this route needs `workos_send_password_reset` + // on the target and mails that account's own address, so the + // recipient is never caller-chosen. $rate_ok = $this->rate_limiter->attempt( 'pw_reset_admin_ip', $ip, TieredRateLimiter::REQUEST_IP ); if ( is_wp_error( $rate_ok ) ) { return $rate_ok; @@ -219,10 +250,7 @@ public function send_reset( WP_REST_Request $request ) { return $rate_ok; } - $redirect_url = $this->redirect_validator->validate( - (string) $request->get_param( 'redirect_url' ), - $profile - ); + $redirect_url = $this->redirect_validator->validate( $redirect_url, $profile ); $reset_url = $this->build_reset_url( $profile, $redirect_url ); @@ -246,15 +274,12 @@ public function send_reset( WP_REST_Request $request ) { ] ); - return new WP_REST_Response( - [ - 'ok' => true, - 'email_hint' => $this->masker->mask( $email ), - 'profile' => $profile->get_slug(), - 'redirect_url' => $redirect_url, - ], - 200 - ); + return [ + 'ok' => true, + 'email_hint' => $this->masker->mask( $email ), + 'profile' => $profile->get_slug(), + 'redirect_url' => $redirect_url, + ]; } /** diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php index 3f29c8f..dc5c19a 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php @@ -8,6 +8,8 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -53,7 +55,7 @@ public function add_action( array $actions, int $user_id, string $workos_id ): a return $actions; } - if ( ! current_user_can( 'edit_user', $user_id ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $user_id ) ) { return $actions; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php index d6db52d..a0d1fce 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php @@ -7,6 +7,8 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -15,7 +17,8 @@ * Two modes, picked by the `user` attribute: * * - `user=""` — admin-of-other mode. Visible only to viewers - * with `edit_user` on the target. Useful on internal dashboards. + * with `workos_send_password_reset` on the target (maps to + * `edit_user`). Useful on internal dashboards. * * - no `user` attribute — self-service mode. Visible to any logged-in * user; pre-targets their own account. @@ -65,7 +68,7 @@ public function render( $atts ): string { return ''; } - if ( ! current_user_can( 'edit_user', $target ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $target ) ) { return ''; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php index 6f876df..6650d2f 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php @@ -7,6 +7,7 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; use WP_User; defined( 'ABSPATH' ) || exit; @@ -40,7 +41,7 @@ public function register(): void { * @return void */ public function render( WP_User $user ): void { - if ( ! current_user_can( 'edit_user', $user->ID ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $user->ID ) ) { return; } diff --git a/src/WorkOS/Capabilities.php b/src/WorkOS/Capabilities.php new file mode 100644 index 0000000..2c13b76 --- /dev/null +++ b/src/WorkOS/Capabilities.php @@ -0,0 +1,150 @@ + true, + * 'workos_view_users' => true, + * 'workos_send_password_reset' => true, + * ] ); + * ``` + * + * Once a user holds a plugin capability (through a role or on their own + * account), that grant decides the check instead of the core mapping. + * Setting it to `false` takes the action away even from a role whose + * core capabilities would allow it. + * + * See docs/capabilities.md. + */ +class Capabilities { + + /** Open the WorkOS → Users screen and its list endpoint. */ + public const VIEW_USERS = 'workos_view_users'; + + /** Send a WorkOS password-reset email for a user (checked per user). */ + public const SEND_PASSWORD_RESET = 'workos_send_password_reset'; + + /** Start or cancel an email change for a user (checked per user). */ + public const CHANGE_EMAIL = 'workos_change_email'; + + /** Clear sign-in lockouts. */ + public const MANAGE_LOCKOUTS = 'workos_manage_lockouts'; + + /** + * Plugin capability → the core capability it stands in for. + * + * `edit_user` is a per-user meta capability: every logged-in user has + * it for their own account, which is what keeps self-service password + * reset and email change working. + * + * @var array + */ + public const MAP = [ + self::VIEW_USERS => 'manage_options', + self::SEND_PASSWORD_RESET => 'edit_user', + self::CHANGE_EMAIL => 'edit_user', + self::MANAGE_LOCKOUTS => 'edit_users', + ]; + + /** + * Register the `map_meta_cap` filter. + * + * @return void + */ + public function register(): void { + add_filter( 'map_meta_cap', [ $this, 'map_meta_cap' ], 10, 4 ); + } + + /** + * Resolve a plugin capability to the primitive capabilities it needs. + * + * @param string[] $caps Primitive capabilities so far. + * @param string $cap Capability being checked. + * @param int $user_id User being checked. + * @param array $args Extra arguments (the target user ID for the per-user capabilities). + * + * @return string[] + */ + public function map_meta_cap( $caps, $cap, $user_id, $args ): array { + $caps = (array) $caps; + + if ( ! is_string( $cap ) || ! isset( self::MAP[ $cap ] ) ) { + return $caps; + } + + $user_id = (int) $user_id; + $args = array_values( (array) $args ); + + if ( $user_id > 0 && $this->is_set_on_user( $cap, $user_id ) ) { + return $this->granted_caps( $cap, $user_id, $args ); + } + + return map_meta_cap( self::MAP[ $cap ], $user_id, ...$args ); + } + + /** + * Caps required when the user holds the plugin capability directly. + * + * The user's own grant decides. On multisite, a per-user capability + * still can't target a super admin unless the caller is one, the same + * guard core applies to `edit_user`. + * + * @param string $cap Plugin capability. + * @param int $user_id User being checked. + * @param array $args Extra arguments. + * + * @return string[] + */ + private function granted_caps( string $cap, int $user_id, array $args ): array { + $target = isset( $args[0] ) ? (int) $args[0] : 0; + + if ( + 'edit_user' === self::MAP[ $cap ] + && is_multisite() + && $target > 0 + && $target !== $user_id + && is_super_admin( $target ) + && ! is_super_admin( $user_id ) + ) { + return [ 'do_not_allow' ]; + } + + return [ $cap ]; + } + + /** + * Whether the user's roles or own capabilities mention the capability. + * + * Reads the stored capabilities, so a grant of `false` counts as set + * (and denies). + * + * @param string $cap Plugin capability. + * @param int $user_id User being checked. + * + * @return bool + */ + private function is_set_on_user( string $cap, int $user_id ): bool { + $user = get_current_user_id() === $user_id ? wp_get_current_user() : get_userdata( $user_id ); + + return $user instanceof WP_User && array_key_exists( $cap, $user->allcaps ); + } +} diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index d46772b..fedfaa7 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -38,6 +38,11 @@ class Controller extends BaseController { * @return void */ protected function doRegister(): void { + // Plugin capabilities resolve on every request (admin, REST, front + // end shortcodes), so map them before any feature checks one. + $this->container->singleton( Capabilities::class ); + $this->container->get( Capabilities::class )->register(); + // Ahead of the features that resolve these at construction. $this->container->register( HttpController::class ); $this->container->register( EmailController::class ); diff --git a/src/WorkOS/User.php b/src/WorkOS/User.php index fe6ddda..8b69e53 100644 --- a/src/WorkOS/User.php +++ b/src/WorkOS/User.php @@ -30,6 +30,15 @@ * Neither method verifies the access token is still valid against * WorkOS's JWKS — use `Api\Client::verify_access_token()` for that. * + * ### Session helpers + * + * Two methods go beyond reading meta: + * + * - {@see self::get_valid_access_token()} returns a token that hasn't + * expired, refreshing it (and rotating the stored tokens) when needed. + * - {@see self::get_logout_url()} builds the WorkOS logout URL for the + * stored session, or falls back to `wp_logout_url()`. + * * ### Example usage * * ```php @@ -52,7 +61,8 @@ * * ```php * if ( workos_is_sso_user() ) { ... } - * $token = workos_get_access_token( $user_id ); + * $token = workos_get_access_token( $user_id ); // As stored; may be expired. + * $token = workos_get_valid_access_token( $user_id ); // Refreshed when needed; may be WP_Error. * ``` */ final class User { @@ -84,6 +94,12 @@ final class User { /** User meta key — flag indicating this WP user's first login via WorkOS. */ public const META_FIRST_LOGIN = '_workos_first_login'; + /** + * Seconds before expiry at which {@see self::get_valid_access_token()} + * refreshes. Matches the React shell's pre-emptive refresh. + */ + public const ACCESS_TOKEN_REFRESH_LEEWAY = 60; + /** * Whether the user is linked to a WorkOS identity. * @@ -141,6 +157,114 @@ public static function get_access_token( int $user_id = 0 ): string { return self::meta_string( $user_id, self::META_ACCESS_TOKEN ); } + /** + * Get an access token for the user that is good for at least + * {@see self::ACCESS_TOKEN_REFRESH_LEEWAY} more seconds. + * + * Returns the stored token while it is fresh. When it has expired, is + * about to, or its expiry can't be read, the stored refresh token is + * exchanged through `Api\Client::refresh_session()` and the new pair is + * stored with `Auth\Login::store_tokens()`, the same calls the + * `POST /auth/session/refresh` endpoint makes. Unlike + * {@see self::get_access_token()}, this may call WorkOS and rotate the + * stored tokens. + * + * The expiry is read from the stored token without checking its + * signature: the token came from WorkOS and was stored server-side, and + * the result only decides whether to refresh. Only pass a user ID your + * own code trusts, never one taken from an unauthenticated request. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string|\WP_Error Access token (JWT), or an error: + * `workos_user_not_linked` (no WorkOS link or no user), + * `workos_no_session` (signed out, or expired with no refresh token), + * `workos_refresh_failed` (WorkOS returned no token), + * or the API error from the refresh call. + */ + public static function get_valid_access_token( int $user_id = 0 ) { + $resolved = self::resolve_user_id( $user_id ); + if ( 0 === $resolved || ! self::is_sso( $resolved ) ) { + return new \WP_Error( + 'workos_user_not_linked', + __( 'This user is not linked to a WorkOS account.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $access_token = self::get_access_token( $resolved ); + if ( '' !== $access_token && ! self::access_token_needs_refresh( $access_token ) ) { + return $access_token; + } + + $refresh_token = self::get_refresh_token( $resolved ); + if ( '' === $refresh_token ) { + return new \WP_Error( + 'workos_no_session', + __( 'This user has no active WorkOS session.', 'integration-workos' ), + [ 'status' => 401 ] + ); + } + + $response = workos()->api()->refresh_session( $refresh_token ); + + if ( ! is_wp_error( $response ) && ( empty( $response['access_token'] ) || ! is_string( $response['access_token'] ) ) ) { + $response = new \WP_Error( + 'workos_refresh_failed', + __( 'WorkOS did not return a new access token.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + + if ( is_wp_error( $response ) ) { + // Another request may have rotated the tokens while this one + // was waiting, which spends the refresh token we sent. Use its + // result if it left a fresh token behind. + $latest = self::get_access_token( $resolved ); + if ( '' !== $latest && $latest !== $access_token && ! self::access_token_needs_refresh( $latest ) ) { + return $latest; + } + + return $response; + } + + Auth\Login::store_tokens( $resolved, $response ); + + return $response['access_token']; + } + + /** + * Get the URL that ends the user's current WorkOS session. + * + * Points at WorkOS's `GET /user_management/sessions/logout` for the + * stored session ID, with `return_to` when given. WorkOS only redirects + * to `return_to` values on the environment's sign-out redirect list. + * With no stored session (not linked, or signed out) this returns + * `wp_logout_url( $return_to )`. + * + * The WorkOS URL ends the WorkOS session only; the WordPress auth + * cookie is untouched. `wp_logout_url()` already ends both, because the + * plugin revokes the WorkOS session server-side on `wp_logout`. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * @param string $return_to Optional URL to land on after sign-out. + * + * @return string + */ + public static function get_logout_url( int $user_id = 0, string $return_to = '' ): string { + $session_id = self::get_session_id( $user_id ); + if ( '' === $session_id || ! self::is_sso( $user_id ) ) { + return wp_logout_url( $return_to ); + } + + $args = [ 'session_id' => $session_id ]; + if ( '' !== $return_to ) { + $args['return_to'] = $return_to; + } + + return Api\Client::get_base_url() . '/user_management/sessions/logout?' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); + } + /** * Get the user's stored WorkOS refresh token. * @@ -202,6 +326,37 @@ public static function snapshot( int $user_id = 0 ): array { ]; } + /** + * Whether a stored access token has expired, expires within the + * leeway, or has no readable `exp` claim. + * + * Reads the claim without checking the signature; see + * {@see self::get_valid_access_token()} for why that is enough here. + * + * @param string $token JWT. + * + * @return bool + */ + private static function access_token_needs_refresh( string $token ): bool { + $parts = explode( '.', $token ); + if ( 3 !== count( $parts ) ) { + return true; + } + + $payload = json_decode( + // JWT payloads are base64url-encoded (RFC 7519); this reads the + // `exp` claim, it is not obfuscation. + (string) base64_decode( strtr( $parts[1], '-_', '+/' ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + true + ); + + if ( ! is_array( $payload ) || ! isset( $payload['exp'] ) || ! is_numeric( $payload['exp'] ) ) { + return true; + } + + return (int) $payload['exp'] - self::ACCESS_TOKEN_REFRESH_LEEWAY <= time(); + } + /** * Read a string-typed meta value, normalized to '' when absent. * diff --git a/src/includes/functions-helpers.php b/src/includes/functions-helpers.php index b914a5d..20355da 100644 --- a/src/includes/functions-helpers.php +++ b/src/includes/functions-helpers.php @@ -7,6 +7,8 @@ * @package WorkOS */ +use WorkOS\App; +use WorkOS\Auth\PasswordResetAdmin\RestApi; use WorkOS\User; defined( 'ABSPATH' ) || exit; @@ -93,7 +95,9 @@ function workos_get_user_id( int $user_id = 0 ): string { * Stored WorkOS access token (JWT) for a user, or empty string when absent. * * Treat as opaque — the token may be expired. Validate with - * `Api\Client::verify_access_token()` if authoritative status is needed. + * `Api\Client::verify_access_token()` if authoritative status is needed, + * or use {@see workos_get_valid_access_token()} to get one that is + * refreshed when it has expired. * * @param int $user_id WP user ID; 0 or omitted targets the current user. * @@ -102,3 +106,94 @@ function workos_get_user_id( int $user_id = 0 ): string { function workos_get_access_token( int $user_id = 0 ): string { return User::get_access_token( $user_id ); } + +/** + * WorkOS access token (JWT) for a user, refreshed first when it has + * expired or expires within a minute. + * + * Thin shortcut for {@see \WorkOS\User::get_valid_access_token()}. A + * refresh rotates the stored access and refresh tokens, the same way the + * `POST /wp-json/workos/v1/auth/session/refresh` endpoint does. Only pass + * a user ID your own code trusts. + * + * ```php + * $token = workos_get_valid_access_token(); + * if ( is_wp_error( $token ) ) { + * return $token; // Not linked, signed out, or the refresh failed. + * } + * $response = wp_remote_get( $url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] ); + * ``` + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string|\WP_Error Access token, or `workos_user_not_linked`, + * `workos_no_session`, `workos_refresh_failed` + * or the WorkOS API error. + */ +function workos_get_valid_access_token( int $user_id = 0 ) { + return User::get_valid_access_token( $user_id ); +} + +/** + * Send a WorkOS password-reset email to a user. + * + * Runs the same code as the admin "Send password reset" action + * (`POST /wp-json/workos/v1/admin/users/{id}/password-reset`): the user + * must be linked and have an email, the Login Profile must allow resets, + * the per-IP and per-user rate limits apply, `redirect_url` is checked by + * `RedirectValidator`, and a `password_reset.admin_sent` row is written to + * the activity log. + * + * It does not check capabilities. The REST route requires + * `current_user_can( 'workos_send_password_reset', $user_id )` (which maps + * to `edit_user`; see docs/capabilities.md); do the same check before + * calling this from a request handler. + * + * ```php + * if ( current_user_can( 'workos_send_password_reset', $user_id ) ) { + * $sent = workos_send_password_reset( $user_id, [ 'redirect_url' => home_url( '/account/' ) ] ); + * } + * ``` + * + * @param int $user_id WP user ID. + * @param array $args { + * Optional. + * + * @type string $redirect_url Where to send the user after the reset. Must be on this site; + * anything else falls back to the profile's post-login redirect. + * @type string $profile Login Profile slug. Defaults to the default profile. + * } + * + * @return true|\WP_Error True when WorkOS accepted the request; otherwise the same error + * the REST route returns (`workos_user_not_found`, `workos_user_not_linked`, + * `workos_user_missing_email`, `workos_profile_not_found`, + * `workos_reset_disabled`, `workos_rate_limited`, or the WorkOS API error). + */ +function workos_send_password_reset( int $user_id, array $args = [] ) { + $result = App::container()->get( RestApi::class )->send_for_user( + $user_id, + sanitize_text_field( (string) ( $args['redirect_url'] ?? '' ) ), + sanitize_title( (string) ( $args['profile'] ?? '' ) ) + ); + + return is_wp_error( $result ) ? $result : true; +} + +/** + * URL that ends the user's current WorkOS session. + * + * Thin shortcut for {@see \WorkOS\User::get_logout_url()}. Returns WorkOS's + * session logout URL for the stored session (WorkOS redirects to + * `$return_to` only when it is on the environment's sign-out redirect + * list), or `wp_logout_url( $return_to )` when the user has no WorkOS + * session. The WorkOS URL does not clear the WordPress auth cookie; + * `wp_logout_url()` ends both. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * @param string $return_to Optional URL to land on after sign-out. + * + * @return string + */ +function workos_get_logout_url( int $user_id = 0, string $return_to = '' ): string { + return User::get_logout_url( $user_id, $return_to ); +} diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index 43f7450..2b316a2 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -42,6 +42,10 @@ interface WorkosUser { id: string; /** Linked WP user id, or 0 if this WorkOS user has no WP counterpart. */ wp_user_id: number; + /** Whether the current admin may send this user a password reset. */ + can_send_password_reset: boolean; + /** Whether the current admin may change this user's email. */ + can_change_email: boolean; email: string; email_verified: boolean; first_name: string; @@ -530,7 +534,7 @@ function App(): JSX.Element { { __( '—', 'integration-workos' ) } ) } - { user.wp_user_id > 0 && ( + { user.can_send_password_reset && (