Friendly forwarding

網站的權限系統已經完成,但還有個小問題,如果使用者嘗試瀏覽有權限的頁面,不管有沒有登入,都會被導向個人資料頁面。也就是說如果未登入的使用者想瀏覽編輯頁面,登入後會被導向到 /users/1,而不是 /users/1/edit,如果登入後可以導向到之前想瀏覽的頁面會更友善。

要實現這種應用程式碼會有點複雜,我們可以先從簡單的測試下手,也就是:

log_in_as(@user)
get edit_user_path(@user)

把先前的登入和瀏覽編輯頁面的順序顛倒,如以下程式碼所示:

test/integration/users_edit_test.rb

require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_path(@user)
    name  = "Foo Bar"
    email = "[email protected]"
    patch user_path(@user), user: { name:  name,
                                    email: email,
                                    password:              "",
                                    password_confirmation: "" }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

先瀏覽編輯頁面,然後登入,最後檢查使用者是被導向到編輯頁面,而不是預設的個人資料頁面,同時也移除 assert_template 'users/edit' 這行。

現在有了失敗的測試,可以繼續實現友善的頁面導向。要讓使用者導向真正想瀏覽的頁面,我們要在某個地方儲存這個頁面的位址,登入後再轉向這個位址。所以要在 Sessions helper 定義一組方法:store_locationredirect_back_or

app/helpers/sessions_helper.rb

module SessionsHelper
  .
  .
  .
  # Redirects to stored location (or to the default).
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
  end

  # Stores the URL trying to be accessed.
  def store_location
    session[:forwarding_url] = request.url if request.get?
  end
end

我們使用 session 儲存位址,和之前定義使用者登入的方式類似。同時也使用 request 物件來取的請求頁面的 URL(透過 request.url)。

store_location 方法把請求的位址儲存在含有 key :forwarding_urlsession 變數裡面,但只在 GET 請求中才儲存。這樣做的原因是,當未登入的使用者提交表單時,不會儲存轉向的位址(這種情況或許少見,但如果在提交表單之前,使用者手動刪除 session,還是會發生)。

而如果儲存了,那本來預期會接收 POST、PATCH 或 DELETE 請求的動作,實際接收到的卻是 GET,會導致錯誤。加上 if request.get? 能避免這種錯誤發生。

要使用 store_location 我們要把它加在 logged_in_user before filter 中:

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # Before filters

    # Confirms a logged-in user.
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # Confirms the correct user.
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

接著要實現轉向位址的話,我們要使用 redirect_back_or 方法,如果儲存了之前請求的位址,就重新導向到那個位址,否則就導向預設的位址:

session[:forwarding_url] || default

如果 session[:forwarding_url] 是 nil,就會執行 default。在 redirect_back_or 方法中還用了:

session.delete(:forwarding_url)

刪除了轉向的位址,因為如果不刪除,後續登入會不斷的導向到受保護的頁面,直到使用者關閉瀏覽器。(針對這個行為的測試留在練習題)

還要注意,即使先重新導向了,還是會刪除 session 中的轉向位址,除非使用明確的 return 或是到 method 的末端,否則重新導向之後,程式碼還是會繼續執行。

redirect_back_or 方法加在 Sessions controller 的 create action 裡面:

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_back_or user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

現在友善導向的測試應該能通過(Green):

$ bundle exec rake test