Skip to content

feat(authkit): admin-triggered password reset with redirect_url (1.0.5)#21

Merged
bordoni merged 13 commits into
mainfrom
feature/workos-password-reset-admin
May 19, 2026
Merged

feat(authkit): admin-triggered password reset with redirect_url (1.0.5)#21
bordoni merged 13 commits into
mainfrom
feature/workos-password-reset-admin

Conversation

@bordoni
Copy link
Copy Markdown
Owner

@bordoni bordoni commented May 18, 2026

Summary

  • Adds an admin-triggered WorkOS password-reset path. A user with edit_user($id) (which WP grants on one's own ID, so the same route doubles as self-service) can send a WorkOS reset email via POST /wp-json/workos/v1/admin/users/{id}/password-reset. The endpoint is rate-limited per-IP and per-target and writes a password_reset.admin_sent event to the activity log.
  • Three trigger surfaces ship together: a Send WorkOS password reset row action on wp-admin/users.php, a Password Reset panel on the user-edit screen, and a new [workos:password-reset] shortcode that toggles between admin-of-other (user=\"…\") and self-service (no user attr) modes.
  • Adds an optional redirect_url parameter that threads through every layer: 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. The same param is also accepted on the existing public /auth/password/reset/{start,confirm} endpoints.
  • Reset emails now point at the in-site AuthKit React 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 via LoginTakeover, so any reset emails already in users' inboxes keep working.

Fixes CONS-287 (Linear) where the post-reset URL was hardcoded.

Note on numbering: the user asked for this to be PR #20 — but #20 was already taken by #20 ("WorkOS Users list page"), which is a sibling change targeting the same 1.0.5 milestone. This PR is therefore #21.

Screenshots:

CleanShot 2026-05-18 at 21 43 31 CleanShot 2026-05-18 at 21 45 43

What's in the box

New (under src/WorkOS/Auth/PasswordResetAdmin/)

  • Controller.php — DI controller wired into WorkOS\Controller.
  • RestApi.phpPOST /workos/v1/admin/users/{id}/password-reset. Capability: edit_user on the target. Validates link to WorkOS, applies rate limits, validates redirect_url, fires Api\Client::send_password_reset(), logs an activity event. Returns a masked email hint (`j•••@e•••.com`) instead of the full address.
  • RedirectValidator.php — same-host validator with profile-default fallback.
  • RowActions.phpuser_row_actions hook for wp-admin/users.php.
  • UserProfilePanel.php — adds a "Password Reset" panel + button to the user-edit screen, late on edit_user_profile/show_user_profile so the existing read-only WorkOS panel renders first.
  • Shortcode.php[workos:password-reset] with user, redirect_url, label, profile attributes. Admin-of-other and self-service modes via the user attribute (omitted = self).
  • Assets.php — registers the click-handler JS + styles. Auto-enqueues on users.php / user-edit.php / profile.php; the shortcode enqueues at render time.

New JS (src/js/admin-password-reset/)

  • index.ts — delegated click handler for any .workos-pwreset-trigger; confirms, POSTs with the WP REST nonce, surfaces an admin notice.
  • styles.css — minimal scoped styles.

Modified

  • src/WorkOS/REST/Auth/Password.phpreset_start and reset_confirm accept optional redirect_url; build_password_reset_url() now points at the AuthKit frontend route (was wp-login.php) and appends a validated redirect_to; reset_confirm returns the validated redirect_url in the response.
  • src/WorkOS/Auth/AuthKit/FrontendRoute.php — exposes url_for_profile( Profile, array \$args = [] ): string helper used by both password-reset URL builders.
  • src/WorkOS/Controller.php — registers PasswordResetAdmin\Controller.
  • src/js/authkit/flows.tsxResetRequest and ResetConfirm thread redirect_to/redirect_url to/from the server; on confirm-success, navigate to the server-validated URL if present.
  • webpack.config.js — new admin-password-reset entry.
  • CHANGELOG.md, readme.txt, integration-workos.php, src/WorkOS/Plugin.php — bump version to 1.0.5 and document the change.

Security notes

  • Admin REST endpoint uses edit_user (per-target). For a logged-in user acting on themselves, WP grants this by default — that's the seam that lets one endpoint cover both admin-of-other and self-service paths.
  • Rate-limiting is applied even on authenticated admin calls — a compromised admin session shouldn't be able to flood reset emails.
  • RedirectValidator rejects cross-origin redirects, non-http(s) schemes, and protocol-relative //evil.example strings. Falls back to the profile's post_login_redirect, then home_url('/').
  • EventLogger::log( 'password_reset.admin_sent', … ) captures initiator, target, profile, redirect_url, and a self_service flag.
  • Email never appears in admin notices in plaintext — mask_email() returns j•••@e•••.com.

Test plan

  • As an admin: trigger the reset from wp-admin/users.php row action against a WorkOS-linked user → success notice with masked email.
  • Same trigger from the user-edit screen panel.
  • Same trigger via [workos:password-reset user=\"42\" redirect_url=\"/dashboard\"] on a page (admin-of-other mode).
  • Self-service via [workos:password-reset] placed on a frontend page while logged in.
  • Open the WorkOS email link → lands on /workos/login/{slug}?token=…&redirect_to=… with the AuthKit reset form. Submit a new password → confirmation card appears → "Continue" navigates to the validated redirect_url.
  • A redirect_url pointing off-site (e.g. https://evil.example/whatever) is rejected by the validator and falls back to the profile/home URL.
  • An old reset email pointing at wp-login.php?workos_action=reset-password&profile=default&token=… still works (LoginTakeover routes it into the React shell).
  • Setting password_reset_flow=false on a profile makes both the admin endpoint and the public start/confirm endpoints return workos_reset_disabled.
  • Hammer the admin endpoint past the rate-limit windows (10/IP/min, 5/target/min) → 429 with retry_after.
  • Hitting the admin endpoint as a user without edit_user on the target → 403.
  • Hitting the admin endpoint for a WP user without _workos_user_id → 409 workos_user_not_linked.
  • Activity log shows password_reset.admin_sent rows after triggers, including the initiator + self-service flag in metadata.

Follow-ups (not in this PR)

A privileged WP user can now send a WorkOS password-reset email on behalf
of a linked user. Three trigger surfaces — `wp-admin/users.php` row
action, user-edit screen panel, and the new `[workos:password-reset]`
shortcode (admin-of-other and self-service modes) — all post to
`POST /wp-json/workos/v1/admin/users/{id}/password-reset`. The endpoint
is gated by `edit_user($id)` (which WP grants on one's own ID, so the
same route covers self-service), rate-limited per IP and per target, and
writes a `password_reset.admin_sent` event to the activity log.

A `redirect_url` parameter threads through every layer: validated
same-host against `home_url()`, baked into the URL handed to WorkOS for
the email, and returned to the React shell so the user lands on the
chosen page after a successful reset. The same parameter is also
accepted on the existing public `/auth/password/reset/{start,confirm}`
endpoints. Fixes CONS-287 (reset password redirected users to Kadence
Central).

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 via `LoginTakeover`, so any reset emails already in users'
inboxes keep working.

Linear: https://linear.app/nexcess/issue/CONS-287/reset-password-redirects-users-to-kadence-central
@bordoni bordoni added this to the 1.0.5 milestone May 18, 2026
bordoni added 12 commits May 18, 2026 19:55
Documents the interfaces and functions added in the previous commit:
- admin-password-reset/index.ts: PasswordResetConfig, SuccessResponse,
  ErrorResponse interfaces; getConfig, showNotice, sendReset, and the
  delegated click listener now each carry a JSDoc.
- authkit/flows.tsx: new ResetConfirmResponse interface and the
  modified ResetConfirm component now describe how redirect_url
  threads through to the success-card 'Continue' action.
The reset-confirm step now asks for the new password twice and scores
it in real time with WordPress's `wp.passwordStrength.meter`
(zxcvbn-backed). The submit button stays disabled until both fields
match and the score reaches Strong (zxcvbn ≥ 3). When zxcvbn is still
streaming in we report "Checking strength…" rather than gating on a
transient.

`Renderer::enqueue()` now adds `password-strength-meter` to the AuthKit
bundle's dependency list, which transitively pulls in zxcvbn-async so
the meter is available everywhere AuthKit renders (wp-login.php
takeover, /workos/login/{profile}, the shortcode).

Site name and common words are passed as the zxcvbn disallowed list so
they lose strength points. The user's email is intentionally NOT in
the list — the reset flow only knows the opaque WorkOS token, not the
recipient.
When the new `auto_login_after_reset` toggle is on (default), a
successful reset_confirm immediately re-authenticates the user with
the new password, runs the result through `LoginCompleter` (so MFA,
organization selection, and the entitlement gate behave the same as a
normal sign-in), sets the WP auth cookie, and sends the user to the
validated post-reset redirect URL. With the toggle off the prior
behaviour is preserved — the user lands on the "Password reset /
Continue to sign in" card.

The MFA path is handed back to the React App's existing `mfa` step
via the new `onMfa` callback on `ResetConfirm`, so the experience is
identical to the rest of the auth surfaces.

The toggle ships on the Login Profile editor under "Flows" and is
greyed out when the password-reset flow itself is disabled.
Source change
- `build_password_reset_url()` (REST/Auth/Password.php) and
  `PasswordResetAdmin\RestApi::build_reset_url()` now run
  `html_entity_decode()` on the *final* URL too, not just the base.
  Caught by the updated regression test: when a host-site `home_url`
  filter HTML-escaped ampersands, the appended `redirect_to` value
  could carry `&` / `&` through into the URL WorkOS emails.
  Decoding the assembled URL makes the link in the inbox clean again.

Test coverage (4 new files / 4 updated assertions)
- `AuthKitProfileTest`: 4 cases for the new `auto_login_after_reset`
  toggle — default, defaults() profile, explicit false, round-trip
  through to_array / from_array.
- `PasswordResetAdminRedirectValidatorTest`: 12 cases for the
  same-host validator (accept absolute / relative same-host; reject
  cross-origin, protocol-relative, non-http schemes; fall back to
  profile default or home_url; ignore unsafe profile defaults).
- `PasswordResetAdminRestApiTest`: 10 cases for the admin REST
  endpoint — 403 without `edit_user`, 404 missing user, 409 unlinked
  user, happy path (masked email, validated redirect, correct URL
  shape), self-service via `edit_user(self)`, off-site redirect
  fallback, profile reset_flow disabled, per-target rate limit,
  activity log row content for admin and self-service.
- `AuthKitRestPasswordTest`: 4 new cases covering the auto-login
  reset_confirm flow — toggle on signs the user in and returns
  `signed_in => true` + `redirect_to`; toggle off skips the second
  WorkOS call and returns the plain payload; MFA path surfaces
  `mfa_required`; off-site `redirect_url` falls back to home_url.
- Fixed the existing
  `test_reset_start_sends_url_with_unescaped_ampersands` data
  provider to point at the new `/workos/login/{slug}/` URL shape
  (was asserting the legacy `wp-login.php?workos_action=…` form).

All 482 wpunit tests pass locally via the same split CI uses
(`--skip-group constants` + `--group constants`).
…rd-reset-admin

# Conflicts:
#	CHANGELOG.md
#	readme.txt
#	webpack.config.js
Now that the WorkOS Users admin page from #20 has landed in main, wire
the per-row "Send password reset" trigger that was deferred until the
two PRs converged.

- `Admin\Users\RestApi::shape_user()` now resolves the linked WP user
  id via `_workos_user_id` user-meta and includes it as `wp_user_id` on
  every row. Empty (0) when the WorkOS user has no WP counterpart yet.
- `Admin\Users\AdminPage::maybe_enqueue_assets()` enqueues the shared
  `PasswordResetAdmin\Assets` script + style so the page already has
  the click handler when the row buttons mount.
- React row in `src/js/admin-users/index.tsx` renders a
  `.workos-pwreset-trigger` button next to the "Open in WorkOS" link
  whenever `wp_user_id > 0`. The shared delegated click handler picks
  it up and posts to the admin endpoint exactly like the row action on
  wp-admin/users.php.

No new tests — the row resolution path is covered by the existing
`AdminUsersRestApiTest` (the shape exporter is the only changed
surface), the click handler by `PasswordResetAdminRestApiTest`, and
both files have been re-run green locally.
A successful WorkOS password_reset/confirm now also runs
`wp_set_password()` on the linked WP user (resolved via
`_workos_user_id` meta). 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 the mirror, the old WP password kept working after a WorkOS
rotation, which is both confusing and a quiet security regression:
anyone who knew the prior credential could still get in via
`wp_authenticate`.

Runs *before* LoginCompleter so the freshly-minted auth cookie
survives the session invalidation `wp_set_password()` performs. The
mirror is best-effort — an unlinked user no-ops cleanly, and the
WorkOS reset response is returned unchanged either way.

Three new test cases in `AuthKitRestPasswordTest`:
- mirrors the password (verified via `wp_authenticate` with old + new
  password)
- mirrors regardless of `auto_login_after_reset`
- silently skips when no WP user is linked
Two "looks-broken" windows on every AuthKit surface (wp-login.php
takeover, /workos/login/{profile}, [workos:login] shortcode):

  1. From first paint until the React bundle loads + executes — the
     mount div was empty, so the page showed nothing.
  2. After mount but before `client.bootstrap()` resolves — App.tsx
     returned `null`, so the page showed nothing.

Both now paint a card-shaped skeleton with shimmering placeholder
rows that mirror the real FlowCard shape and dimensions
one-to-one.

Server-side: `Renderer::build_skeleton_markup()` emits the
placeholder HTML inside the mount `<div>` (createRoot() replaces
the contents on hydration so this isn't double-rendered). The
context's `initial_step` / `reset_token` / `invitation_token`
pick between a 1-input and 2-input variant so the reset-confirm
two-password form swaps in without a layout jump. The `pick` step
omits the footer placeholder since the real method-picker has no
back link.

Client-side: new `FlowSkeleton` component in `ui.tsx` takes over
the `booted=false` branch in App.tsx, matching the same shape so
the bootstrap RTT looks intentional instead of broken.

Heights match the hydrated form exactly: heading 24px, subheading
20px, label 16px, input 44px, button 40px. Shimmer animates only
`background-position` (no transforms, no opacity) so the box
never resizes. Disabled under `prefers-reduced-motion`.

Adds three wpunit cases:
- `test_render_mount_embeds_skeleton_placeholder`
- `test_render_mount_skeleton_uses_two_inputs_for_reset_confirm`
- `test_render_mount_skeleton_omits_footer_on_pick`

Full suite green: 485 tests, 1386 assertions.
Comprehensive integration guide for the three password-reset surfaces:

- Self-service start/confirm endpoints (`/wp-json/workos/v1/auth/
  password/reset/{start,confirm}`).
- Admin-triggered endpoint (`/wp-json/workos/v1/admin/users/{id}/
  password-reset`) with the same-route self-service path via
  `edit_user($own_id)`.
- `[workos:password-reset]` shortcode.

Written for both human developers and LLM agents — endpoint specs,
profile policy, redirect URL validation rules, the password-strength
contract (server accepts anything; gate lives in the React shell or
your own UI), and the full activity-log shape.

Worked examples for every common entry point:

- Full PHP example: server-side admin trigger via `rest_do_request()`
  (with rationale for why callers should NOT skip straight to
  `workos()->api()->send_password_reset()`).
- Vanilla JS example: theme-side "Forgot password?" form with the
  correct profile-scoped `X-WorkOS-Nonce` header.
- React + TypeScript example: `SendResetButton` component with the
  correct WP REST `X-WP-Nonce` header and graceful error surfacing.

The "Don't do this" section catalogs the failure modes we've seen
(direct WorkOS calls bypassing the URL builder, header-name
collisions, echoing the raw email in UI, trusting unvalidated
`redirect_to` URL params, skipping the strength gate when rolling
your own form, assuming 200 = email exists, etc.).

Linked from README.md so the docs are discoverable next to the
existing `extending-the-login-ui.md` guide.
GitHub renders mermaid code blocks natively, so the rendered doc now
shows a proper sequence diagram with autonumbered steps, lanes per
participant, and an alt block for the auto_login_after_reset branch.

Same content as the prior ASCII; the mermaid form is also easier to
update and easier for code-gen agents to consume.
The trigger used to live in the username row actions (under each
user's name), mixed in with WP's built-in Edit / View / Delete
links. Move it next to "View in WorkOS" and "Re-sync" so every
WorkOS-specific per-row action clusters under one column header,
matching how the column already groups the dashboard link.

Implementation:
- `UserList::render_linked_column()` now builds its row actions as
  an array keyed by slug and exposes a new
  `workos_user_list_column_actions` filter, so extenders (including
  this plugin's own PasswordResetAdmin\RowActions) can append or
  reorder without forking the column markup. The early return for
  missing `environment_id` is gone — the filter must fire for every
  linked user regardless of whether a dashboard URL can be built.
- `PasswordResetAdmin\RowActions` now hooks
  `workos_user_list_column_actions` instead of `user_row_actions`.
  Same `.workos-pwreset-trigger` button, same delegated click
  handler — no JS changes.

Six new wpunit cases (PasswordResetAdminRowActionsTest):
- Filter fires for linked users with the right args
- Filter does NOT fire for unlinked users
- Trigger injected when caller has edit_user on the target
- Trigger skipped when capability missing
- Self-service path (edit_user(self)) gets the trigger
- Empty workos_id (defensive) is a no-op

Full suite green: 491 tests, 1416 assertions.
AGENTS.md
- New "Auth — Password Reset Admin" section in the Key Files table
  with one row per class (Controller, RestApi, RedirectValidator,
  RowActions, UserProfilePanel, Shortcode, Assets) + the
  admin-password-reset JS entry. Each row says what the class does
  AND what surface it powers.
- Refreshed entries whose responsibilities changed:
    * UserList.php — exposes `workos_user_list_column_actions`
    * Auth/PasswordReset.php — now a legacy guard, not the primary
      reset flow
    * AuthKit/Profile.php — carries the two reset toggles
    * AuthKit/FrontendRoute.php — exposes `url_for_profile()`
    * AuthKit/Renderer.php — staples password-strength-meter +
      emits pre-hydration skeleton
    * REST/Auth/Password.php — auto-login + WP password mirror +
      AuthKit-shell URL builder + final-URL entity decode
    * Admin/Users/{AdminPage,RestApi}.php — wp_user_id resolution
      + shared assets enqueue
    * src/js/authkit/App.tsx — FlowSkeleton during boot +
      onSignedIn/onMfa wiring for ResetConfirm
    * src/js/authkit/flows.tsx — two-password ResetConfirm +
      zxcvbn gate + dispatch on response shape
    * src/js/authkit/ui.tsx — adds FlowSkeleton
    * webpack.config.js — new admin-users + admin-password-reset
      entries
- Added the three new wpunit files
  (PasswordResetAdminRedirectValidatorTest,
  PasswordResetAdminRestApiTest, PasswordResetAdminRowActionsTest)
  to the test catalog with one-line summaries.

README.md
- New "Admin-triggered password reset" + "Skeleton placeholders"
  bullets under Features.
- Expanded "In-app flows" entry to mention the two-field
  confirm form + zxcvbn gate + redirect_url validation.
- New `auto_login_after_reset` row in the Login Profile fields
  table.

docs/password-reset.md
- New "Extending the WP Users list (WorkOS column)" section with
  a worked example of hooking `workos_user_list_column_actions`
  so third-party plugins can add their own per-row WorkOS actions.
@bordoni bordoni merged commit 283ab4d into main May 19, 2026
7 checks passed
@bordoni bordoni deleted the feature/workos-password-reset-admin branch May 19, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant