Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()` |
Expand All @@ -82,16 +82,16 @@ 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 |
| `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, `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 |
Expand All @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).

Expand Down Expand Up @@ -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.
13 changes: 11 additions & 2 deletions src/WorkOS/Admin/UserProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace WorkOS\Admin;

use WorkOS\Capabilities;
use WorkOS\Config;
use WorkOS\Sync\UserSync;

Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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 '<h3>' . esc_html__( 'User Information', 'integration-workos' ) . '</h3>';

if ( null === $workos_user ) {
Expand Down Expand Up @@ -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 .= ' <span style="color:#00a32a;" title="' . esc_attr__( 'Verified', 'integration-workos' ) . '">&#10003;</span>';
} elseif ( current_user_can( Capabilities::VERIFY_EMAIL ) ) {
// Handled by the shared admin script (admin-password-reset).
$email_html .= sprintf(
' <button type="button" class="button button-small workos-verify-email-trigger" data-user-id="%d">%s</button>',
$wp_user_id,
esc_html__( 'Mark email verified', 'integration-workos' )
);
}
$this->render_form_row( __( 'Email', 'integration-workos' ), $email_html );

Expand Down
Loading
Loading