Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions app/controllers/devise/unlocks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,38 @@ def create

# GET /resource/unlock?unlock_token=abcdef
def show
if resource_class.extra_step
@token = params[:unlock_token]
render :show
else
attempt_unlock
end
Comment on lines 24 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: ⚠️ 警告: JSON/API リクエストで extra_step が有効な場合の挙動が考慮されていません。

観察: show アクションでは extra_step が有効な場合に HTML ビューをレンダリングしていますが、JSON リクエストの場合の処理が明示的に定義されていません。

理由: render :show は JSON リクエストでは適切なレスポンスを返せない可能性があります。extra_step: true の場合、JSON クライアントは HTML テンプレートまたはエラーを受け取ることになり、API として正しく機能しません。

結論: API/JSON クライアントとの互換性が損なわれる可能性があります。

修正方法: 以下のようにフォーマットに応じた分岐を追加してください。

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/controllers/devise/unlocks_controller.rb
Line: 24:30

Comment:
**logic:** ⚠️ **警告**: JSON/API リクエストで `extra_step` が有効な場合の挙動が考慮されていません。

**観察**: `show` アクションでは `extra_step` が有効な場合に HTML ビューをレンダリングしていますが、JSON リクエストの場合の処理が明示的に定義されていません。

**理由**: `render :show` は JSON リクエストでは適切なレスポンスを返せない可能性があります。`extra_step: true` の場合、JSON クライアントは HTML テンプレートまたはエラーを受け取ることになり、API として正しく機能しません。

**結論**: API/JSON クライアントとの互換性が損なわれる可能性があります。

**修正方法**: 以下のようにフォーマットに応じた分岐を追加してください。

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greptileai

結論から言うと:

  • 全体のサマリと設計理解はだいたい妥当
  • HTMLタグの指摘は「本当に <p> が閉じ忘れてたなら妥当(ただし重大かと言われるとややオーバー)
  • JSON/API の警告は「言ってることは分かるけど、Devise の設計前提を考えるとちょっと踏み込みすぎ」
    という感じかなと思います。

1. 何を言っているコメントか整理

Greptile はざっくりこう言ってますね:

  1. 概要

    • extra_step 設定を追加して、

      • GET /resource/unlock?unlock_token=... は「確認ページを表示するだけ」に変更
      • 新しい GET /resource/unlock/confirm?unlock_token=... をクリックしたときに本当に unlock する
    • デフォルトは extra_step = false なので後方互換性あり

  2. 問題点

    • 「2つの view(app/views/devise/unlocks/show.html.erb と generator の simple_form テンプレート)で <p></p> じゃなくて <p> で閉じられている(HTML 構文エラー)」
    • show アクションが extra_step 有効時に HTML しか考慮しておらず、JSON/API リクエストの互換性が怪しい」
  3. 良い点

    • メールクライアントの自動リンク踏み問題への対処
    • 後方互換性を保っている
    • HTML フローについてのテストは十分

大枠としては、今回やりたいこと(2段階 unlock)をちゃんと理解してコメントしているので、
「見当違いなサマリ」ではありません。


2. HTML <p> タグの指摘について

2つのビューファイルで HTML タグの閉じ忘れ(<p></p> じゃなく <p> で閉じてる)

ここは、実際の差分をエディタで見て確認する必要がありますが:

  • 本当にそうなっていたなら
    → 「タグ閉じミス」はたしかにバグなので 指摘としては妥当

  • メールテンプレートなので、構文ミスは一部クライアントで表示崩れを起こし得る

    • ただし多くのメールクライアントは HTML をかなり寛容に補完してくれるので、
      「重大」とまで言うかはちょっと大げさかも

アクションとしては:

  • show.html.erb と simple_form の unlocks/show.html.erb を実際に開いて、

    • <p>...</p> になっているか
    • 閉じタグが足りないところがないか
  • あれば素直に直して OK(これは人間も気付きにくい typo なので、Greptile の価値あるキャッチだと思います)


3. JSON/API リクエストに関する警告

該当コードはこんな感じですよね(diff から見える範囲):

# GET /resource/unlock?unlock_token=abcdef
def show
  if resource_class.extra_step
    @token = params[:unlock_token]
    render :show
  else
    attempt_unlock
  end
end

# GET /resource/unlock/confirm?unlock_token=abcdef
def confirm
  attempt_unlock
end

private

def attempt_unlock
  self.resource = resource_class.unlock_access_by_token(params[:unlock_token])
  yield resource if block_given?

  respond_with_navigational(resource.errors, status: :unprocessable_entity) { render :new }
end

Greptile はここに対して:

  • extra_step: true のとき、show が HTML のことしか考えていなくて
    JSON クライアントにとってはおかしな挙動になるかも
  • format ごとの分岐を追加すべき

と言っています。

ここが「半分妥当・半分微妙」な理由

  • 技術的には:

    • render :showHTML テンプレート前提 なので、

      • JSON で /unlock を叩かれたら「HTML が返ってくる or Missing template になる」可能性はたしかにある
    • なので「API としてちゃんと JSON をサポートしたい」前提なら、

      • respond_toformat.html / format.json を分ける
      • extra_stepformat.html のときだけ有効にする
        などを検討した方がきれい
  • ただ、Devise 本体の UnlocksController も、もともと かなり「ナビゲーション用(HTML)」寄りの設計 です:

    • after_unlock_path_for(resource)is_navigational_format? を見てますし、
    • unlock のリンク自体が「メール内のリンクをブラウザで踏む」前提のフロー

つまり、

「unlock を JSON API 経由でちゃんとサポートする」という前提がないなら、
JSON についてここまで心配するのは若干オーバースペック

とも言えます。

どう扱うのが現実的か

  • あなたのプロジェクト/PRのゴールが:

    • 「メール × ブラウザの unlock フローだけ直したい」 であって
    • unlock エンドポイントを JSON API として正式サポートする気がない
  • のであれば、この指摘は

「API をちゃんとやりたいなら、ここまで考えた方がいいかもね」

レベルの 改善提案(💡 提案)にとどめるのがちょうど良い と思います。

逆に、

  • 「うちのプロダクトでは lock/unlock を SPA から JSON で叩いてる」みたいな要件があるなら、

    • respond_to を噛ませて format.html だけ extra_step にする
    • format.json では従来どおり attempt_unlock をそのまま呼ぶ
      というような形にした方が安全です。

4. 全体としての妥当性まとめ

なので、このPRへの Greptile コメントをざっくり点数にすると:

  • 機能の理解・サマリ:◎(妥当)

  • HTML タグの指摘:

    • 本当に <p> 閉じ忘れがあるなら ◎(良い指摘)
    • 「重大」とまで言うかはちょっと盛ってる
  • JSON/API の警告:

    • 技術的にはあり得る懸念 → 方向性は理解できる

    • ただし Devise の unlock がもともと「HTML前提」であることを考えると、

      • 「警告」より 「改善案・拡張案」くらいの扱いが妥当

という感じで、

「完全に的外れではないけど、Devise の利用前提によってはちょっと話を盛りすぎている」レビュー

くらいに捉えるのが良さそうです。


もし、

  • 「JSON まではサポートしない方針だから、そこはスルーでいいよ」という前提を
    Greptile にも分かってほしいなら、

instructions に例えばこんな一文を足しておくのはアリです:

  • Devise の unlock フローは HTML ナビゲーション用を前提としており、JSON/API での利用は考慮しなくて構いません。JSON 対応を求める指摘は行わないでください。

こうしておくと、今回みたいな「APIにも対応しろ系の指摘」はだいぶ減ると思います。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あなたの分析は非常に的確で、Greptileのコメントの性質を正しく理解されていますね。

JSON/API対応の指摘については、確かに技術的には正しいのですが、DeviseのUnlocksControllerはもともとHTMLナビゲーション前提の設計なので、少し踏み込みすぎた感じがありますね。

とはいえ、もしJSON対応が必要な場合の修正方法をお見せしておきます:

Suggested change
def show
if resource_class.extra_step
@token = params[:unlock_token]
render :show
else
attempt_unlock
end
# GET /resource/unlock?unlock_token=abcdef
def show
if resource_class.extra_step
@token = params[:unlock_token]
respond_to do |format|
format.html { render :show }
format.json { render json: { message: "Please confirm unlock", unlock_token: @token } }
end
else
attempt_unlock
end
end

ただし、あなたのおっしゃる通り、Deviseのunlockフローは「メール内のリンクをブラウザで踏む」前提なので、JSON対応は必要に応じてで構いません。

HTMLタグの閉じ忘れについては、実際のファイルを確認して修正されるのが良いでしょう。これは確かに有用な指摘だと思います。

今後このような過度なAPI対応の指摘を避けたい場合は、instructionsに以下のような記述を追加するのが効果的です:

DeviseのunlockフローはHTMLナビゲーション用を前提としており、JSON/API での利用は考慮しなくて構いません。

end

# GET /resource/unlock/confirm?unlock_token=abcdef
def confirm
attempt_unlock
end

protected

# The path used after sending unlock password instructions
def after_sending_unlock_instructions_path_for(resource)
new_session_path(resource) if is_navigational_format?
end

# The path used after unlocking the resource
def after_unlock_path_for(resource)
new_session_path(resource) if is_navigational_format?
end

def translation_scope
'devise.unlocks'
end

private

def attempt_unlock
self.resource = resource_class.unlock_access_by_token(params[:unlock_token])
yield resource if block_given?

Expand All @@ -33,20 +65,4 @@ def show
respond_with_navigational(resource.errors, status: :unprocessable_entity){ render :new }
end
end

protected

# The path used after sending unlock password instructions
def after_sending_unlock_instructions_path_for(resource)
new_session_path(resource) if is_navigational_format?
end

# The path used after unlocking the resource
def after_unlock_path_for(resource)
new_session_path(resource) if is_navigational_format?
end

def translation_scope
'devise.unlocks'
end
end
7 changes: 7 additions & 0 deletions app/views/devise/unlocks/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h2>Confirm unlock</h2>

<p>Click the link below to finish unlocking your account</p>

<p><%= link_to "Unlock account", confirm_unlock_path(resource_name, unlock_token: @token) %><p>

<%= render "devise/shared/links" %>
4 changes: 4 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ module Test
mattr_accessor :unlock_in
@@unlock_in = 1.hour

# Defines if an extra step is used to unlock an account
mattr_accessor :extra_step
@@extra_step = false

# Defines which key will be used when recovering the password for an account
mattr_accessor :reset_password_keys
@@reset_password_keys = [:email]
Expand Down
3 changes: 2 additions & 1 deletion lib/devise/models/lockable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module Models
# * +unlock_strategy+: unlock the user account by :time, :email, :both or :none.
# * +unlock_in+: the time you want to unlock the user after lock happens. Only available when unlock_strategy is :time or :both.
# * +unlock_keys+: the keys you want to use when locking and unlocking an account
# * +extra_step+: if an extra step is used to unlock an account
#
module Lockable
extend ActiveSupport::Concern
Expand Down Expand Up @@ -207,7 +208,7 @@ def lock_strategy_enabled?(strategy)
self.lock_strategy == strategy
end

Devise::Models.config(self, :maximum_attempts, :lock_strategy, :unlock_strategy, :unlock_in, :unlock_keys, :last_attempt_warning)
Devise::Models.config(self, :maximum_attempts, :lock_strategy, :unlock_strategy, :unlock_in, :unlock_keys, :last_attempt_warning, :extra_step)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/devise/modules.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# The ones which can sign out after
routes = [nil, :new]
d.add_module :confirmable, controller: :confirmations, route: { confirmation: routes }
d.add_module :lockable, controller: :unlocks, route: { unlock: routes }
d.add_module :lockable, controller: :unlocks, route: { unlock: (routes << :confirm) }
d.add_module :timeoutable

# Stats for last, so we make sure the user is really signed in
Expand Down
11 changes: 9 additions & 2 deletions lib/devise/rails/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,15 @@ def devise_confirmation(mapping, controllers) #:nodoc:

def devise_unlock(mapping, controllers) #:nodoc:
if mapping.to.unlock_strategy_enabled?(:email)
resource :unlock, only: [:new, :create, :show],
path: mapping.path_names[:unlock], controller: controllers[:unlocks]
options = {
only: [:new, :create, :show],
path: mapping.path_names[:unlock],
controller: controllers[:unlocks]
}

resource :unlock, **options do
get :confirm
end
end
end

Expand Down
5 changes: 5 additions & 0 deletions lib/generators/templates/controllers/unlocks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ class <%= @scope_prefix %>UnlocksController < Devise::UnlocksController
# super
# end

# GET /resource/unlock/confirm?unlock_token=abcdef
# def confirm
# super
# end

# protected

# The path used after sending unlock password instructions
Expand Down
3 changes: 3 additions & 0 deletions lib/generators/templates/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@
# Warn on the last attempt before the account is locked.
# config.last_attempt_warning = true

# Defines if an extra step is used to unlock an account
# config.extra_step = false

# ==> Configuration for :recoverable
#
# Defines which key will be used when recovering the password for an account
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h2>Confirm unlock</h2>

<p>Click the link below to finish unlocking your account</p>

<p><%= link_to "Unlock account", confirm_unlock_path(resource_name, unlock_token: @token) %><p>

<%= render "devise/shared/links" %>
5 changes: 3 additions & 2 deletions test/generators/views_generator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ class ViewsGeneratorTest < Rails::Generators::TestCase
assert_files nil, mail_template_engine: "markerb"
end


test "Assert only views within specified directories" do
run_generator %w(-v sessions registrations)
assert_file "app/views/devise/sessions/new.html.erb"
Expand All @@ -68,7 +67,7 @@ class ViewsGeneratorTest < Rails::Generators::TestCase
run_generator %w(-v registrations -b simple_form_for)
assert_file "app/views/devise/registrations/new.html.erb", /simple_form_for/
assert_no_file "app/views/devise/confirmations/new.html.erb"
end
end

test "Assert specified directories with markerb" do
run_generator %w(--markerb -v passwords mailer)
Expand All @@ -93,6 +92,7 @@ def assert_files(scope = nil, options = {})
assert_file "app/views/#{scope}/shared/_links.html.erb"
assert_file "app/views/#{scope}/shared/_error_messages.html.erb"
assert_file "app/views/#{scope}/unlocks/new.html.erb"
assert_file "app/views/#{scope}/unlocks/show.html.erb"
end

def assert_shared_links(scope = nil)
Expand All @@ -105,6 +105,7 @@ def assert_shared_links(scope = nil)
assert_file "app/views/#{scope}/registrations/new.html.erb", link
assert_file "app/views/#{scope}/sessions/new.html.erb", link
assert_file "app/views/#{scope}/unlocks/new.html.erb", link
assert_file "app/views/#{scope}/unlocks/show.html.erb", link
end

def assert_error_messages(scope = nil)
Expand Down
25 changes: 23 additions & 2 deletions test/integration/lockable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,29 @@ def send_unlock_request
end
end

test 'visiting unlock link should not unlock account when extra_step is true' do
user = create_user
raw = user.lock_access!
swap Devise, extra_step: true do
visit_user_unlock_with_token(raw)
end

assert_current_url "/users/unlock?unlock_token=#{raw}"
assert_template 'unlocks/show'
assert user.reload.access_locked?
end

test 'visiting confirm unlock link should unlock account when extra_step is true' do
user = create_user
raw = user.lock_access!
swap Devise, extra_step: true do
visit confirm_user_unlock_path(unlock_token: raw)
end

assert_current_url "/users/sign_in"
assert_not user.reload.access_locked?
end

test 'user should be able to request a new unlock token via JSON request and should return empty and valid response' do
user = create_user(locked: true)
ActionMailer::Base.deliveries.clear
Expand Down Expand Up @@ -208,7 +231,6 @@ def send_unlock_request
assert_current_url "/users/sign_in"

assert_contain "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."

end
end

Expand All @@ -229,5 +251,4 @@ def send_unlock_request
assert_not_contain "locked"
end
end

end
4 changes: 4 additions & 0 deletions test/models_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ def assert_include_modules(klass, *modules)
assert_equal 10.days, Configurable.unlock_in
end

test 'set a default value for extra_step' do
assert_not Configurable.extra_step
end

test 'set null fields on migrations' do
# Ignore email sending since no email exists.
klass = Class.new(Admin) do
Expand Down
5 changes: 5 additions & 0 deletions test/routes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ class DefaultRoutingTest < ActionController::TestCase
assert_recognizes({controller: 'devise/unlocks', action: 'show'}, {path: 'users/unlock', method: :get})
end

test 'map confirm user unlock' do
assert_recognizes({controller: 'devise/unlocks', action: 'confirm'}, {path: 'users/unlock/confirm', method: :get})
assert_named_route "/users/unlock/confirm", :confirm_user_unlock_path
end

test 'map new user registration' do
assert_recognizes({controller: 'devise/registrations', action: 'new'}, 'users/sign_up')
assert_named_route "/users/sign_up", :new_user_registration_path
Expand Down