feat(authkit): admin-triggered password reset with redirect_url (1.0.5)#21
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 viaPOST /wp-json/workos/v1/admin/users/{id}/password-reset. The endpoint is rate-limited per-IP and per-target and writes apassword_reset.admin_sentevent to the activity log.Send WorkOS password resetrow action onwp-admin/users.php, aPassword Resetpanel on the user-edit screen, and a new[workos:password-reset]shortcode that toggles between admin-of-other (user=\"…\") and self-service (nouserattr) modes.redirect_urlparameter that threads through every layer: validated same-host againsthome_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./workos/login/{slug}?token=…&redirect_to=…) instead ofwp-login.php. The oldwp-login.php?workos_action=reset-passwordURL still resolves cleanly viaLoginTakeover, so any reset emails already in users' inboxes keep working.Fixes CONS-287 (Linear) where the post-reset URL was hardcoded.
Screenshots:
What's in the box
New (under
src/WorkOS/Auth/PasswordResetAdmin/)Controller.php— DI controller wired intoWorkOS\Controller.RestApi.php—POST /workos/v1/admin/users/{id}/password-reset. Capability:edit_useron the target. Validates link to WorkOS, applies rate limits, validatesredirect_url, firesApi\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.php—user_row_actionshook forwp-admin/users.php.UserProfilePanel.php— adds a "Password Reset" panel + button to the user-edit screen, late onedit_user_profile/show_user_profileso the existing read-only WorkOS panel renders first.Shortcode.php—[workos:password-reset]withuser,redirect_url,label,profileattributes. Admin-of-other and self-service modes via theuserattribute (omitted = self).Assets.php— registers the click-handler JS + styles. Auto-enqueues onusers.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.php—reset_startandreset_confirmaccept optionalredirect_url;build_password_reset_url()now points at the AuthKit frontend route (waswp-login.php) and appends a validatedredirect_to;reset_confirmreturns the validatedredirect_urlin the response.src/WorkOS/Auth/AuthKit/FrontendRoute.php— exposesurl_for_profile( Profile, array \$args = [] ): stringhelper used by both password-reset URL builders.src/WorkOS/Controller.php— registersPasswordResetAdmin\Controller.src/js/authkit/flows.tsx—ResetRequestandResetConfirmthreadredirect_to/redirect_urlto/from the server; on confirm-success, navigate to the server-validated URL if present.webpack.config.js— newadmin-password-resetentry.CHANGELOG.md,readme.txt,integration-workos.php,src/WorkOS/Plugin.php— bump version to 1.0.5 and document the change.Security notes
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.RedirectValidatorrejects cross-origin redirects, non-http(s) schemes, and protocol-relative//evil.examplestrings. Falls back to the profile'spost_login_redirect, thenhome_url('/').EventLogger::log( 'password_reset.admin_sent', … )captures initiator, target, profile, redirect_url, and aself_serviceflag.mask_email()returnsj•••@e•••.com.Test plan
wp-admin/users.phprow action against a WorkOS-linked user → success notice with masked email.[workos:password-reset user=\"42\" redirect_url=\"/dashboard\"]on a page (admin-of-other mode).[workos:password-reset]placed on a frontend page while logged in./workos/login/{slug}?token=…&redirect_to=…with the AuthKit reset form. Submit a new password → confirmation card appears → "Continue" navigates to the validatedredirect_url.redirect_urlpointing off-site (e.g.https://evil.example/whatever) is rejected by the validator and falls back to the profile/home URL.wp-login.php?workos_action=reset-password&profile=default&token=…still works (LoginTakeover routes it into the React shell).password_reset_flow=falseon a profile makes both the admin endpoint and the public start/confirm endpoints returnworkos_reset_disabled.retry_after.edit_useron the target → 403._workos_user_id→ 409workos_user_not_linked.password_reset.admin_sentrows after triggers, including the initiator + self-service flag inmetadata.Follow-ups (not in this PR)
main. Single small commit on top once feat(admin): WorkOS Users list page (1.0.5) #20 lands.RedirectValidator, the admin REST endpoint (cap matrix, rate limit, redirect validation), and the URL builder. Worth a follow-up.