diff --git a/Docs/Documentation/AjaxAndHtmx.md b/Docs/Documentation/AjaxAndHtmx.md
new file mode 100644
index 00000000..38fbb0e2
--- /dev/null
+++ b/Docs/Documentation/AjaxAndHtmx.md
@@ -0,0 +1,221 @@
+Ajax, JSON & HTMX Responses
+===========================
+The plugin can return its user-facing actions (login, register, password flows, profile) as
+JSON — for SPA / programmatic consumers — or as HTML fragments driven by
+[HTMX](https://htmx.org/) — for modal / progressive-enhancement logins — in addition to the
+normal full-page HTML responses.
+
+The feature is **opt-in and disabled by default**. When disabled, the plugin behaves exactly
+as before and no extra middleware or component is loaded.
+
+Enabling
+--------
+Add this to your `config/users.php`:
+
+```php
+'Users.Ajax.enabled' => true,
+'Users.Ajax.skipFormProtectionForJson' => true, // default, see "FormProtection & CSRF"
+```
+
+The host application is responsible for loading the HTMX library if you use the HTMX channel —
+the plugin emits HTML attributes and response headers and ships no HTMX runtime of its own (the
+only script it ships is the optional reCaptcha glue, see [reCaptcha](#recaptcha)):
+
+```html
+
+```
+
+reCaptcha
+---------
+**HTMX channel:** reCaptcha works with HTMX. reCaptcha assumes a full-page lifecycle
+(load → render → submit → reload) that HTMX breaks in two places — a widget arriving via swap
+is never auto-rendered, and the one-time token is spent when the form re-renders without a
+reload — so, when the feature is enabled, `addReCaptcha()` loads a small glue script
+(`reCaptchaHtmx.js`) that bridges both, for the two versions the plugin supports:
+
+* **v2 (checkbox):** the glue renders every widget on load and after each swap
+ (`grecaptcha.render`); its token rides along in the serialized form, and a failed submit swaps
+ in a fresh widget, so no manual reset is needed.
+* **v3 (invisible):** the plugin drops its default button-bound flow (which does a native
+ `form.submit()` that bypasses HTMX) for a plain submit button plus a hidden
+ `g-recaptcha-response` field; on `htmx:confirm` the glue runs `grecaptcha.execute()` and
+ injects a fresh token into the request via `htmx:configRequest` before it is issued.
+
+Nothing changes server-side — the token is validated against `siteverify` exactly as for a
+normal form. This is all automatic once `Users.Ajax.enabled` is on; you only load HTMX itself.
+
+The **v3** HTMX rendering follows the plugin's ajax context: `addReCaptcha()`/`button()` switch to
+the HTMX flow only where `AjaxResponseComponent` is active (the plugin's ajax controllers set the
+`ajaxEnabled` view var), so a v3 reCaptcha on a form rendered elsewhere — a custom app form, or a
+non-plugin page — keeps the normal button-bound flow and works unchanged. The one case to avoid is
+stripping the `hx-*` attributes from an *overridden plugin template* while the feature is on: the
+reCaptcha would then render in HTMX mode but the form would submit natively with an empty token.
+(v2 is unaffected — its token comes from the checkbox regardless of how the form submits.)
+
+**JSON channel:** a programmatic JSON client runs no browser JavaScript and so can never
+produce a token — such a request always fails the reCaptcha check (a tokenless request is
+reported as a failed reCaptcha; note that `cakedc/auth` before the empty-token guard raised a
+`500` TypeError here instead). Disable reCaptcha on any form you expose over JSON — login
+(`Users.reCaptcha.login`) and registration (`Users.reCaptcha.registration`):
+
+```php
+'Users.reCaptcha.login' => false,
+'Users.reCaptcha.registration' => false,
+```
+
+How does it work
+----------------
+When enabled, a lightweight component and a thin middleware are loaded for the plugin's
+controllers. They inspect each request and adapt the response:
+
+* **JSON** — when the request sends `Accept: application/json`, the response body is JSON.
+* **HTMX** — when the request sends the `HX-Request` header (HTMX adds it automatically), the
+ response is an HTML fragment (no page skeleton), suitable for swapping into a modal or
+ container.
+* **Normal** — neither header present: behaves exactly as today (full-page HTML).
+
+JSON responses
+--------------
+CakePHP's `JsonView` serializes each key that is listed in `_serialize` at the **top level** —
+there is no `data` envelope.
+
+`GET /users/profile` (the only action that returns a populated user on success):
+
+```json
+{ "user": { "id": "...", "username": "...", "email": "..." }, "isCurrentUser": true, "success": true, "flash": null }
+```
+
+Actions that finish with a redirect — `login`, `register`, `changePassword`, `resetPassword`,
+two-factor `verify` — return the redirect target rather than a user object (the middleware
+intercepts the server-side redirect and rewrites it). The HTTP status is always `200`; read the
+`success` flag for the outcome:
+
+```json
+{ "success": true, "redirect": "http://example.com/dashboard", "flash": { "type": "success", "message": "..." } }
+```
+
+Not every plugin redirect means success — a failed two-factor `verify`, for instance, redirects
+back to the login action. When the action queued an error flash before redirecting, the middleware
+reports the redirect as a failure and echoes the message, so a client can tell the two apart:
+
+```json
+{ "success": false, "redirect": "http://example.com/login", "error": "Verification code is invalid. Try again", "flash": { "type": "error", "message": "..." } }
+```
+
+A SPA that needs the full user object after login can request `GET /users/profile` with
+`Accept: application/json`.
+
+A failed JSON **login** returns `401` (a two-factor `verify` failure is different — it
+redirects and is reported as a `200` with `success: false`; see [Notes & limitations](#notes--limitations)):
+
+```json
+{ "success": false, "error": "Username or password is incorrect", "flash": { "type": "error", "message": "..." } }
+```
+
+Validation failure (register, password, profile) returns `422`:
+
+```json
+{ "success": false, "errors": { "username": { "_required": "This field is required" } }, "flash": { "type": "error", "message": "..." } }
+```
+
+### Sensitive fields
+The `user` object never includes `password`, `token`, `secret` or `api_token` — these are
+hidden by the User entity's `$_hidden` list. To expose or hide more fields, edit `$_hidden` on
+your (extended) User entity.
+
+HTMX responses
+--------------
+With the feature enabled, the plugin's forms (`login`, `register`, `change_password`,
+`request_reset_password`, `verify`) emit HTMX attributes so they submit via HTMX and swap the
+response into a target container (default target `#ajax-login-container`).
+
+* On a successful login the response carries an `HX-Redirect` header, so HTMX performs a
+ full-page redirect.
+* On a validation/authentication failure the re-rendered form fragment is returned and swapped
+ back in, showing the flash error.
+
+### Full-page progressive enhancement
+When the feature is enabled, the plugin's own full-page views (`/login`, `/register`, the
+password flows, two-factor `verify`) render the form inside the `#ajax-login-container` swap
+target and carry the `hx-*` attributes. So — once the host app has loaded HTMX — the full page
+submits via HTMX and swaps in place instead of reloading: validation/auth failures swap the
+re-rendered fragment back into the container, and successes (as well as the two-factor
+challenge) navigate via `HX-Redirect`. If HTMX is not loaded the same form still carries a
+normal `action`/`method`, so it degrades gracefully to a standard full-page POST.
+
+The `#ajax-login-container` wrapper is emitted on the full page only; the HTMX fragment
+re-render (which is swapped *into* that container) omits it, so nothing nests or duplicates.
+
+Load HTMX from the host app — the plugin ships no HTMX runtime. A common pattern is to load it
+only while the feature is on, e.g. in your layout `
`:
+
+```php
+
+
+
+```
+
+### Using it in a modal
+1. Enable the feature and include HTMX (see [Enabling](#enabling)).
+2. Add a trigger that loads the login form fragment into your modal body:
+
+ ```html
+
+
+ ```
+3. The plugin returns the login form rendered with the `ajax` layout (fragment only).
+4. The form submits via HTMX; on success the `HX-Redirect` header redirects the page.
+
+> **reCaptcha in the modal flow:** the `ajax` layout renders only the content fragment, so the
+> reCaptcha `api.js` and glue that `addReCaptcha()` registers into the `script` block are dropped
+> from the fragment. When the login form is loaded into a modal on a page that is not itself one
+> of the plugin's reCaptcha pages, the host page must load `https://www.google.com/recaptcha/api.js`
+> (v3: `?render=`, v2: `?render=explicit`) and `CakeDC/Users.reCaptchaHtmx` itself, **and**
+> set the config the glue reads — `window.CakeDCUsersReCaptcha = {version: 2|3, siteKey: ''}`
+> (the glue gates every path on it) — or disable reCaptcha for that flow.
+
+FormProtection & CSRF
+---------------------
+The plugin protects its forms with CakePHP's `FormProtection` (field tampering) and CSRF
+components. The HTMX channel posts the rendered form, so both checks pass with no extra work.
+
+Programmatic JSON clients build the request body themselves and cannot satisfy `FormProtection`
+(it locks the exact rendered fields). When `Users.Ajax.skipFormProtectionForJson` is `true`
+(default), `FormProtection` is skipped for JSON-negotiated requests only; CSRF protection still
+applies — send the token in the `X-CSRF-Token` header (read it from the `csrfToken` cookie). Set
+the option to `false` to instead require JSON clients to round-trip the rendered form:
+
+```php
+'Users.Ajax.skipFormProtectionForJson' => false,
+```
+
+> ⚠️ **Mass-assignment on the JSON register.** On the HTML channel `FormProtection`'s field
+> locking is also what stops a client from POSTing columns the form never rendered. With
+> `skipFormProtectionForJson => true` that guard is gone for JSON, so a JSON `register` is bounded
+> only by the User entity's `$_accessible`. The plugin's default entity is `'*' => true`, which
+> lets a JSON registrant set `secret`/`secret_verified` (pre-seeding a known TOTP secret),
+> `api_token`, `additional_data`, and any custom column your app added to `users`. Before exposing
+> `register` over JSON, tighten `$_accessible` on your (extended) User entity — allow only the
+> real registration fields — or set `skipFormProtectionForJson => false` and round-trip the form.
+
+Events
+------
+This feature changes only response formatting. All plugin events
+(`Users.Authentication.afterLogin`, `Users.Global.afterRegister`, etc.) still fire exactly as
+before, so your existing listeners keep working. See [Events](Events.md).
+
+Notes & limitations
+-------------------
+* Session-based authentication only (no JWT / token issuance).
+* With two-factor login enabled (e.g. `OneTimePasswordAuthenticator.login`), a successful
+ password step redirects to the `verify` action for the second factor. The middleware wraps the
+ authentication and two-factor middleware, so that redirect is converted like any other: JSON
+ receives `{ "success": true, "redirect": "" }` and HTMX an `HX-Redirect` to the verify
+ page. A `success: true` here means "password accepted, now complete 2FA", not a finished login.
+* Two-factor `verify` failures redirect to the login action rather than returning a `401` body.
+ They are reported as `{ "success": false, "redirect": "", "error": "..." }` (HTTP `200`),
+ so a JSON client must check `success` rather than assume a redirect means the step passed.
+* The feature acts only on the configured Users controller's routes — it honors a custom
+ `Users.controller`, and your application's other routes are untouched.
+* `socialLogin` is an OAuth browser callback (full-page navigation), not a JSON/HTMX channel,
+ and is unaffected by this feature.
diff --git a/Docs/Home.md b/Docs/Home.md
index be8944f5..739cd134 100644
--- a/Docs/Home.md
+++ b/Docs/Home.md
@@ -23,6 +23,7 @@ Documentation
* [Two Factor Authenticator](Documentation/Two-Factor-Authenticator.md)
* [Webauthn Two-Factor Authentication (Yubico Key compatible)](Documentation/WebauthnTwoFactorAuthenticator.md)
* [Magic Link](Documentation/MagicLink.md)
+* [Ajax, JSON & HTMX Responses](Documentation/AjaxAndHtmx.md)
* [UserHelper](Documentation/UserHelper.md)
* [AuthLinkHelper](Documentation/AuthLinkHelper.md)
* [Events](Documentation/Events.md)
@@ -166,6 +167,18 @@ I want to
```
+ *
+ Ajax / JSON / HTMX responses
+
+ Add this to your config/users.php file to enable JSON and HTMX responses (disabled by default):
+
+ ```php
+ 'Users.Ajax.enabled' => true,
+ ```
+
+ See [Ajax, JSON & HTMX Responses](Documentation/AjaxAndHtmx.md) for details.
+
+
- allow access to
- [public actions (non-logged user)](./Documentation/Permissions.md#i-want-to-allow-access-to-public-actions-non-logged-user)
- [one specific action](./Documentation/Permissions.md#i-want-to-allow-access-to-one-specific-action)
diff --git a/config/users.php b/config/users.php
index 1210d07b..23721059 100644
--- a/config/users.php
+++ b/config/users.php
@@ -137,6 +137,14 @@
],
// Avatar placeholder
'Avatar' => ['placeholder' => 'CakeDC/Users.avatar_placeholder.png'],
+ 'Ajax' => [
+ // Master opt-in switch. When false the AjaxResponseComponent and
+ // AjaxRedirectMiddleware are never loaded; behavior is unchanged.
+ 'enabled' => false,
+ // Skip FormProtection for JSON-negotiated requests (field-locking is
+ // meaningless without a server-rendered form). CSRF stays on via header.
+ 'skipFormProtectionForJson' => true,
+ ],
'RememberMe' => [
// configure Remember Me component
'active' => true,
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 1550fe42..81ba24ac 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -12,12 +12,6 @@ parameters:
count: 1
path: src/Controller/Component/LoginComponent.php
- -
- message: '#^Call to function method_exists\(\) with Authentication\\Authenticator\\AuthenticatorInterface and ''getIdentifier'' will always evaluate to true\.$#'
- identifier: function.alreadyNarrowedType
- count: 1
- path: src/Controller/Component/LoginComponent.php
-
-
message: '#^Parameter \#1 \$request of method CakeDC\\Users\\Middleware\\SocialAuthMiddleware\:\:goNext\(\) expects Cake\\Http\\ServerRequest, Psr\\Http\\Message\\ServerRequestInterface given\.$#'
identifier: argument.type
@@ -60,6 +54,18 @@ parameters:
count: 1
path: src/Model/Behavior/LinkSocialBehavior.php
+ -
+ message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$id\.$#'
+ identifier: property.notFound
+ count: 5
+ path: src/Model/Behavior/OneTimeLoginLinkBehavior.php
+
+ -
+ message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$login_token_date\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Model/Behavior/OneTimeLoginLinkBehavior.php
+
-
message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$active\.$#'
identifier: property.notFound
@@ -78,6 +84,12 @@ parameters:
count: 1
path: src/Model/Behavior/RegisterBehavior.php
+ -
+ message: '#^Call to an undefined method Cake\\Datasource\\EntityInterface\:\:tokenExpired\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Model/Behavior/RegisterBehavior.php
+
-
message: '#^Call to an undefined method Cake\\ORM\\Table\:\:validationRegister\(\)\.$#'
identifier: method.notFound
@@ -90,6 +102,24 @@ parameters:
count: 1
path: src/Model/Behavior/SocialAccountBehavior.php
+ -
+ message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$active\.$#'
+ identifier: property.notFound
+ count: 2
+ path: src/Model/Behavior/SocialAccountBehavior.php
+
+ -
+ message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$token\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Model/Behavior/SocialAccountBehavior.php
+
+ -
+ message: '#^Access to an undefined property Cake\\Datasource\\EntityInterface\:\:\$user\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Model/Behavior/SocialAccountBehavior.php
+
-
message: '#^Method CakeDC\\Users\\Model\\Behavior\\SocialAccountBehavior\:\:resendValidation\(\) should return CakeDC\\Users\\Model\\Entity\\User but returns array\.$#'
identifier: return.type
@@ -102,6 +132,12 @@ parameters:
count: 1
path: src/Model/Behavior/SocialAccountBehavior.php
+ -
+ message: '#^Parameter \#1 \$socialAccount of method CakeDC\\Users\\Model\\Behavior\\SocialAccountBehavior\:\:_activateAccount\(\) expects CakeDC\\Users\\Model\\Entity\\SocialAccount, Cake\\Datasource\\EntityInterface given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Model/Behavior/SocialAccountBehavior.php
+
-
message: '#^Access to an undefined property Cake\\ORM\\Table\:\:\$SocialAccounts\.$#'
identifier: property.notFound
diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php
index 8869fdf1..24d30f34 100644
--- a/src/Controller/AppController.php
+++ b/src/Controller/AppController.php
@@ -14,6 +14,7 @@
namespace CakeDC\Users\Controller;
use App\Controller\AppController as BaseController;
+use Cake\Core\Configure;
/**
* AppController for Users Plugin
@@ -28,10 +29,28 @@ class AppController extends BaseController
public function initialize(): void
{
parent::initialize();
- $this->loadComponent('FormProtection');
+ if (!$this->shouldSkipFormProtection()) {
+ $this->loadComponent('FormProtection');
+ }
if ($this->request->getParam('_csrfToken') === false) {
$this->loadComponent('Csrf');
}
$this->loadComponent('CakeDC/Users.Setup');
+ if (Configure::read('Users.Ajax.enabled')) {
+ $this->loadComponent('CakeDC/Users.AjaxResponse');
+ }
+ }
+
+ /**
+ * Skip FormProtection only for JSON-negotiated ajax requests (field-locking is
+ * meaningless without a server-rendered form). CSRF protection still applies.
+ *
+ * @return bool
+ */
+ protected function shouldSkipFormProtection(): bool
+ {
+ return Configure::read('Users.Ajax.enabled')
+ && Configure::read('Users.Ajax.skipFormProtectionForJson')
+ && $this->request->is('json');
}
}
diff --git a/src/Controller/Component/AjaxResponseComponent.php b/src/Controller/Component/AjaxResponseComponent.php
new file mode 100644
index 00000000..c666c0b6
--- /dev/null
+++ b/src/Controller/Component/AjaxResponseComponent.php
@@ -0,0 +1,169 @@
+ 'beforeRender',
+ UsersPlugin::EVENT_AFTER_LOGIN_FAILURE => 'afterLoginFailure',
+ ];
+ }
+
+ /**
+ * Is the current request an HTMX request?
+ *
+ * @return bool
+ */
+ protected function isHtmx(): bool
+ {
+ return $this->getController()->getRequest()->hasHeader('HX-Request');
+ }
+
+ /**
+ * Does the current request negotiate JSON?
+ *
+ * @return bool
+ */
+ protected function isJson(): bool
+ {
+ return $this->getController()->getRequest()->is('json');
+ }
+
+ /**
+ * Flag a JSON login failure so beforeRender() can shape a 401 response.
+ * (HTMX login failures re-render the form fragment, so they are not flagged here.)
+ *
+ * @param \Cake\Event\EventInterface $event The afterLoginFailure event.
+ * @return void
+ */
+ public function afterLoginFailure(EventInterface $event): void
+ {
+ if (!$this->isJson()) {
+ return;
+ }
+ $this->loginFailed = true;
+ $controller = $this->getController();
+ $controller->setResponse($controller->getResponse()->withStatus(401));
+ }
+
+ /**
+ * Apply HTMX layout / JSON view negotiation just before rendering.
+ *
+ * @param \Cake\Event\EventInterface $event The beforeRender event.
+ * @return void
+ */
+ public function beforeRender(EventInterface $event): void
+ {
+ $controller = $this->getController();
+ // This component is only loaded while the feature is enabled, so the plugin's
+ // forms are always HTMX-enhanced. `ajaxFragment` tells a full-page render
+ // (which emits the outer #ajax-login-container swap target) from an HTMX
+ // fragment re-render (which is swapped *into* that container, so omits it).
+ $controller->set('ajaxEnabled', true);
+ $controller->set('ajaxFragment', $this->isHtmx());
+
+ if ($this->isHtmx()) {
+ $controller->viewBuilder()->setLayout('ajax');
+
+ return;
+ }
+
+ if ($this->isJson()) {
+ $this->renderJson($controller);
+ }
+ }
+
+ /**
+ * Switch the view to JSON and assemble the response envelope.
+ *
+ * @param \Cake\Controller\Controller $controller The controller being rendered.
+ * @return void
+ */
+ protected function renderJson(Controller $controller): void
+ {
+ $builder = $controller->viewBuilder();
+ $builder->setClassName('Json');
+
+ $flash = AjaxFlash::consume($controller->getRequest()->getSession());
+ $controller->set('flash', $flash);
+
+ if ($this->loginFailed) {
+ $controller->set('success', false);
+ $controller->set('error', $flash['message'] ?? __d('cake_d_c/users', 'Authentication failed'));
+ $builder->setOption('serialize', ['success', 'error', 'flash']);
+
+ return;
+ }
+
+ $errorEntity = $this->findEntityWithErrors($builder->getVars());
+ if ($errorEntity !== null) {
+ $controller->set('success', false);
+ $controller->set('errors', $errorEntity->getErrors());
+ $controller->setResponse($controller->getResponse()->withStatus(422));
+ $builder->setOption('serialize', ['success', 'errors', 'flash']);
+
+ return;
+ }
+
+ $controller->set('success', true);
+ $existing = (array)$builder->getOption('serialize');
+ $builder->setOption('serialize', array_values(array_unique(array_merge($existing, ['success', 'flash']))));
+ }
+
+ /**
+ * Find the first view var that is an entity carrying validation errors.
+ *
+ * Keyed off "has errors" rather than a fixed var name so any action's entity
+ * (register's `user`, a profile form, etc.) drives the 422 response.
+ *
+ * @param array $vars The view vars set on the controller.
+ * @return \Cake\Datasource\EntityInterface|null The entity with errors, or null.
+ */
+ protected function findEntityWithErrors(array $vars): ?EntityInterface
+ {
+ foreach ($vars as $value) {
+ if ($value instanceof EntityInterface && $value->hasErrors()) {
+ return $value;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Identifier/SocialIdentifier.php b/src/Identifier/SocialIdentifier.php
index bf05da41..2a369a88 100644
--- a/src/Identifier/SocialIdentifier.php
+++ b/src/Identifier/SocialIdentifier.php
@@ -21,9 +21,6 @@
class SocialIdentifier extends AbstractIdentifier
{
- /**
- * @use \Cake\Event\EventDispatcherTrait<\CakeDC\Users\Identifier\SocialIdentifier>
- */
use EventDispatcherTrait;
use LocatorAwareTrait;
diff --git a/src/Loader/MiddlewareQueueLoader.php b/src/Loader/MiddlewareQueueLoader.php
index eff36fd8..679836c7 100644
--- a/src/Loader/MiddlewareQueueLoader.php
+++ b/src/Loader/MiddlewareQueueLoader.php
@@ -22,6 +22,7 @@
use Cake\Http\MiddlewareQueue;
use CakeDC\Auth\Authentication\TwoFactorProcessorLoader;
use CakeDC\Auth\Middleware\TwoFactorMiddleware;
+use CakeDC\Users\Middleware\AjaxRedirectMiddleware;
use CakeDC\Users\Middleware\SocialAuthMiddleware;
use CakeDC\Users\Middleware\SocialEmailMiddleware;
@@ -51,6 +52,12 @@ public function __invoke(
AuthenticationServiceProviderInterface $authenticationServiceProvider,
AuthorizationServiceProviderInterface $authorizationServiceProvider,
) {
+ // Ajax middleware must wrap the authentication, two-factor and authorization
+ // middleware so it can rewrite the redirects THEY emit (e.g. a successful
+ // login redirecting into the two-factor `verify` action, or an unauthorized
+ // access redirecting to login) into JSON / HX-Redirect responses. Loading it
+ // first makes it the outermost of the plugin middleware.
+ $this->loadAjaxMiddleware($middlewareQueue);
$this->loadSocialMiddleware($middlewareQueue);
$this->loadAuthenticationMiddleware($middlewareQueue, $authenticationServiceProvider);
$this->load2faMiddleware($middlewareQueue);
@@ -102,6 +109,19 @@ protected function load2faMiddleware(MiddlewareQueue $middlewareQueue)
}
}
+ /**
+ * Load AjaxRedirectMiddleware when 'Users.Ajax.enabled' is true.
+ *
+ * @param \Cake\Http\MiddlewareQueue $middlewareQueue queue of middleware
+ * @return void
+ */
+ protected function loadAjaxMiddleware(MiddlewareQueue $middlewareQueue)
+ {
+ if (Configure::read('Users.Ajax.enabled')) {
+ $middlewareQueue->add(new AjaxRedirectMiddleware());
+ }
+ }
+
/**
* Load authorization middleware based on Auth.Authorization.
*
diff --git a/src/Middleware/AjaxRedirectMiddleware.php b/src/Middleware/AjaxRedirectMiddleware.php
new file mode 100644
index 00000000..794a6700
--- /dev/null
+++ b/src/Middleware/AjaxRedirectMiddleware.php
@@ -0,0 +1,93 @@
+ 200 with an `HX-Redirect` header (HTMX performs the redirect client-side).
+ * - JSON request -> 200 with `{"success": , "redirect": "", "flash": ...}`.
+ * `success` is false (and an `error` message is added) when the plugin queued an
+ * error flash before redirecting — e.g. a failed two-factor `verify`, which
+ * redirects back to the login action. A redirect alone never implies success.
+ *
+ * The middleware echoes the response's existing `Location` verbatim; it never
+ * builds a destination from request input, so it adds no open-redirect surface of
+ * its own. For the login flow that `Location` was already host-validated by
+ * LoginComponent::afterIdentifyUser(); the other plugin redirects target fixed
+ * plugin actions.
+ */
+class AjaxRedirectMiddleware implements MiddlewareInterface
+{
+ /**
+ * @param \Psr\Http\Message\ServerRequestInterface $request The request.
+ * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
+ * @return \Psr\Http\Message\ResponseInterface A response.
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $response = $handler->handle($request);
+
+ // Scope to the configured Users controller (honors a custom `Users.controller`)
+ // rather than a hardcoded plugin name, so an app controller is not left in a
+ // half-enabled state where the component/templates emit hx-* but redirects
+ // are never converted.
+ /** @var \Cake\Http\ServerRequest $request */
+ $scope = UsersUrl::actionParams('login');
+ if (
+ $request->getParam('plugin') !== $scope['plugin']
+ || $request->getParam('controller') !== $scope['controller']
+ ) {
+ return $response;
+ }
+
+ $location = $response->getHeaderLine('Location');
+ $status = $response->getStatusCode();
+ if ($status < 300 || $status >= 400 || $location === '') {
+ return $response;
+ }
+
+ /** @var \Cake\Http\Response $response */
+ if ($request->hasHeader('HX-Request')) {
+ return $response
+ ->withStatus(200)
+ ->withoutHeader('Location')
+ ->withHeader('HX-Redirect', $location);
+ }
+
+ if ($request->is('json')) {
+ $flash = AjaxFlash::consume($request->getSession());
+ $success = ($flash['type'] ?? null) !== 'error';
+ $payload = ['success' => $success, 'redirect' => $location, 'flash' => $flash];
+ if (!$success) {
+ $payload['error'] = $flash['message'];
+ }
+
+ return $response
+ ->withStatus(200)
+ ->withoutHeader('Location')
+ ->withType('application/json')
+ ->withStringBody((string)json_encode($payload));
+ }
+
+ return $response;
+ }
+}
diff --git a/src/Model/Behavior/SocialBehavior.php b/src/Model/Behavior/SocialBehavior.php
index 7ad6258a..47116eb9 100644
--- a/src/Model/Behavior/SocialBehavior.php
+++ b/src/Model/Behavior/SocialBehavior.php
@@ -31,9 +31,6 @@
*/
class SocialBehavior extends BaseTokenBehavior
{
- /**
- * @use \Cake\Event\EventDispatcherTrait<\CakeDC\Users\Model\Behavior\SocialBehavior>
- */
use EventDispatcherTrait;
use RandomStringTrait;
diff --git a/src/Utility/AjaxFlash.php b/src/Utility/AjaxFlash.php
new file mode 100644
index 00000000..7a964360
--- /dev/null
+++ b/src/Utility/AjaxFlash.php
@@ -0,0 +1,69 @@
+ element `error`, `success()` ->
+ * `success`, etc.).
+ *
+ * `consume()` drains the whole Flash stack on purpose: a JSON/ajax response is a
+ * terminal transition for the client, so any queued message is delivered in the
+ * body and must not linger to be rendered on a later full-page load.
+ */
+class AjaxFlash
+{
+ /**
+ * Read the first pending flash message and clear the Flash stack.
+ *
+ * @param \Cake\Http\Session $session The session holding the Flash stack.
+ * @return array|null `['type' => 'error'|'success'|'info', 'message' => string]`,
+ * or null when no flash is set.
+ */
+ public static function consume(Session $session): ?array
+ {
+ $stack = $session->read('Flash');
+ if (empty($stack)) {
+ return null;
+ }
+
+ $result = null;
+ foreach ($stack as $messages) {
+ foreach ((array)$messages as $entry) {
+ if (!is_array($entry) || !isset($entry['message'])) {
+ continue;
+ }
+ $element = (string)($entry['element'] ?? '');
+ $type = 'info';
+ if (str_contains($element, 'error')) {
+ $type = 'error';
+ } elseif (str_contains($element, 'success')) {
+ $type = 'success';
+ }
+ $result = ['type' => $type, 'message' => $entry['message']];
+ break 2;
+ }
+ }
+ $session->delete('Flash');
+
+ return $result;
+ }
+}
diff --git a/src/View/Helper/UserHelper.php b/src/View/Helper/UserHelper.php
index 0a41301b..7018bd12 100644
--- a/src/View/Helper/UserHelper.php
+++ b/src/View/Helper/UserHelper.php
@@ -19,6 +19,7 @@
use Cake\View\Helper;
use Cake\View\StringTemplateTrait;
use CakeDC\Users\Utility\UsersUrl;
+use Exception;
use InvalidArgumentException;
/**
@@ -198,6 +199,21 @@ public function addPasswordMeter(): string
$this->Html->tag('div', '', ['id' => 'pswmeter-message']) . $script;
}
+ /**
+ * Whether the current render is the plugin's HTMX-enhanced ajax context.
+ *
+ * Gated on the `ajaxEnabled` view var (set by AjaxResponseComponent only for the
+ * plugin's ajax-enabled controllers) rather than the raw `Users.Ajax.enabled`
+ * config, so reCaptcha on a form rendered outside that context — a custom app
+ * form, or a non-plugin page — keeps its normal (non-HTMX) rendering and works.
+ *
+ * @return bool
+ */
+ protected function isAjaxHtmxContext(): bool
+ {
+ return (bool)$this->getView()->get('ajaxEnabled');
+ }
+
/**
* Add reCaptcha to the form
*
@@ -214,20 +230,69 @@ public function addReCaptcha(): mixed
),
);
}
- $this->addReCaptchaScript();
$version = Configure::read('Users.reCaptcha.version', 2);
- $method = "addReCaptchaV$version";
- if (method_exists($this, $method)) {
- try {
- $this->Form->unlockField('g-recaptcha-response');
- } catch (\Exception $e) {
- }
+ if (!in_array($version, [2, 3, '2', '3'], true)) {
+ throw new InvalidArgumentException(
+ __d('cake_d_c/users', 'reCaptcha version is wrong. Please configure Users.reCaptcha.version as 2 or 3'),
+ );
+ }
+ $version = (int)$version;
+ try {
+ $this->Form->unlockField('g-recaptcha-response');
+ } catch (Exception $e) {
+ }
- return $this->{$method}();
+ // In the plugin's HTMX render, reCaptcha's page-load lifecycle breaks: a v2
+ // widget swapped into the DOM is never auto-rendered, and v3's default
+ // button-bound flow submits the form natively (bypassing HTMX). There the
+ // integration renders differently and loads a glue script instead. Outside
+ // that render (a custom app form) the normal reCaptcha is kept.
+ if ($this->isAjaxHtmxContext()) {
+ return $this->addReCaptchaHtmx($version);
}
- throw new InvalidArgumentException(
- __d('cake_d_c/users', 'reCaptcha version is wrong. Please configure Users.reCaptcha.version as 2 or 3'),
+
+ $this->addReCaptchaScript();
+
+ return $this->{"addReCaptchaV$version"}();
+ }
+
+ /**
+ * Render reCaptcha for the AJAX/HTMX channel and load the glue script that
+ * bridges Google's page-load lifecycle to HTMX:
+ * - v2: the checkbox widget is rendered by the glue on load and after every
+ * swap (`grecaptcha.render`), and its token rides along in the serialized form.
+ * - v3: a hidden `g-recaptcha-response` field is filled by the glue from
+ * `grecaptcha.execute()` on `htmx:confirm`, just before the request is issued
+ * (a fresh token per submit, so the single-use limitation is handled too).
+ *
+ * @param int $version reCaptcha version (2 or 3)
+ * @return string markup to place inside the form
+ */
+ private function addReCaptchaHtmx(int $version): string
+ {
+ $key = (string)Configure::read('Users.reCaptcha.key');
+ // v3 needs api.js loaded with ?render= so execute() is available;
+ // v2 needs ?render=explicit so the glue controls when widgets render.
+ $apiUrl = $version === 3
+ ? 'https://www.google.com/recaptcha/api.js?render=' . urlencode($key)
+ : 'https://www.google.com/recaptcha/api.js?render=explicit';
+ $this->Html->script($apiUrl, ['block' => 'script']);
+ $this->Html->scriptBlock(
+ sprintf('window.CakeDCUsersReCaptcha = {version: %d, siteKey: %s};', $version, json_encode($key)),
+ ['block' => 'script'],
);
+ $this->Html->script('CakeDC/Users.reCaptchaHtmx', ['block' => 'script']);
+
+ if ($version === 3) {
+ return $this->Form->hidden('g-recaptcha-response', ['id' => false]);
+ }
+
+ return $this->Html->tag('div', '', [
+ 'class' => 'g-recaptcha',
+ 'data-sitekey' => $key,
+ 'data-theme' => Configure::read('Users.reCaptcha.theme') ?: 'light',
+ 'data-size' => Configure::read('Users.reCaptcha.size') ?: 'normal',
+ ]);
}
/**
@@ -268,7 +333,15 @@ private function addReCaptchaV3(): void
public function button(string $title, array $options = []): string
{
$key = Configure::read('Users.reCaptcha.key');
- if ($key && Configure::read('Users.reCaptcha.version', 2) === 3) {
+ // The v3 "button-bound" flow (grecaptcha binds the click and calls onSubmit,
+ // which does a native form.submit()) bypasses HTMX. In the plugin's HTMX
+ // render the button stays a plain submit and reCaptchaHtmx.js runs execute()
+ // on htmx:confirm instead; outside it (a custom app form) the button-bound
+ // native flow is kept. See addReCaptchaHtmx().
+ if (
+ $key && (int)Configure::read('Users.reCaptcha.version', 2) === 3
+ && !$this->isAjaxHtmxContext()
+ ) {
$options = array_merge($options, [
'class' => 'g-recaptcha',
'data-sitekey' => $key,
diff --git a/templates/Users/change_password.php b/templates/Users/change_password.php
index 233343c2..cd4288a5 100644
--- a/templates/Users/change_password.php
+++ b/templates/Users/change_password.php
@@ -1,6 +1,15 @@
+
+
+