Devise 使用 Rails アプリで、パスワード変更機能をユーザー情報編集から分離➡パスワードリセットと統合してみた話

## 概要（やってみたこと）

1. ユーザー編集画面で、パスワードとそれ以外の属性の編集画面を別々にする
1. パスワード**以外**の編集時は確定時のパスワード再入力を不要とする
1. パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする

末筆の通りあまりお行儀は良くない気がするので、コードの量より分かりやすさが重要な状況（大規模プロジェクトetc.）には向かないと思います。
コード量抑制を重視する状況下では使いどころもあるかもしれませんが、こんなやり方もできたという一種の頭の体操的に捉えて頂けますと幸いです🙏

なお前提として、本稿内では Devise の認証対象のモデルは「`User`」としています。
モデル名が異なる場合は適宜読み替えてください。


## ソース・環境

ソース全文・全履歴は下記になります。なるべく対応内容ごとの変化が分かりやすいようにコミット単位も調整しました。

https://github.com/k-yamauchi-1/passwords_edit_with_devise

ローカル環境は Windows 10 Pro、Ruby3.2.2、Rails7.1.2


## 各段階の詳細手順

### 前準備

:::note info
既に Devise 導入が完了しているプロジェクトであれば不要な手順だと思いますが
断片的に必要な部分もあるかもしれません（カスタマイズ対象コントローラの追加etc.）ので各々ご判断ください
:::

Gemfile に `devise` （と、必要なら `devise-i18n`）を追加した上で下記を実行します。
（この辺りは devise 導入の基本的な手順なので詳細は割愛します）
```bash
$ bundle install
$ rails g devise:install
$ rails g devise User
```
Userモデル `app/models/user.rb` とそのマイグレーションファイル `db/migration/YYYYMMDDHHmmss_devise_create_users.rb` について `recoverable` モジュールが有効化されていることを確認します[^1]。

User（認証対象）モデルにメール・パスワード以外の属性を持たせる場合は[マイグレーションファイルに追記](https://github.com/k-yamauchi-1/passwords_edit_with_devise/commit/36efa219fae5b0621e58b622f02cfaf49938f11c)[^2]（今回のサンプルコードでは必須属性として名前：`name`、任意属性として職業：`job`・趣味：`hobby` を追加）します。
追記が終わったら（なければそのまま）続いて下記を実行します。
```bash
$ rails db:migrate
$ rails g devise:views users
$ rails g devise:controllers users -c registrations passwords
```
カスタマイズが必要なコントローラは最後のコマンドの通り `registrations` と `passwords` なので[この2つを生成し、ルーティングの設定を行います](https://github.com/k-yamauchi-1/passwords_edit_with_devise/commit/bcb22aff20c373fc1f8ad92d0938560176fd98ec)。
```diff_ruby:config/routes.rb
- devise_for :users
+ devise_for :users, controllers: {
+ 　registrations: 'users/registrations',
+ 　passwords: 'users/passwords'
+ }
```


### ① [パスワードとそれ以外の属性の編集画面を別々にする（ユーザー編集画面/アクションをパスワード以外の属性の編集に特化）](https://github.com/k-yamauchi-1/passwords_edit_with_devise/commit/7a241b33b2f90dde8ee668c99c79bc9f04ea5a02)

Devise 元々の（＝`$ rails g devise:views users`で作られる）ユーザー情報編集ビュー `users/registrations/edit.html.erb` はメール（email）とパスワード（現在/変更/確認）の入力欄しかないので、
それ以外の属性の入力欄をフォームに追加する一方でパスワード関連の欄3つ：`password`, `password_confirmation`, `current_password` は削除します。

また対応するアクションは `RegistrationsController` の `update` アクションになります。
これ自体は Devise 元々の実装のままでいいのでオーバーライド（カスタマイズ）は不要（`def update` 以下自体記述の必要なし、敢えて明記したい場合は下記）ですが、
```ruby:app/controllers/users/registrations_controller.rb
# 以下まるまる書かなくても違いはない
def update
  super
end
```
追加の属性がストロングパラメータに入っていなくそのままだと送信できない（弾かれてしまう）ので、今回の例であれば `name`, `job`, `hobby` を下記の通り追加します[^3]。
```ruby:app/controllers/users/registrations_controller.rb
# If you have extra params to permit, append them to the sanitizer.
def configure_sign_up_params  # 編集用ではなく新規登録用だが追加属性あるなら必要
  devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :job, :hobby])
end

# If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params
  devise_parameter_sanitizer.permit(:account_update, keys: [:name, :job, :hobby])
end
```

パスワード変更機能は `passwords_controller#update` アクション及び `users/passwords` ビューへ移しますが、これについては要の部分なので後述します。

### ② [パスワード以外の編集時は確定時のパスワード再入力を不要とする](https://github.com/k-yamauchi-1/passwords_edit_with_devise/commit/fcbd83b57ce9e58e57535c852918436d0b0deff7)

Devise の元々の仕様ではユーザー情報編集時にはパスワードの入力が必要なのでこのままでは動きません。
前項で `RegistrationsController#update` はオーバーライド不要――と書いたのは嘘ではないですが、その中から呼ばれている `update_resource` メソッドを下記の通りオーバーライドすることで意図する挙動に変更する必要があります。
```ruby:app/controllers/users/registrations_controller.rb
def update_resource(resource, params)
  resource.update_without_password(params)
end
```
こちらについても参考記事は散見されますので詳細はそれら（下記は一例）をご参照のほど。

https://wcf-drill.com/devise%E3%81%AE%E3%83%91%E3%82%B9%E3%83%AF%E3%83%BC%E3%83%89%E6%9B%B4%E6%96%B0%E3%81%8C%E5%87%BA%E6%9D%A5%E3%81%AA%E3%81%84-752.html

https://easyramble.com/user-account-update-without-password-on-devise.html


### ③ [パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする](https://github.com/k-yamauchi-1/passwords_edit_with_devise/commit/13942046073332c74b121f896106767ece1a93d2)

ここから本記事の主題です。前項の参考記事にもある通りここまでの修正で登録情報編集時にパスワードが不要になったのはよいものの、パスワード自体の変更もできなくなってしまっているのでその後に条件分岐やらなんやら追加して云々――とやっていますが正直ちょっと複雑ですよね。💦
で、そもそもパスワード変更って忘却時のリセットでもやってたよな？使えないかな？ということでそちらに寄せられないか探ってみた（で一応できた）結果がこちらです。

#### ビュー

具体的には、まずビュー `app/views/users/passwords/edit.html.erb` はパスワードリセット要求時に発行される `reset_password_token` の欄がありますが、こちらはリセット即ち非ログイン状態でのパスワード変更時のみ必要な一方
ログイン中のパスワード変更は元々のユーザー情報変更と同様に現在（変更前）のパスワードを要求するようにするため、下記のようにログイン中か否かで表示する入力欄を変更します。
```diff_html:app/views/users/passwords/edit.html.erb
+ <% if user_signed_in? %>
+   <div class="field">
+     <%= f.label :current_password, "Current password" %><br />
+     <%= f.password_field :current_password, autofocus: true, autocomplete: "password" %>
+   </div>
+ <% else %>
    <%= f.hidden_field :reset_password_token %>
+ <% end %>
```

この対応で `passwords/edit.html.erb` 及びエラーメッセージパーシャルの `resource` がnilになる状況が生じます（その理由はまだ詳しく判明してないので突き止められたら追記します）が、
`$ rails g devise:views users` したままではその考慮がされておらずnil落ちするのでそれぞれnil対策を追加します。

`edit.html.erb` は伝統的(?)なフォームヘルパー `form_for` で下記のエラーになりますが、
```:デフォルト（form_forのまま）だと出るエラー
ActionView::Template::Error: First argument in form cannot contain nil or be empty
```
下記のようにモダン(??)な `form_with` に書き換えたらnil落ちを回避できました。
```diff_html:app/views/users/passwords/edit.html.erb
- <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= form_with model: resource, url: password_path(resource_name), html: { method: :put } do |f| %>
```

Devise のエラーメッセージパーシャルも冒頭の `resource` 存否確認にnil考慮を追加します。
ここは普通にnilガードを入れてやれば大丈夫でした。
```diff_html:app/views/users/shared/_error_messages.html.erb
- <% if resource.errors.any? %>
+ <% if (resource&.errors || []).any? %>  <!-- if resource&.errors&.any? でもよい -->
```


#### コントローラ

最後にコントローラですが、`PasswordsController` を下記のように実装します。
（元のコメントアウトコードをまるまる下記に置き換える形でOK）

```ruby:app/controllers/users/passwords_controller.rb
# frozen_string_literal: true

class Users::PasswordsController < Devise::PasswordsController
  # 下記ポイント1.
  skip_before_action :assert_reset_token_passed, if: :user_signed_in?
  skip_before_action :require_no_authentication, if: :user_signed_in?
  # ▲ only: が効かない（if: と併用不可? 詳細不明）ため ▼ を別途指定
  before_action :require_no_authentication, only: [:new]  # skip_before_action より前だと効かない

  # PUT /resource/password
  def update  # 下記ポイント2.
    return super unless user_signed_in?

    if current_user.update_with_password(update_pw_params)
      bypass_sign_in(current_user)  # PW変更時の強制ログアウト回避のため
      flash[:success] = "Password changed!"
      redirect_to root_path
    else
      flash[:danger] = "Failed to change password"
      render 'users/passwords/edit', status: :unprocessable_entity
    end
  end

  protected
    def update_pw_params  # 下記ポイント3.  メソッド名は任意
      params.require(:user).permit(:current_password, :password, :password_confirmation)
    end
end
```

ポイントは下記の3つ。
1. Devise 元々のパスワードリセット画面は、ログイン中、及びリセットトークン未発行時は表示させない（リダイレクトする）設定が入っています。
ログイン中のパスワード変更ではいずれも無効化したいので、それらのスキップ指定 `skip_before_action` を `if: :user_signed_in?` の条件付きで追加します。
`:require_no_authentication` は `new` アクションも対象なので `only: [:edit, :update]` などとして影響しないようにしたいところですが、それだと効かなかった――`if:` とは併用できない?――ため
`before_action :require_no_authentication, only: [:new]` を**最後**に再度指定することでやっと意図通りに動きました。なお記載順序が違うとやはり効かなかったのでご注意。
1. `update` アクションは、未ログイン時 `unless user_signed_in?` は元々のパスワードリセット処理をそのまま実行でよいため `return super`。
ログイン時については `update_with_password` メソッド＋コントローラ定番のif~elseでユーザー情報の更新処理を実装します。
デフォルトではパスワードを更新すると強制的にログアウトさせられるため、回避するには `bypass_sign_in` を入れておきます[^4]。
1. ストロングパラメータは現在のパスワード `:current_password`・変更後 `:password`・変更後確認 `:password_confirmation` の3つを受け入れるようにします。

***

以上で

* ユーザー情報編集画面からパスワード変更機能を外し、パスワード確認入力も不要に
* パスワードリセットもログイン中のパスワード変更も同じ画面/アクションで実現

できました。

冒頭でも触れた通りちゃんとお行儀よく作るなら、単一責任の原則から言っても ユーザー情報編集画面・パスワード変更・パスワードリセット いずれも個別にビュー・アクションを設けた方がよいかと思いますが、
独りであれやこれや弄っている場合など、少ないソース量で手っ取り早く実現したければこういうやり方もできるというご紹介でした。

[^1]: デフォルトで有効なのでこの手順通り Devise を新たに追加していれば問題ないが、元々追加済の場合は要確認
[^2]: 後からカラム追加のマイグレーションを別途作成してももちろんOK
[^3]: 通常のコントローラのストロングパラメータとは書き方が少々異なるので注意
[^4]: [こちら](https://qiita.com/ysk91_engineer/questions/2b5ccb2244ac39d6cbe6)に `config.sign_in_after_reset_password = true` でも回避できる旨情報があったが未検証