diff --git a/AGENTS.md b/AGENTS.md
index fbdb2f3..e173c7e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,7 +74,7 @@ Per-environment constants (take priority over generic):
| **Admin** | |
| `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 columns) |
+| `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/AdminBar.php` | Admin bar environment badge |
| `src/WorkOS/Admin/DiagnosticsPage.php` | System diagnostics page |
@@ -82,9 +82,9 @@ Per-environment constants (take priority over generic):
| `src/WorkOS/Admin/OnboardingAjax.php` | Onboarding wizard AJAX handlers |
| **Admin — Users** | |
| `src/WorkOS/Admin/Users/Controller.php` | Wires the WorkOS Users admin submenu + REST endpoint |
-| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list |
-| `src/WorkOS/Admin/Users/RestApi.php` | `GET /wp-json/workos/v1/admin/users` — proxies `Api\Client::list_users()` with sanitized pagination + filters and a server-computed `dashboard_url` per row |
-| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link) |
+| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list and enqueues the shared `workos-admin-password-reset` JS/CSS so the per-row trigger button works. |
+| `src/WorkOS/Admin/Users/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`) |
| **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 |
@@ -94,20 +94,29 @@ Per-environment constants (take priority over generic):
| `src/WorkOS/Auth/Login.php` | SSO login flow (redirect + headless modes) |
| `src/WorkOS/Auth/LoginBypass.php` | Login bypass (`?fallback=1`) when WorkOS is unavailable |
| `src/WorkOS/Auth/Registration.php` | User registration redirect |
-| `src/WorkOS/Auth/PasswordReset.php` | Password reset flow |
+| `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/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). |
+| `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/WorkOS/Auth/Redirect.php` | Role-based login redirects |
| `src/WorkOS/Auth/LogoutRedirect.php` | Role-based logout redirects |
| **Auth — Custom AuthKit (React shell)** | |
| `src/WorkOS/Auth/AuthKit/Controller.php` | Wires Login Profile CPT + takeover + shortcode + route |
-| `src/WorkOS/Auth/AuthKit/Profile.php` | Immutable Login Profile value object |
+| `src/WorkOS/Auth/AuthKit/Profile.php` | Immutable Login Profile value object. Carries the `password_reset_flow` and `auto_login_after_reset` toggles consumed by the password-reset endpoints. |
| `src/WorkOS/Auth/AuthKit/ProfileRepository.php` | CPT-backed CRUD + default seeding |
| `src/WorkOS/Auth/AuthKit/ProfileRouter.php` | Rule-based profile resolution |
| `src/WorkOS/Auth/AuthKit/LoginCompleter.php` | Post-auth finalizer (EntitlementGate + MFA policy) |
| `src/WorkOS/Auth/AuthKit/LoginTakeover.php` | wp-login.php `action=login` takeover, default-profile custom-path bounce, already-signed-in 302 |
| `src/WorkOS/Auth/AuthKit/LoginRedirector.php` | `for_visitor( Profile )` precedence (post_login_redirect → validated redirect_to → admin_url) + `forward_query_args` filter; mirrors `src/js/authkit/redirect.ts` allowlist |
-| `src/WorkOS/Auth/AuthKit/FrontendRoute.php` | `/workos/login/{profile}` canonical rewrite + per-profile `custom_path` rewrites (signature-gated flush) + already-signed-in guard |
+| `src/WorkOS/Auth/AuthKit/FrontendRoute.php` | `/workos/login/{profile}` canonical rewrite + per-profile `custom_path` rewrites (signature-gated flush) + already-signed-in guard. Exposes `FrontendRoute::url_for_profile( Profile, $args )` so password-reset emails (and any future caller) build the same URL the rewrite resolves. |
| `src/WorkOS/Auth/AuthKit/Shortcode.php` | `[workos:login]` shortcode |
-| `src/WorkOS/Auth/AuthKit/Renderer.php` | HTML shell + React bundle enqueue. Fires `workos_authkit_enqueue_assets` action and applies `workos_authkit_branding` / `workos_authkit_profile_data` / `workos_authkit_body_classes` filters — see `docs/extending-the-login-ui.md` |
+| `src/WorkOS/Auth/AuthKit/Renderer.php` | HTML shell + React bundle enqueue. Stapled to `password-strength-meter` (zxcvbn) so `wp.passwordStrength.meter` is available for the reset-confirm form. Emits a pre-hydration skeleton inside the mount `
` mirroring the React `FlowSkeleton` shape (1- or 2-input variant chosen from `initial_step` / `reset_token` / `invitation_token`) so the page never paints blank. Fires `workos_authkit_enqueue_assets` action and applies `workos_authkit_branding` / `workos_authkit_profile_data` / `workos_authkit_body_classes` filters — see `docs/extending-the-login-ui.md`. |
| `src/WorkOS/Auth/AuthKit/Nonce.php` | Profile-scoped CSRF nonces |
| `src/WorkOS/Auth/AuthKit/RateLimiter.php` | Per-IP / per-email transient buckets |
| `src/WorkOS/Auth/AuthKit/Radar.php` | WorkOS Radar site-key + request-header extraction |
@@ -146,7 +155,7 @@ Per-environment constants (take priority over generic):
| **REST — Public Auth (Custom AuthKit)** | |
| `src/WorkOS/REST/Auth/Controller.php` | Wires all public `/wp-json/workos/v1/auth/*` endpoints |
| `src/WorkOS/REST/Auth/BaseEndpoint.php` | Shared profile + nonce + rate-limit + Radar helpers |
-| `src/WorkOS/REST/Auth/Password.php` | `password/authenticate`, `password/reset/{start,confirm}` |
+| `src/WorkOS/REST/Auth/Password.php` | `password/authenticate`, `password/reset/{start,confirm}`. `reset_confirm` mirrors the new password into the linked WP user (`wp_set_password`) and, when `Profile::is_auto_login_after_reset_enabled()` is on, re-authenticates via WorkOS and runs the result through `LoginCompleter` so MFA / org-selection / entitlement gates still apply. `build_password_reset_url()` builds the email URL via `FrontendRoute::url_for_profile()` and `html_entity_decode()`s the final URL (the legacy regression `home_url` filters that escape `&` to `&`). |
| `src/WorkOS/REST/Auth/MagicCode.php` | `magic/{send,verify}` |
| `src/WorkOS/REST/Auth/Session.php` | `nonce`, `session/{refresh,logout}` |
| `src/WorkOS/REST/Auth/Signup.php` | `signup/{create,verify}` |
@@ -168,10 +177,10 @@ Per-environment constants (take priority over generic):
| `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` |
| **Browser — Custom AuthKit (TypeScript + TSX)** | |
| `src/js/authkit/index.tsx` | Entry + data-* hydration |
-| `src/js/authkit/App.tsx` | Top-level step machine |
+| `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. |
| `src/js/authkit/api.ts` | Fetch client w/ nonce + 401 refresh + Radar header |
-| `src/js/authkit/flows.tsx` | 11 flow components (password, magic, signup, reset, mfa, invitation, complete) |
-| `src/js/authkit/ui.tsx` | 11 primitives (Button, Input, Card, …) |
+| `src/js/authkit/flows.tsx` | 11 flow components (password, magic, signup, reset, mfa, invitation, complete). `ResetConfirm` renders two password fields (new + confirm), scores via `window.wp.passwordStrength.meter` (zxcvbn ≥ 3 required), and disables submit until both match. On success it dispatches based on the response shape: `signed_in` → navigate to `redirect_to`; `mfa_required` → bubble to App's MFA step; otherwise → success card with manual continue. |
+| `src/js/authkit/ui.tsx` | 11 primitives + `FlowSkeleton` placeholder card with shimmering rows (heights match the hydrated card one-to-one — heading 24px / label 16px / input 44px / button 40px). Used by App during the boot window and mirrored shape-for-shape in `Renderer::build_skeleton_markup()` for the pre-hydration window. |
| `src/js/authkit/radar.ts` | WorkOS Radar SDK loader (+ `window.WorkOSRadar` augmentation) |
| `src/js/authkit/redirect.ts` | `forwardQueryArgs( destination, originalQuery )` — strips internals (`redirect_to`, `_wpnonce`, `loggedout`, `wp_lang`, `workos_*`, …) and appends safe args; mirrors PHP `LoginRedirector::INTERNAL_QUERY_ARGS` |
| `src/js/authkit/slots.tsx` | SlotFill slot name constants (10 slots, including `workos.authkit.belowCard`) |
@@ -184,7 +193,7 @@ Per-environment constants (take priority over generic):
| `package.json` | JS dependencies (bun; uses `@wordpress/scripts` v30 + TypeScript strict) |
| `bun.lock` | Locked dependency graph (committed) |
| `tsconfig.json` | TypeScript config (strict, `jsx: react-jsx`, `noEmit: true`) |
-| `webpack.config.js` | Extends `@wordpress/scripts` default config with authkit + admin-profiles entries |
+| `webpack.config.js` | Extends `@wordpress/scripts` default config with `authkit`, `admin-profiles`, `admin-users`, and `admin-password-reset` entries |
| `phpstan.neon.dist` | PHPStan config (level 5, scans `src/` + `integration-workos.php` + `uninstall.php`, `phpVersion: 70400`, Strauss vendor resolved via `vendor/autoload.php` in `scanFiles`) |
| `phpstan/stubs.php` | Symbol stubs for `WORKOS_*` runtime-defined constants so PHPStan can resolve them statically — never executed |
@@ -278,7 +287,10 @@ tests/
├── OnboardingSyncTest.php # Onboarding sync tests
├── OptionsTest.php # Options classes tests
├── OrganizationManagerTest.php # Organization manager tests
- ├── PasswordResetTest.php # Password reset tests
+ ├── PasswordResetTest.php # Legacy WP-side password reset guard tests
+ ├── PasswordResetAdminRedirectValidatorTest.php # Same-host redirect validator: accept absolute/relative same-host; reject cross-origin/protocol-relative/non-http; profile-default fallback chain
+ ├── 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
├── RedirectTest.php # Login redirect tests
├── RendererKsesTest.php # Shared renderer KSES allowlist tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73779d6..b134ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,83 @@
Events catalogue, and the public workos-node / -python / -ruby / -go
SDK sources). This page builds the foundation; once WorkOS ships an
API, a row + bulk action can be wired in without reworking the UI.
+- **Admin-triggered WorkOS password reset** (#21, [CONS-287](https://linear.app/nexcess/issue/CONS-287/reset-password-redirects-users-to-kadence-central))
+ — A privileged WP user can now send a WorkOS password-reset email
+ on behalf of any linked user via
+ `POST /wp-json/workos/v1/admin/users/{id}/password-reset`. The
+ endpoint is gated by `edit_user($id)` (so the same route also
+ covers self-service from the shortcode), rate-limited per-IP and
+ per-target, and writes a `password_reset.admin_sent` event to the
+ activity log. Triggered from three surfaces: a `Send password
+ reset` row action under the WorkOS column on
+ `wp-admin/users.php` (next to "View in WorkOS" and "Re-sync", so
+ all WorkOS-specific per-row actions cluster together via the
+ new `workos_user_list_column_actions` filter), a `Password
+ Reset` panel on the user-edit screen, and a
+ `[workos:password-reset]` shortcode that toggles between
+ admin-of-other (`user="…"`) and self-service modes based on its
+ attributes. Companion `redirect_url` parameter threads through
+ every layer: the value is validated same-host against
+ `home_url()`, baked into the URL handed to WorkOS for the email,
+ and passed back to the React shell so the user lands on the
+ chosen page after a successful reset. Fixes the long-standing
+ CONS-287 ("reset password redirects users to Kadence Central")
+ where the post-reset URL was unconfigurable.
+- **In-site React reset page** — reset emails now point at
+ `/workos/login/{profile}?token=…&redirect_to=…` instead of
+ `wp-login.php`. The existing AuthKit `ResetConfirm` step picks the
+ token off the URL and navigates the user to the validated redirect
+ on success. The old `wp-login.php?workos_action=reset-password`
+ URL still resolves cleanly via `LoginTakeover`, so any reset
+ emails already in users' inboxes continue to work.
+- **Password strength + confirmation on reset** — the
+ `ResetConfirm` step now requires the user to enter the new
+ password twice and runs it through WordPress's
+ `wp.passwordStrength.meter` (zxcvbn-backed) in real time. The
+ submit button stays disabled until the two fields match and the
+ score reaches `Strong` (zxcvbn ≥ 3). Site name and common words
+ are passed as the zxcvbn disallowed list so they lose strength
+ points. The `password-strength-meter` script is wired in as a
+ dependency of the AuthKit bundle by `Renderer::enqueue()`; when
+ zxcvbn is still loading the meter reports "Checking strength…"
+ rather than gating on a transient.
+- **Skeleton placeholders on every AuthKit surface** — wp-login.php
+ takeover, `/workos/login/{profile}`, and `[workos:login]` shortcode
+ now paint a card-shaped skeleton with shimmering placeholder rows
+ the moment the page lands, instead of a blank gap while the React
+ bundle downloads and `client.bootstrap()` resolves. The skeleton
+ comes from two places that mirror each other shape-for-shape: PHP
+ embeds it inside the mount `
` so it's visible from first paint
+ (Renderer emits 1-input or 2-input variants based on context —
+ reset_confirm gets two, pick gets one with no footer); React's new
+ `FlowSkeleton` ui component takes over from the `booted=false`
+ branch in App.tsx during the bootstrap RTT. Heights match the
+ hydrated form exactly (heading 24px, subheading 20px, label 16px,
+ input 44px, button 40px) so the swap is a flicker, not a layout
+ jump. Shimmer animates only `background-position` (no transforms,
+ no opacity) and is disabled under `prefers-reduced-motion`.
+- **Password mirror to the WP user on reset** — when a WorkOS reset
+ succeeds and the WorkOS user is linked to a WP user (via
+ `_workos_user_id` meta), the plugin now also runs
+ `wp_set_password()` on the linked WP user with the new plaintext.
+ Keeps the WordPress password fallback paths (`?fallback=1` on
+ wp-login.php, the `wp_authenticate` filter, REST app passwords) in
+ sync with what the user just typed in the React shell — without it
+ the old WP password would silently keep working after the WorkOS
+ rotation. Best-effort: unlinked users no-op cleanly; a write
+ failure never fails the reset response.
+- **Auto-login after password reset** — new per-profile toggle
+ `auto_login_after_reset` (default: on). When enabled, a
+ successful reset_confirm authenticates the user with the new
+ password, runs it through the same `LoginCompleter` the rest of
+ the auth surfaces use (so MFA, organization selection, and the
+ entitlement gate all behave the same as a regular sign-in), sets
+ the WP auth cookie, and sends them to the validated post-reset
+ redirect. If MFA is required the React shell hands off to the
+ existing `mfa` step. With the toggle off the prior behaviour is
+ preserved — the user lands on the "Password reset / Continue to
+ sign in" card. Surfaced in the Login Profile editor under
+ "Flows".
## [1.0.4] - 2026-05-14
diff --git a/README.md b/README.md
index c270118..811e410 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,9 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos.
- **React login shell** on wp-login.php, `[workos:login]` shortcode, and a dedicated `/workos/login/{profile}` route — all driven by the same TypeScript bundle
- **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
+- **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).
+- **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
- **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call
@@ -161,6 +163,7 @@ Each profile stores:
| `signup` | `{enabled, require_invite}` — toggles self-serve signup |
| `invite_flow` | Allow invitation acceptance |
| `password_reset_flow` | Allow in-app password reset |
+| `auto_login_after_reset` | When `true` (default), `reset_confirm` re-authenticates the user with the new password (still running MFA / org-selection / entitlement gates via `LoginCompleter`), sets the WP auth cookie, and lands them on the validated `redirect_url`. When `false`, the user lands on the "Password reset — Continue to sign in" card and signs in manually. |
| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` |
| `branding` | `{logo_mode, logo_attachment_id, primary_color, heading, subheading}` — `logo_mode` is `default` / `custom` / `none` (see "Logo modes" in [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md)) |
| `post_login_redirect` | URL the React shell navigates to on success (beats `redirect_to`)|
@@ -171,7 +174,10 @@ The `branding.logo` field defaults to the WordPress Site Icon when no
per-profile logo is set. See
[`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md) for the
full developer guide on injecting React elements (SlotFill), enqueuing
-per-profile CSS/JS, and the available PHP filters.
+per-profile CSS/JS, and the available PHP filters. For password-reset
+integrations (admin-triggered, self-service, shortcode, redirect_url
+policy, what-not-to-do), see
+[`docs/password-reset.md`](docs/password-reset.md).
### Custom paths
diff --git a/docs/password-reset.md b/docs/password-reset.md
new file mode 100644
index 0000000..3bb78de
--- /dev/null
+++ b/docs/password-reset.md
@@ -0,0 +1,693 @@
+# Password Reset API
+
+This document covers every way to trigger or complete a WorkOS-backed password reset for a WordPress user managed by `integration-workos`.
+
+It is written for two audiences in parallel: a developer integrating against the plugin, and an LLM agent reading it as the source of truth for code-gen. The patterns and gotchas below are exhaustive — if your integration deviates from them, you are almost certainly hitting one of the **Don't do this** sections at the bottom.
+
+> **Plugin requirement:** integration-workos **1.0.5** or later. Earlier versions only expose the `start` and `confirm` endpoints under the legacy `wp-login.php` URL.
+
+---
+
+## At a glance
+
+| Surface | Endpoint | Auth | Audience |
+| --- | --- | --- | --- |
+| 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 |
+| 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`).
+
+---
+
+## End-to-end flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Caller as Caller (user form / admin)
+ participant Plugin as integration-workos
+ participant WorkOS as WorkOS API
+ actor User as End user
+ participant Shell as AuthKit React shell (/workos/login/{slug})
+
+ Caller->>Plugin: POST /reset/start or /admin/users/{id}/password-reset
+ activate Plugin
+ Plugin->>Plugin: Validate redirect_url (same-host or fallback)
+ Plugin->>Plugin: Rate-limit (per IP + per target)
+ Plugin->>WorkOS: send_password_reset(email, reset_url)
+ WorkOS-->>Plugin: 200 OK
+ Plugin-->>Caller: 200 { ok: true, ... }
+ deactivate Plugin
+
+ WorkOS->>User: Email link /workos/login/{slug}?token=…&redirect_to=…
+ User->>Shell: Click link
+
+ Shell->>Plugin: POST /reset/confirm { token, new_password, redirect_url }
+ activate Plugin
+ Plugin->>WorkOS: reset_password(token, new_password)
+ WorkOS-->>Plugin: 200 { user: { id, email } }
+ Plugin->>Plugin: Mirror password to linked WP user (wp_set_password)
+
+ alt auto_login_after_reset = true
+ Plugin->>WorkOS: authenticate_with_password
+ WorkOS-->>Plugin: tokens (or pending MFA)
+ Plugin->>Plugin: LoginCompleter sets WP auth cookie
+ Plugin-->>Shell: { ok, signed_in, redirect_to }
+ else auto_login_after_reset = false
+ Plugin-->>Shell: { ok, redirect_url }
+ end
+ deactivate Plugin
+
+ Shell->>User: window.location.assign(redirect_to)
+```
+
+---
+
+## Endpoint reference
+
+### Public: `POST /wp-json/workos/v1/auth/password/reset/start`
+
+Sends the WorkOS reset email. The endpoint always returns `200` whether or not the email matches a real account — that prevents account enumeration. Response time is also flattened (~900 ms floor) for the same reason.
+
+**Headers**
+
+```
+Content-Type: application/json
+X-WorkOS-Nonce:
+```
+
+**Body**
+
+```jsonc
+{
+ "profile": "default", // required — Login Profile slug
+ "email": "user@example.com", // required
+ "redirect_url": "https://site.example/welcome" // optional — same-host required, falls back to profile.post_login_redirect or home_url('/')
+}
+```
+
+**200 response** (always 200 unless rate-limited or input invalid)
+
+```json
+{
+ "ok": true,
+ "message": "If an account exists for this email, a password reset link is on its way."
+}
+```
+
+**Rate limits**
+
+- `10` attempts per IP per `60s` window.
+- `5` attempts per email per `60s` window.
+
+A 429 returns `WP_Error` with `retry_after` in the data array.
+
+---
+
+### Public: `POST /wp-json/workos/v1/auth/password/reset/confirm`
+
+Completes the reset. Reads the token from the URL the user clicked in the email.
+
+**Headers**
+
+```
+Content-Type: application/json
+X-WorkOS-Nonce:
+```
+
+**Body**
+
+```jsonc
+{
+ "profile": "default", // required
+ "token": "abc123…", // required — value of ?token= from the email link
+ "new_password": "S0meStrongPa$$w0rd!", // required — see "Password strength" below
+ "redirect_url": "https://site.example/welcome" // optional — overrides anything carried in the URL
+}
+```
+
+**200 response** — three shapes depending on profile config and WorkOS state:
+
+1. **Plain reset** (toggle `auto_login_after_reset` is off):
+
+ ```json
+ {
+ "ok": true,
+ "redirect_url": "https://site.example/welcome"
+ }
+ ```
+
+2. **Auto-login** (toggle on, no MFA):
+
+ ```json
+ {
+ "ok": true,
+ "redirect_url": "https://site.example/welcome",
+ "signed_in": true,
+ "user": { "id": 42, "email": "user@example.com", "display_name": "User" },
+ "redirect_to": "https://site.example/welcome"
+ }
+ ```
+
+ A WP auth cookie has been set; the linked WP user's password has been mirrored to match.
+
+3. **Auto-login with MFA required**:
+
+ ```json
+ {
+ "ok": true,
+ "redirect_url": "https://site.example/welcome",
+ "mfa_required": true,
+ "pending_authentication_token": "pending_xyz",
+ "factors": [ { "id": "auth_factor_…", "type": "totp" } ]
+ }
+ ```
+
+ Submit `pending_authentication_token` + factor code to `/wp-json/workos/v1/auth/mfa/verify` to finish.
+
+**Rate limits**
+
+- `10` attempts per IP per `60s` window. (No per-email bucket here; the token already gates against arbitrary callers.)
+
+---
+
+### 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.
+
+**Headers**
+
+```
+Content-Type: application/json
+X-WP-Nonce:
+```
+
+**Body** (all optional)
+
+```jsonc
+{
+ "redirect_url": "https://site.example/welcome", // optional — same-host required
+ "profile": "default" // optional — defaults to the canonical default profile
+}
+```
+
+**200 response**
+
+```json
+{
+ "ok": true,
+ "email_hint": "u•••@e•••.com",
+ "profile": "default",
+ "redirect_url": "https://site.example/welcome"
+}
+```
+
+`email_hint` is intentionally masked — never echo the full target email in UI.
+
+**Errors**
+
+| Status | Code | Cause |
+| --- | --- | --- |
+| 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 |
+| 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 |
+| 429 | `workos_rate_limited` | Rate-limit window exhausted (10/IP/min, 5/target-user/min) |
+
+Every successful call writes a `password_reset.admin_sent` row to the activity log (`{$wpdb->prefix}workos_activity_log`) with the initiator id, target id, profile slug, redirect URL, and a `self_service` flag.
+
+---
+
+## Profile policy
+
+Each Login Profile exposes three settings that govern the flow:
+
+| Setting | Default | Effect |
+| --- | --- | --- |
+| `password_reset_flow` | `true` | When `false`, all three endpoints return `workos_reset_disabled`. Reset emails can't be sent and `?token=` links can't be redeemed. |
+| `auto_login_after_reset` | `true` | When `true`, a successful `confirm` re-authenticates the user with their new password and sets the WP auth cookie. MFA still runs. |
+| `post_login_redirect` | `''` (home) | Default destination when no `redirect_url` is supplied. |
+
+Toggle them in **WorkOS → Login Profiles → {profile}**.
+
+---
+
+## Password strength + confirmation
+
+The React reset-confirm step renders **two password fields** and scores the value via WordPress's bundled `wp.passwordStrength.meter` (zxcvbn-backed). Submit is disabled until:
+
+- Both fields match exactly, AND
+- zxcvbn score reaches `Strong` (≥ 3 out of 4).
+
+The site name plus the strings `wordpress` and `admin` are passed as the zxcvbn disallowed list, so reusing those loses strength points.
+
+When zxcvbn is still streaming in, the meter reports `Checking strength…` rather than gating on a transient.
+
+> If you're calling the `confirm` endpoint from your own UI, **you** own enforcing this gate. The server accepts any non-empty `new_password`.
+
+---
+
+## Redirect URL validation
+
+`redirect_url` accepts:
+
+- Absolute URLs whose host equals `home_url()`'s host.
+- Site-relative paths (e.g. `/welcome` → resolved against `home_url()`).
+
+It rejects, falling back to `profile.post_login_redirect` → `home_url('/')`:
+
+- Cross-origin absolute URLs.
+- Protocol-relative URLs (`//evil.example/x`).
+- Non-`http(s)` schemes (`javascript:`, `data:`, etc.).
+
+The validation runs on every endpoint that accepts `redirect_url` (start, confirm, admin-trigger) and on the server side, so a malicious client can't bypass it.
+
+---
+
+## Examples
+
+### Full PHP example — server-side admin trigger
+
+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).
+
+```php
+set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ [
+ // Optional. Must be same-host; falls back to profile default otherwise.
+ 'redirect_url' => $redirect_url,
+ // Optional. Defaults to the canonical "default" Login Profile.
+ 'profile' => 'default',
+ ]
+ )
+ );
+
+ $response = rest_do_request( $request );
+
+ if ( $response->is_error() ) {
+ return $response->as_error();
+ }
+
+ $data = $response->get_data();
+ // $data['email_hint'] is masked (e.g. "j•••@e•••.com") — safe to surface in UI.
+ return $data;
+}
+
+// Example usage from a custom admin action.
+add_action( 'admin_post_my_send_reset', static function () {
+ check_admin_referer( 'my_send_reset' );
+
+ $target_id = absint( $_POST['user_id'] ?? 0 );
+ $redirect_url = esc_url_raw( $_POST['redirect_url'] ?? home_url( '/' ) );
+
+ $result = my_plugin_trigger_workos_password_reset( $target_id, $redirect_url );
+
+ if ( is_wp_error( $result ) ) {
+ wp_die( esc_html( $result->get_error_message() ), 'Reset failed', [ 'response' => 400 ] );
+ }
+
+ wp_safe_redirect( admin_url( 'users.php?reset_sent=1' ) );
+ exit;
+} );
+```
+
+Why call into the internal endpoint instead of `workos()->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.
+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.
+
+If you absolutely need the lower-level call, see [`WorkOS\Api\Client::send_password_reset()`](../src/WorkOS/Api/Client.php).
+
+---
+
+### Vanilla JS example — frontend self-service
+
+Use this for a custom "Forgot password?" link on your theme's login page, outside the AuthKit React shell.
+
+```html
+
+
+```
+
+```html
+
+
+```
+
+```js
+// Browser code.
+( function () {
+ const form = document.getElementById( 'forgot-form' );
+ const status = form.querySelector( '.status' );
+ const cfg = window.myResetConfig;
+
+ form.addEventListener( 'submit', async ( event ) => {
+ event.preventDefault();
+ status.textContent = 'Sending…';
+
+ try {
+ const response = await fetch( cfg.endpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type' : 'application/json',
+ // Note the header name — the public auth endpoints use the
+ // profile-scoped X-WorkOS-Nonce, not X-WP-Nonce.
+ 'X-WorkOS-Nonce' : cfg.nonce,
+ },
+ body: JSON.stringify( {
+ profile : cfg.profile,
+ email : form.email.value,
+ redirect_url : cfg.redirectUrl,
+ } ),
+ } );
+
+ const data = await response.json();
+
+ if ( ! response.ok ) {
+ // 429s arrive here with data.code === 'workos_rate_limited'.
+ status.textContent = data.message || 'Could not send the email. Please try again.';
+ return;
+ }
+
+ // Server always returns 200 to prevent account enumeration.
+ status.textContent = data.message;
+ } catch ( err ) {
+ status.textContent = 'Network error. Please try again.';
+ }
+ } );
+} )();
+```
+
+**The hidden gotcha:** the per-profile nonce is rotated every WP nonce tick (~12h by default). Long-lived single-page apps must re-fetch the nonce after a 403 response, not hard-fail.
+
+---
+
+### React + TypeScript example — admin trigger button
+
+Use this when you're building a custom admin UI that lives outside this plugin — e.g. a SaaS dashboard's React surface — and need to trigger a reset for a specific WP user. (For the public self-service flow, use the bundled AuthKit shell instead of writing your own.)
+
+```tsx
+// SendResetButton.tsx
+import { useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+interface SendResetConfig {
+ /** Base URL — admin endpoint is `${baseUrl}{id}/password-reset`. */
+ baseUrl: string;
+ /** WP REST nonce (`wp_rest`), refreshed when stale. */
+ nonce: string;
+}
+
+interface Props {
+ config: SendResetConfig;
+ /** Target WP user id. */
+ userId: number;
+ /** Optional same-host URL to land the user on after they reset. */
+ redirectUrl?: string;
+ /** Optional Login Profile slug — defaults to "default" server-side. */
+ profile?: string;
+ onSuccess?: ( emailHint: string ) => void;
+ onError?: ( message: string ) => void;
+}
+
+interface SuccessResponse {
+ ok: true;
+ email_hint: string;
+ redirect_url: string;
+}
+
+interface ErrorResponse {
+ code?: string;
+ message?: string;
+}
+
+export function SendResetButton( {
+ config,
+ userId,
+ redirectUrl = '',
+ profile = '',
+ onSuccess,
+ onError,
+}: Props ) {
+ const [ busy, setBusy ] = useState( false );
+
+ const send = async () => {
+ if ( ! window.confirm( __( 'Send a password reset email to this user?', 'my-plugin' ) ) ) {
+ return;
+ }
+ setBusy( true );
+
+ try {
+ const response = await fetch( `${ config.baseUrl }${ userId }/password-reset`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Admin endpoint uses the standard WP REST nonce.
+ 'X-WP-Nonce' : config.nonce,
+ },
+ body: JSON.stringify( { redirect_url: redirectUrl, profile } ),
+ } );
+
+ const data = ( await response.json() ) as
+ | SuccessResponse
+ | ErrorResponse;
+
+ if ( ! response.ok ) {
+ const err = data as ErrorResponse;
+ onError?.( err.message ?? __( 'Could not send the email.', 'my-plugin' ) );
+ return;
+ }
+
+ const ok = data as SuccessResponse;
+ onSuccess?.( ok.email_hint );
+ } catch {
+ onError?.( __( 'Network error.', 'my-plugin' ) );
+ } finally {
+ setBusy( false );
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+**Wiring it up.** Pass the config from PHP via `wp_localize_script()`:
+
+```php
+wp_localize_script(
+ 'my-admin-bundle',
+ 'mySendResetConfig',
+ [
+ 'baseUrl' => esc_url_raw( rest_url( 'workos/v1/admin/users/' ) ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ ]
+);
+```
+
+---
+
+## Shortcode reference
+
+```
+[workos:password-reset
+ user="42" # WP user id OR email. Omit for self-service.
+ redirect_url="/dashboard" # Optional same-host URL.
+ profile="default" # Optional Login Profile slug.
+ label="Reset my password" # Optional button label.
+]
+```
+
+**Modes**
+
+- **Admin-of-other:** `user="42"` — renders only when the viewer has `edit_user(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.
+
+---
+
+## Activity log
+
+Successful admin-triggered sends append a row to `{$wpdb->prefix}workos_activity_log`:
+
+| Column | Value |
+| --- | --- |
+| `event_type` | `password_reset.admin_sent` |
+| `user_id` | Target WP user ID |
+| `user_email` | Target email (in plaintext — column is internal) |
+| `workos_user_id` | Linked WorkOS user ID |
+| `metadata` | JSON: `{ profile, redirect_url, initiator_id, self_service }` |
+
+Read via `\WorkOS\ActivityLog\EventLogger::get_events([ 'event_type' => 'password_reset.admin_sent' ])`.
+
+---
+
+## Don't do this
+
+This section catalogs the failure modes we've seen people hit. If your integration is misbehaving, scan here before opening an issue.
+
+### ❌ Don't call WorkOS directly to send the reset email.
+
+```php
+// BAD — bypasses the URL builder, rate limits, profile gating, activity log.
+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`.
+
+### ❌ Don't store reset tokens or replay them.
+
+The token in `?token=…` is single-use and validated by WorkOS, not this plugin. Capturing it in your own database serves no purpose and creates a credential surface you have to protect. Just hand it to `POST /auth/password/reset/confirm` once.
+
+### ❌ Don't trust the `redirect_url` you got from URL parameters without re-validating.
+
+```js
+// BAD — pulls redirect_to from the URL and uses it directly.
+const params = new URLSearchParams( window.location.search );
+window.location.assign( params.get( 'redirect_to' ) ); // open redirect
+```
+
+The server already validates `redirect_url` against `home_url()`, but only when it travels through the endpoint payload. If you're consuming a URL parameter directly in JS, run it back through `wp_validate_redirect()` (or its same-host equivalent in your stack) before navigating.
+
+### ❌ Don't echo the user's email from the admin response.
+
+```js
+// BAD
+status.textContent = `Sent to ${ data.email }`;
+```
+
+The endpoint deliberately returns `email_hint` (`u•••@e•••.com`) instead of the full address. Don't undo that — operator-screen leakage is one of the easier audit findings to avoid.
+
+### ❌ Don't skip the password-strength gate when you write your own reset UI.
+
+The server accepts any non-empty `new_password`. The strength meter is an in-shell UX gate, not an API-level one. If you're writing a custom reset form (instead of using the bundled AuthKit shell), reproduce the zxcvbn check in your UI — otherwise users can set `password` and `123456` against the WorkOS account.
+
+### ❌ Don't assume a 200 from `/reset/start` means the email exists.
+
+The endpoint **always** returns 200 (with a fixed ~900 ms response-time floor) to prevent account enumeration. If you need to know whether the email is registered, ask via a different surface (a separate authenticated lookup), not this one.
+
+### ❌ Don't hard-code `wp-login.php` as the reset URL.
+
+Reset emails point at `/workos/login/{slug}` since 1.0.5. Legacy `wp-login.php?workos_action=reset-password` links still resolve (via `LoginTakeover`), but new code should never build that URL by hand. Let the endpoint construct it via `FrontendRoute::url_for_profile()`.
+
+### ❌ Don't use `X-WP-Nonce` for the public auth endpoints.
+
+```js
+// BAD — X-WP-Nonce works for the admin endpoint, NOT for /auth/*.
+fetch( '/wp-json/workos/v1/auth/password/reset/start', {
+ headers: { 'X-WP-Nonce': nonce },
+} );
+```
+
+Public auth endpoints use a profile-scoped nonce in the **`X-WorkOS-Nonce`** header. (We deliberately avoid `X-WP-Nonce` to prevent collisions with other plugins.) Admin endpoints under `/admin/*` use the standard `X-WP-Nonce`. Mixing them returns 403.
+
+### ❌ Don't disable `auto_login_after_reset` because "session management is hard".
+
+When the toggle is off, the user is left on a "Continue to sign in" card after a successful reset — they then have to re-authenticate. That sounds safer; it isn't. With it on, `LoginCompleter` still runs every gate the rest of your auth surfaces apply (MFA, organization selection, entitlement gate). Leave it on unless you have a specific reason (e.g. you want to require a captcha on every login).
+
+---
+
+## Extending the WP Users list (WorkOS column)
+
+The "Send password reset" link lives in a slot-keyed row-action list under the WorkOS column on `wp-admin/users.php`. Third-party plugins can extend that same list via the `workos_user_list_column_actions` filter:
+
+```php
+add_filter(
+ 'workos_user_list_column_actions',
+ function ( array $actions, int $user_id, string $workos_id ): array {
+ if ( ! current_user_can( 'edit_user', $user_id ) ) {
+ return $actions;
+ }
+
+ $actions['my_plugin_audit'] = sprintf(
+ '%s',
+ esc_url(
+ add_query_arg(
+ [ 'workos_user_id' => $workos_id ],
+ admin_url( 'admin.php?page=my-plugin-audit' )
+ )
+ ),
+ esc_html__( 'Audit history', 'my-plugin' )
+ );
+
+ return $actions;
+ },
+ 10,
+ 3
+);
+```
+
+The filter only fires for users with a non-empty `_workos_user_id` meta (so unlinked rows never invoke your callback). Each entry is a fully-formed `Label` string keyed by action slug; the keys exist so a later plugin can replace or reorder a specific action without rebuilding the full list. Entries are joined with ` | ` separators on render.
+
+---
+
+## Internal references
+
+| Concept | Source |
+| --- | --- |
+| Public REST endpoints | [`src/WorkOS/REST/Auth/Password.php`](../src/WorkOS/REST/Auth/Password.php) |
+| Admin REST endpoint | [`src/WorkOS/Auth/PasswordResetAdmin/RestApi.php`](../src/WorkOS/Auth/PasswordResetAdmin/RestApi.php) |
+| Shortcode | [`src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php`](../src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php) |
+| Redirect URL validator | [`src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php`](../src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php) |
+| URL builder helper | [`FrontendRoute::url_for_profile()`](../src/WorkOS/Auth/AuthKit/FrontendRoute.php) |
+| Profile toggles | [`src/WorkOS/Auth/AuthKit/Profile.php`](../src/WorkOS/Auth/AuthKit/Profile.php) (`is_password_reset_flow_enabled`, `is_auto_login_after_reset_enabled`) |
+| WorkOS API client | [`WorkOS\Api\Client::send_password_reset()` / `::reset_password()`](../src/WorkOS/Api/Client.php) |
+| React `ResetConfirm` step | [`src/js/authkit/flows.tsx`](../src/js/authkit/flows.tsx) |
+
+## External references
+
+- WorkOS User Management API — Password reset:
+- WorkOS Radar (anti-fraud action tokens, threaded through every endpoint):
+- WordPress REST API nonces:
+- WordPress `wp.passwordStrength.meter`:
+- zxcvbn (the score engine):
diff --git a/readme.txt b/readme.txt
index cda6cdb..02cbe65 100644
--- a/readme.txt
+++ b/readme.txt
@@ -178,6 +178,13 @@ WorkOS is provided by WorkOS, Inc.
= 1.0.5 - 2026-05-18 =
* New: WorkOS → Users admin page. Paginated, searchable React list of WorkOS users for the active environment, with a per-row "Open in WorkOS" deep-link straight to the user's Dashboard page. Lets admins triage WorkOS users (including re-enabling a suppressed email under the Dashboard's Emails tab) without bouncing through the Dashboard's own user picker. Requires `manage_options`. No bulk re-enable yet — WorkOS does not expose a public REST endpoint for the "Re-enable email" action. ([CONS-273](https://linear.app/nexcess/issue/CONS-273/re-enable-workos-emails-for-affected-portal-users))
+* New: Admin-triggered WorkOS password reset. A user with `edit_user` capability on a linked target (which includes self-service, since WP grants `edit_user` on one's own ID) can send a WorkOS reset email via three surfaces — a row action on `wp-admin/users.php`, a "Password Reset" panel on the user-edit screen, and the new `[workos:password-reset]` shortcode. The shortcode supports both admin-of-other (`user="…"`) and self-service (no `user` attr) modes. (#21)
+* New: `redirect_url` parameter on the admin REST endpoint and on the existing public reset endpoints. The value is validated against `home_url()` host, threaded through the WorkOS-hosted email link, and used by the AuthKit React shell to send the user to the chosen page after a successful reset. Fixes the CONS-287 regression where the post-reset URL was unconfigurable.
+* New: WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly so any reset emails already in users' inboxes keep working.
+* New: Password strength + confirmation on the reset-confirm step. Users must enter the new password twice; the value is scored in real time via WordPress's `wp.passwordStrength.meter` (zxcvbn) and the submit button stays disabled until the fields match and the score reaches Strong. Site name and common words are passed as the zxcvbn disallowed list.
+* New: Per-profile `auto_login_after_reset` toggle (default on). When enabled, a successful password reset signs the user in (via the shared `LoginCompleter`, so MFA / organization selection / entitlement gates still apply) and lands them on the validated post-reset redirect URL. With the toggle off the user lands on the existing "Password reset — Continue to sign in" card.
+* New: Password reset mirrors the new password to the linked WordPress user via `wp_set_password()` so the WP password fallback (`?fallback=1`, `wp_authenticate`, REST app passwords) stays in sync with the WorkOS password the user just typed. Unlinked users no-op cleanly.
+* New: Skeleton placeholders on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, and the shortcode) paint a card-shaped silhouette with shimmering rows the moment the page lands, instead of a blank gap while the React bundle downloads and bootstraps. Heights match the hydrated form one-to-one so the swap is a flicker, not a layout jump. Honours `prefers-reduced-motion`.
= 1.0.4 - 2026-05-14 =
@@ -250,7 +257,7 @@ Base platform:
== Upgrade Notice ==
= 1.0.5 =
-Adds a new WorkOS → Users admin page (read-only, paginated, searchable) with deep-links into the WorkOS Dashboard so admins can re-enable a user's suppressed email faster. No bulk re-enable yet — WorkOS does not expose a public API for that action.
+Adds the WorkOS → Users admin page (read-only, paginated, searchable, with deep-links into the WorkOS Dashboard for re-enabling a user's suppressed email), admin-triggered WorkOS password resets (Users list row action, user-edit panel, and `[workos:password-reset]` shortcode), and a `redirect_url` parameter that lands users on the chosen page after they finish resetting. WorkOS reset emails now point at the in-site React reset page instead of `wp-login.php`.
= 1.0.4 =
Fixes the "you have been logged out" screen leaking the native wp-login form, password-reset emails arriving with HTML-encoded `&` in the link, an infinite redirect loop caused by cached redirect responses, and a Login Profile editor bug where unchecking an auth method or MFA factor did not persist on save.
diff --git a/src/WorkOS/Admin/UserList.php b/src/WorkOS/Admin/UserList.php
index f1907ec..3e69772 100644
--- a/src/WorkOS/Admin/UserList.php
+++ b/src/WorkOS/Admin/UserList.php
@@ -82,28 +82,33 @@ public function render_column( string $output, string $column_name, int $user_id
* @return string Column HTML.
*/
private function render_linked_column( int $user_id, string $workos_id, string $environment_id ): string {
- if ( ! $environment_id ) {
- return '' . esc_html( $workos_id ) . '';
+ $dashboard_url = '' !== $environment_id
+ ? sprintf(
+ 'https://dashboard.workos.com/%s/users/%s/details',
+ rawurlencode( $environment_id ),
+ rawurlencode( $workos_id )
+ )
+ : '';
+
+ if ( '' !== $dashboard_url ) {
+ $html = sprintf(
+ '%s',
+ esc_url( $dashboard_url ),
+ esc_html( $workos_id )
+ );
+ } else {
+ $html = '' . esc_html( $workos_id ) . '';
}
- $dashboard_url = sprintf(
- 'https://dashboard.workos.com/%s/users/%s/details',
- rawurlencode( $environment_id ),
- rawurlencode( $workos_id )
- );
-
- $html = sprintf(
- '%s',
- esc_url( $dashboard_url ),
- esc_html( $workos_id )
- );
+ $actions = [];
- $html .= '