概要(やってみたこと)
- ユーザー編集画面で、パスワードとそれ以外の属性の編集画面を別々にする
- パスワード以外の編集時は確定時のパスワード再入力を不要とする
- パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする
末筆の通りあまりお行儀は良くない気がするので、コードの量より分かりやすさが重要な状況(大規模プロジェクトetc.)には向かないと思います。
コード量抑制を重視する状況下では使いどころもあるかもしれませんが、こんなやり方もできたという一種の頭の体操的に捉えて頂けますと幸いです🙏
なお前提として、本稿内では Devise の認証対象のモデルは「User
」としています。
モデル名が異なる場合は適宜読み替えてください。
ソース・環境
ソース全文・全履歴は下記になります。なるべく対応内容ごとの変化が分かりやすいようにコミット単位も調整しました。
ローカル環境は Windows 10 Pro、Ruby3.2.2、Rails7.1.2
各段階の詳細手順
前準備
既に Devise 導入が完了しているプロジェクトであれば不要な手順だと思いますが
断片的に必要な部分もあるかもしれません(カスタマイズ対象コントローラの追加etc.)ので各々ご判断ください
Gemfile に devise
(と、必要なら devise-i18n
)を追加した上で下記を実行します。
(この辺りは devise 導入の基本的な手順なので詳細は割愛します)
$ 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(認証対象)モデルにメール・パスワード以外の属性を持たせる場合はマイグレーションファイルに追記2(今回のサンプルコードでは必須属性として名前:name
、任意属性として職業:job
・趣味:hobby
を追加)します。
追記が終わったら(なければそのまま)続いて下記を実行します。
$ rails db:migrate
$ rails g devise:views users
$ rails g devise:controllers users -c registrations passwords
カスタマイズが必要なコントローラは最後のコマンドの通り registrations
と passwords
なのでこの2つを生成し、ルーティングの設定を行います。
- devise_for :users
+ devise_for :users, controllers: {
+ registrations: 'users/registrations',
+ passwords: 'users/passwords'
+ }
① パスワードとそれ以外の属性の編集画面を別々にする(ユーザー編集画面/アクションをパスワード以外の属性の編集に特化)
Devise 元々の(=$ rails g devise:views users
で作られる)ユーザー情報編集ビュー users/registrations/edit.html.erb
はメール(email)とパスワード(現在/変更/確認)の入力欄しかないので、
それ以外の属性の入力欄をフォームに追加する一方でパスワード関連の欄3つ:password
, password_confirmation
, current_password
は削除します。
また対応するアクションは RegistrationsController
の update
アクションになります。
これ自体は Devise 元々の実装のままでいいのでオーバーライド(カスタマイズ)は不要(def update
以下自体記述の必要なし、敢えて明記したい場合は下記)ですが、
# 以下まるまる書かなくても違いはない
def update
super
end
追加の属性がストロングパラメータに入っていなくそのままだと送信できない(弾かれてしまう)ので、今回の例であれば name
, job
, hobby
を下記の通り追加します3。
# 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
ビューへ移しますが、これについては要の部分なので後述します。
② パスワード以外の編集時は確定時のパスワード再入力を不要とする
Devise の元々の仕様ではユーザー情報編集時にはパスワードの入力が必要なのでこのままでは動きません。
前項で RegistrationsController#update
はオーバーライド不要――と書いたのは嘘ではないですが、その中から呼ばれている update_resource
メソッドを下記の通りオーバーライドすることで意図する挙動に変更する必要があります。
def update_resource(resource, params)
resource.update_without_password(params)
end
こちらについても参考記事は散見されますので詳細はそれら(下記は一例)をご参照のほど。
③ パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする
ここから本記事の主題です。前項の参考記事にもある通りここまでの修正で登録情報編集時にパスワードが不要になったのはよいものの、パスワード自体の変更もできなくなってしまっているのでその後に条件分岐やらなんやら追加して云々――とやっていますが正直ちょっと複雑ですよね。💦
で、そもそもパスワード変更って忘却時のリセットでもやってたよな?使えないかな?ということでそちらに寄せられないか探ってみた(で一応できた)結果がこちらです。
ビュー
具体的には、まずビュー app/views/users/passwords/edit.html.erb
はパスワードリセット要求時に発行される reset_password_token
の欄がありますが、こちらはリセット即ち非ログイン状態でのパスワード変更時のみ必要な一方
ログイン中のパスワード変更は元々のユーザー情報変更と同様に現在(変更前)のパスワードを要求するようにするため、下記のようにログイン中か否かで表示する入力欄を変更します。
+ <% 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
で下記のエラーになりますが、
ActionView::Template::Error: First argument in form cannot contain nil or be empty
下記のようにモダン(??)な form_with
に書き換えたらnil落ちを回避できました。
- <%= 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ガードを入れてやれば大丈夫でした。
- <% if resource.errors.any? %>
+ <% if (resource&.errors || []).any? %> <!-- if resource&.errors&.any? でもよい -->
コントローラ
最後にコントローラですが、PasswordsController
を下記のように実装します。
(元のコメントアウトコードをまるまる下記に置き換える形でOK)
# 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つ。
- Devise 元々のパスワードリセット画面は、ログイン中、及びリセットトークン未発行時は表示させない(リダイレクトする)設定が入っています。
ログイン中のパスワード変更ではいずれも無効化したいので、それらのスキップ指定skip_before_action
をif: :user_signed_in?
の条件付きで追加します。
:require_no_authentication
はnew
アクションも対象なのでonly: [:edit, :update]
などとして影響しないようにしたいところですが、それだと効かなかった――if:
とは併用できない?――ため
before_action :require_no_authentication, only: [:new]
を最後に再度指定することでやっと意図通りに動きました。なお記載順序が違うとやはり効かなかったのでご注意。 -
update
アクションは、未ログイン時unless user_signed_in?
は元々のパスワードリセット処理をそのまま実行でよいためreturn super
。
ログイン時についてはupdate_with_password
メソッド+コントローラ定番のif~elseでユーザー情報の更新処理を実装します。
デフォルトではパスワードを更新すると強制的にログアウトさせられるため、回避するにはbypass_sign_in
を入れておきます4。 - ストロングパラメータは現在のパスワード
:current_password
・変更後:password
・変更後確認:password_confirmation
の3つを受け入れるようにします。
以上で
- ユーザー情報編集画面からパスワード変更機能を外し、パスワード確認入力も不要に
- パスワードリセットもログイン中のパスワード変更も同じ画面/アクションで実現
できました。
冒頭でも触れた通りちゃんとお行儀よく作るなら、単一責任の原則から言っても ユーザー情報編集画面・パスワード変更・パスワードリセット いずれも個別にビュー・アクションを設けた方がよいかと思いますが、
独りであれやこれや弄っている場合など、少ないソース量で手っ取り早く実現したければこういうやり方もできるというご紹介でした。