Skip to content

Fix sign_in_after_change_password? when :registerable is not included#5828

Closed
mattmenefee wants to merge 1 commit intoheartcombo:mainfrom
mattmenefee:fix-sign-in-after-change-password-without-registerable
Closed

Fix sign_in_after_change_password? when :registerable is not included#5828
mattmenefee wants to merge 1 commit intoheartcombo:mainfrom
mattmenefee:fix-sign-in-after-change-password-without-registerable

Conversation

@mattmenefee
Copy link

@mattmenefee mattmenefee commented Feb 19, 2026

Summary

  • Fix NoMethodError in RegistrationsController#sign_in_after_change_password? when the resource model does not include the :registerable module
  • Fall back to the global Devise.sign_in_after_change_password config when resource_class doesn't respond to the method
  • Add controller test verifying the fallback behavior

Context

PR #5825 (released in 5.0.2) introduced per-model override for sign_in_after_change_password via the :registerable module. However, the RegistrationsController can be subclassed and used even when :registerable is not included — a common pattern to allow password updates while preventing self-registration.

In this case, resource_class.sign_in_after_change_password raises:

NoMethodError: undefined method 'sign_in_after_change_password' for class User

The fix uses respond_to? to gracefully fall back to the global Devise config when the model-level method isn't available.

Why this use case should be supported

Devise's own architecture encourages this pattern. The RegistrationsController is explicitly designed to be subclassed (Devise even provides a generator for custom controllers), and the devise_for helper supports skip: :registrations with custom devise_scope routes — making it straightforward to use the registrations controller for password management while disabling self-registration:

# routes.rb
devise_for :users, skip: :registrations

devise_scope :user do
  get  'profile/password', to: 'users/registrations#edit'
  patch :users,            to: 'users/registrations#update'
end
# User model — :registerable intentionally excluded
devise :database_authenticatable, :confirmable, :recoverable, :rememberable, :validatable

This worked in Devise 5.0.0 and 5.0.1. The regression in 5.0.2 breaks any app using this documented routing pattern.

Test plan

  • Add controller test that stubs resource_class with a plain class (no :registerable) and verifies sign_in_after_change_password? falls back to global Devise config
  • Test covers both true and false values of Devise.sign_in_after_change_password
  • Verify test fails with NoMethodError when the fix is reverted

Fixes #5827

@mattmenefee mattmenefee force-pushed the fix-sign-in-after-change-password-without-registerable branch from 7af3243 to ec289ce Compare February 19, 2026 07:31
The `sign_in_after_change_password?` method in `RegistrationsController`
calls `resource_class.sign_in_after_change_password`, which is only
defined by the `:registerable` module via `Devise::Models.config`.

When a model does not include `:registerable` (e.g. to prevent
self-registration while still using the registrations controller for
password changes), this raises a NoMethodError.

Fall back to the global `Devise.sign_in_after_change_password` config
when the resource class doesn't respond to the method.

Add a controller test verifying the fallback uses the global Devise
config when the model does not include `:registerable`.

Fixes heartcombo#5827
@mattmenefee mattmenefee force-pushed the fix-sign-in-after-change-password-without-registerable branch from ec289ce to 2c89089 Compare February 19, 2026 07:33
@carlosantoniodasilva
Copy link
Member

Thanks for the very detailed issue and PR!

The fix here is actually to include the :registerable module. Devise definitely encourages the subclassing of controllers, and you can skip routes or an entire controller if you want... but the controllers are expected to go hand in hand with their respective modules.

You can't use passwords controller without recoverable, confirmations controller without confirmable, and so on. Same for registrations. If you don't include the module in the model, you'll likely run into trouble with any of the other
modules, or you'll have to provide the necessary hooks / model methods yourself. The change in #5825 fixed a bug which just made that required connection between module <-> controller clearer for registrations.

Also note: if you don't include the module, the routes aren't generated by Devise, so skip: :registrations actually is a no-op. (the model controls which modules are included and thus which routes get generated). That means this:

# routes.rb
devise_for :users, skip: :registrations

devise_scope :user do
  get  'profile/password', to: 'users/registrations#edit'
  patch :users,            to: 'users/registrations#update'
end

# User model — :registerable intentionally excluded
devise :database_authenticatable, :confirmable, :recoverable, :rememberable, :validatable

Is essentially mapping a couple routes to your own users/registrations controller, which happen to inherit from Devise's registrations controller, but Devise itself doesn't know you're using registrations... and it needs to.

registerable is a very thin module, adds almost nothing to the model, and you're already skipping the routes so I can't think of any issue that could happen including it... but you can also override sign_in_after_change_password? in the controller if you want/prefer.

@mattmenefee
Copy link
Author

mattmenefee commented Feb 19, 2026

Thanks for the thorough explanation, @carlosantoniodasilva! That clarifies the intended design.

The insight about skip: :registrations being a no-op without the module is particularly helpful — it makes the module/controller coupling clear. I hadn't realized the route generation was tied that tightly to the included modules.

Adding :registerable to the model (with the existing route skip) is a clean solution and what I'll go with. Since the module is lightweight and the routes are already handled separately, there's no downside.

Thanks again for taking the time to explain the design rationale!

@jtFrancisco
Copy link

I had the same issue. Are you planning on updating the README to explain the breaking consequences of not having :registrable or in the release notes. I think a lot of other people will run into this same issue.

@carlosantoniodasilva
Copy link
Member

carlosantoniodasilva commented Mar 5, 2026

@jtFrancisco sorry you ran into this as well... honestly I'm not sure where in the README I'd try to explain that. Devise modules are defined in the model, the routes/controllers options are mainly for customizations.

It happened to work before because registerable was not using one of its own attributes in the controller. Essentially the behavior described as the issue here is not much different than this Rails route, for example:

get "up" => "rails/health#show", as: :rails_health_check

i.e. pointing to a controller that's exposed by the framework/library. Except Rails does not have the "model" module contract, Devise does (or it was supposed to).

I guess we could maybe expand the CHANGELOG to mention that people might run into this issue if they are pointing routes to the Devise registrations controller without the module defined? (and that it should be explicitly defined in that case) Would that work/help?

carlosantoniodasilva added a commit that referenced this pull request Mar 6, 2026
Check the related issue for more info and explanation:
#5828 (comment)

[ci skip]
@carlosantoniodasilva
Copy link
Member

Added a note: 3fd0610

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

RegistrationsController#sign_in_after_change_password? breaks when :registerable is not included

3 participants