Chapter 08

認証画面の UI を作成しよう

FarStep
FarStep
2022.12.09に更新

はじめに

本 Chapter では、Tailwind CSS を使って認証画面の UI を作成していきます。
同時に、トップページ・ナビゲーションバーやフッターの作成も行なってしまいます。

トップページを作成しよう

最初に、トップページを作成していきます。
app/views/pages/home.html.erb を開いて下記コードを記述してください。

app/views/pages/home.html.erb
<div class='pt-32 pb-12 md:pt-40 md:pb-20'>
  <div class='pb-12 text-center md:pb-16'>
    <h1 class='leading-tighter mb-4 text-7xl font-extrabold tracking-tighter md:text-8xl'>
      <span class='p-2 bg-gradient-to-r from-purple-600 to-blue-500 bg-clip-text text-transparent'>
        ECommerce
      </span>
    </h1>
    <div class='mx-auto max-w-3xl'>
      <p class='mb-8 text-xl'>You will find what you are looking for.</p>
      <div class='flex justify-center mt-5'>
        <div class='mt-3'>
          <%= link_to root_path, class:'group inline-flex items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-purple-600 to-blue-500 p-0.5 font-medium text-gray-900 hover:text-white focus:ring-4 focus:ring-blue-300 group-hover:from-purple-600 group-hover:to-blue-500' do %>
            <span class='rounded-md bg-white px-5 py-2.5 transition-all duration-75 ease-in group-hover:bg-opacity-0'>
              Find Products
            </span>
          <% end %>
        </div>
      </div>
    </div>
  </div>
</div>

上記コードを記述し終わりましたら http://localhost:8000/ にアクセスしてみましょう。

上記のような画面が表示されていれば OK です。
ただし、「Find Products ボタン」のパスはルートパスになっていますので、後ほど修正します。

ナビゲーションバー・フッターを作成しよう

続いて、ナビゲーションバーとフッターを作成しましょう。
app/views/layouts/application.html.erb を開いて body 部分を下記のように編集してください。

app/views/layouts/application.html.erb
<body class="flex flex-col h-screen justify-between">
  <div class='bg-gray-900 mb-8 z-10'>
    <div class='container mx-auto flex max-w-4xl items-center px-2 py-5'>
      <div class='mx-auto flex w-full flex-wrap items-center'>
        <div class='flex w-full justify-center font-extrabold text-white lg:w-1/2 lg:justify-start'>
          <%= link_to root_path, class: "text-2xl text-gray-900 no-underline hover:text-gray-900 hover:no-underline" do %>
            🛍️ &nbsp; <span class=' text-gray-200'>ECommerce</span>
          <% end %>
        </div>
        <div class='flex w-full content-center justify-between pt-2 lg:w-1/2 lg:justify-end lg:pt-0'>
          <ul class='list-reset flex flex-1 items-center justify-center lg:flex-none h-12'>
            <% if admin_signed_in? %>
              <li class='no-underline'>
                <div class="flex items-center justify-center">
                  <div class="relative inline-block text-left dropdown">
                    <button class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out  rounded-md" type="button" aria-haspopup="true" aria-expanded="true" aria-controls="headlessui-menu-items-117">
                      <svg class="h-6 w-6" id="dropdownDividerButton" data-dropdown-toggle="dropdownDivider" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
                        <path fill="white" d="M288 320a224 224 0 1 0 448 0 224 224 0 1 0-448 0zm544 608H160a32 32 0 0 1-32-32v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 0 1-32 32z">
                        </path>
                      </svg>
                    </button>
                    <div class="opacity-0 invisible dropdown-menu transition-all duration-300 transform origin-top-right -translate-y-2 scale-95">
                      <div class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" aria-labelledby="headlessui-menu-button-1" id="headlessui-menu-items-117" role="menu">
                        <div class="px-4 py-3">         
                          <p class="text-sm leading-5">Signed in as</p>
                          <p class="text-sm font-medium leading-5 text-gray-900 truncate"><%= current_admin.email %></p>
                        </div>
                        <div class="py-1">
                          <%= link_to destroy_admin_session_path, data: { turbo_method: :delete }, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                            Sign out
                          <% end %>
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </li>
            <% elsif customer_signed_in? %>
              <li class='no-underline'>
                <div class="flex items-center justify-center">
                  <div class="relative inline-block text-left dropdown">
                    <button class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out  rounded-md" type="button" aria-haspopup="true" aria-expanded="true" aria-controls="headlessui-menu-items-117">
                      <svg class="h-6 w-6" id="dropdownDividerButton" data-dropdown-toggle="dropdownDivider" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
                        <path fill="white" d="M288 320a224 224 0 1 0 448 0 224 224 0 1 0-448 0zm544 608H160a32 32 0 0 1-32-32v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 0 1-32 32z">
                        </path>
                      </svg>
                    </button>
                    <div class="opacity-0 invisible dropdown-menu transition-all duration-300 transform origin-top-right -translate-y-2 scale-95">
                      <div class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" aria-labelledby="headlessui-menu-button-1" id="headlessui-menu-items-117" role="menu">
                        <div class="px-4 py-3">         
                          <p class="text-sm leading-5">Signed in as</p>
                          <p class="text-sm font-medium leading-5 text-gray-900 truncate"><%= current_customer.email %></p>
                        </div>
                        <div class="py-1">
                          <%= link_to edit_customer_registration_path, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                            Account settings
                          <% end %>
                        </div>
                        <div class="py-1">
                          <%= link_to destroy_customer_session_path, data: { turbo_method: :delete }, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                            Sign out
                          <% end %>
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </li>
            <% else %>
              <li class='px-4 text-white no-underline'>
                <%= link_to "Sign Up", new_customer_registration_path %>
              </li>
              <li class='px-4 text-white no-underline'>
                <%= link_to "Sign In", new_customer_session_path %>
              </li>
            <% end %>
          </ul>
        </div>
      </div>
    </div>
  </div>

  <% if flash[:notice] %>
    <div class="p-4 mb-4 text-md text-blue-700 text-center font-bold">
      <%= notice %>
    </div>
  <% end %>
  <% if flash[:alert] %>
    <div class="p-4 mb-4 text-md text-red-700 text-center font-bold">
      <%= alert %>
    </div>
  <% end %>

  <main class="container mx-auto sm:w-10/12 lg:w-9/12 mb-8 z-0">
    <%= yield %>
  </main>

  <style>
  .dropdown:focus-within .dropdown-menu {
    opacity:1;
    transform: translate(0) scale(1);
    visibility: visible;
  }
  </style>

  <footer class="text-center mt-6 pb-6 h-10">
    <p class="text-gray-500">
      Copyright © FarStep All Rights Reserved.
    </p>
  </footer>
</body>

注目すべきは、ナビゲーションバーの中身です。
下記のように条件分岐を行なっています。

<% if admin_signed_in? %>
  管理者がログインしている場合
<% elsif customer_signed_in? %>
  顧客がログインしている場合
<% else %>
  どちらもログインしていない場合
<% end %>

devise を使うと、admin_signed_in? といったヘルパーメソッドが使えるようになります。
admin_signed_in?admin はモデル名と一致しています。
管理者がログインしている場合、admin_signed_in?true を返し、ログインしていなければ false を返すため、上記のような条件分岐ができるのです。

https://rubydoc.info/github/plataformatec/devise/master/Devise%2FControllers%2FHelpers.define_helpers

管理者の認証画面の UI を作成しよう

それでは、管理者のログイン画面の UI を作成していきましょう。
app/views/admin/sessions/new.html.erb を開いて、下記コードを記述してください。

app/views/admin/sessions/new.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    Sign In
  </span>
</div>

<div class='flex flex-wrap justify-center mb-6'>
  <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "w-11/12 md:w-10/12 xl:w-8/12" }, data: { turbo: false } )  do |f| %>
    <%= render "admin/shared/error_messages", resource: resource %>
    <div class="mb-6">
      <%= f.label :email, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.email_field :email, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :password, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <%= f.submit 'Sign In', class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none cursor-pointer" %>
  <% end %>
</div>

それでは、コンテナが起動していることを確認して、http://localhost:8000/admins/sign_in にアクセスしてみてください。下記のような UI が現れれば OK です。

顧客の認証画面の UI を作成しよう

続いて、顧客の新規登録画面の UI を作成しましょう。
app/views/customer/registrations/new.html.erb を開いて下記コードを記述してください。

app/views/customer/registrations/new.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    Sign Up
  </span>
</div>

<div class='flex flex-wrap justify-center mb-6'>
  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "w-11/12 md:w-10/12 xl:w-8/12" }, data: { turbo: false } )  do |f| %>
    <%= render "customer/shared/error_messages", resource: resource %>
    
    <div class="mb-6">
      <%= f.label :name, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_field :name, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    
    <div class="mb-6">
      <%= f.label :email, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.email_field :email, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    
    <div class="mb-6">
      <%= f.label :password, "password", class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    
    <div class="mb-6">
      <%= f.label :password_confirmation, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password_confirmation, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    
    <%= f.submit 'Sign Up', class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none cursor-pointer" %>
  <% end %>
</div>

<div class="text-center">
  <%= render "customer/shared/links" %>
</div>

http://localhost:8000/customers/sign_up にアクセスしてみてください。下記のような UI が現れれば OK です。

次に、アカウント編集画面です。
app/views/customer/registrations/edit.html.erb を開いて、下記コードを記述してください。

app/views/customer/registrations/edit.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    Edit Profile
  </span>
</div>

<div class='flex flex-wrap justify-center mb-6'>
  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "w-11/12 md:w-10/12 xl:w-8/12" }, data: { turbo: false }) do |f| %>

    <%= render "customer/shared/error_messages", resource: resource %>

    <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
      <div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
    <% end %>

    <div class="mb-6">
      <%= f.label :name, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_field :name, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>

    <div class="mb-6">
      <%= f.label :email, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.email_field :email, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>

    <div class="mb-6">
      <%= f.label :password, "password", class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>

    <div class="mb-6">
      <%= f.label :password_confirmation, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password_confirmation, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>

    <div class="mb-6">
      <%= f.label :current_password, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :current_password, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>

    <div class="actions">
      <%= f.submit "Update Profile", class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none" %>
    </div>
  <% end %>
</div>

アカウントを編集する際には、現在のパスワードを入力する必要があるため、current_password というフィールドが用意されています。また、アカウント編集画面はログインしないと遷移することができません。

続いて、ログイン画面です。
app/views/customer/sessions/new.html.erb を開いて、下記コードを記述してください。

app/views/customer/sessions/new.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    Sign In
  </span>
</div>

<div class='flex flex-wrap justify-center mb-6'>
  <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "w-11/12 md:w-10/12 xl:w-8/12" }, data: { turbo: false } )  do |f| %>
    <%= render "customer/shared/error_messages", resource: resource %>
    <div class="mb-6">
      <%= f.label :email, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.email_field :email, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :password, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.password_field :password, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <%= f.submit 'Sign In', class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none cursor-pointer" %>
  <% end %>
</div>

<div class="text-center">
  <%= render "customer/shared/links" %>
</div>

今回は、パスワードを忘れた場合の処理を実装しませんので、app/views/customer/shared/_links.html.erb 内のパスワード再設定画面へのリンクを削除しておきましょう。

app/views/customer/shared/_links.html.erb
- <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
-   <%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
- <% end %>

http://localhost:8000/customers/sign_in にアクセスして、下記のような画面が表示されれば OK です。

認証機能の動作確認をしよう

これで認証の機能と画面が完成しましたので、動作確認をしましょう。

まずは、管理者の認証機能についてです。
管理者には、新規登録機能がありませんので、rails console 上から直接、管理者を生成します。

コンテナが立ち上がっていることを確認後、下記コマンドを実行して ecommerce_web コンテナに入ります。

$ docker-compose run --rm web bash

app フォルダにログインできたら、下記コマンドを実行しましょう。

$ rails c

コンソールを起動できましたら、下記コマンドを実行して admins テーブルに一件データを挿入します。

$ Admin.create!(email: "admin@gmail.com", password: "1234qwer")

下記のようなログが出力されていれば、OK です。
データベースのテーブル上にデータを登録する際に発行される INSERT INTO という SQL 文が表示されていますね。

irb(main):001:0> Admin.create!(email: "admin@gmail.com", password: "1234qwer")
  TRANSACTION (2.5ms)  BEGIN
  Admin Exists? (5.1ms)  SELECT 1 AS one FROM "admins" WHERE "admins"."email" = $1 LIMIT $2  [["email", "admin@gmail.com"], ["LIMIT", 1]]
  Admin Create (13.9ms)  INSERT INTO "admins" ("email", "encrypted_password", "reset_password_token", "reset_password_sent_at", "remember_created_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "id"  [["email", "admin@gmail.com"], ["encrypted_password", "[FILTERED]"], ["reset_password_token", "[FILTERED]"], ["reset_password_sent_at", "[FILTERED]"], ["remember_created_at", nil], ["created_at", "2022-11-19 01:47:58.176235"], ["updated_at", "2022-11-19 01:47:58.176235"]]
  TRANSACTION (1.1ms)  COMMIT
=> #<Admin id: 1, email: "admin@gmail.com", created_at: "2022-11-19 01:47:58.176235000 +0000", updated_at: "2022-11-19 01:47:58.176235000 +0000">

それでは、この管理者でログインしてみましょう。
http://localhost:8000/admins/sign_in にアクセスして、先ほど作成した管理者のメールアドレスとパスワードでログインしてみてください。

ログインに成功し、ルートパスにリダイレクトされればログイン成功です。
サクセスメッセージも表示されていますね。
ユーザのアイコンをクリックすると、現在ログインしている管理者のメールアドレスが表示されているはずです。

Sign Out ボタンを押して、正常にログアウトできるかどうか確認してみてください。
ログアウトした後、ルートパスにリダイレクトされれば OK です。

では次に、顧客の認証機能についてです。
顧客には新規登録機能がありますので、http://localhost:8000/customers/sign_up にアクセスし、新たな customer を作成しましょう。

新規登録に成功し、ルートパスにリダイレクトされればログイン成功です。
ユーザのアイコンをクリックすると、現在ログインしている顧客のメールアドレスが表示されているはずです。

次に、ログイン機能の動作確認をしましょう。
一度ログアウトしてから http://localhost:8000/customers/sign_in にアクセスし、先ほど新規登録した顧客でログインしてみましょう。

正常にログインできれば OK です。

最後に、アカウントを編集してみましょう。
ログイン状態であることを確認して、ユーザのアイコンをクリックした後、「Account settings」を選択してください。すると、http://localhost:8000/customers/edit 遷移するはずです。

下記のように、name フィールドと email フィールドに現在の値が入っている編集画面が表示されれば OK です。

試しに、name・email・password のいずれかを編集してみてください。
ただし、devise の仕様上、アカウントを編集するためには、現在のパスワードを入力する必要があります。

アカウントの編集が成功し、ルートパスにリダイレクトされれば OK です。
name や email を更新した場合には、ユーザアイコンをクリックした時の表示も変更されているはずです。

バリデーションのエラーメッセージの UI を整えよう

現在、顧客の新規登録・アカウント編集を行う際にバリエーションによるチェックが走ります。
試しに、name・email・password のいずれかが空の状態で「Sign Up」ボタンを押下してみましょう。
もしも、name のみが空の状態ですと下記のようなエラーメッセージが表示されるはずです。

1 error prohibited this customer from being saved:
Name can't be blank

このようにバリエーションによるチェックが走るのは、app/models/customer.rb にバリエーションの記述をしたからです。

このバリデーションのエラーメッセージの UI を整えましょう。
app/views/customer/shared/_error_messages.html.erb を開いて、下記コードを記述してください。

app/views/customer/shared/_error_messages.html.erb
<% if resource.errors.any? %>
  <div class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4 mb-5" role="alert">
    <p class="font-bold">
      <%= I18n.t("errors.messages.not_saved",
                  count: resource.errors.count,
                  resource: resource.class.model_name.human.downcase)
      %>
    </p>
    <ul>
      <% resource.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </div>
<% end %>

記述が完了したら、再度バリエーションのエラーメッセージを表示してみましょう。
下記のようにエラーメッセージが装飾されていれば OK です。

app/views/customer/shared/_error_messages.html.erb は、アカウント編集画面でも使用されていますので、アカウント編集画面でのバリエーションのエラーメッセージの UI も整っているはずです。

これで、認証機能に関する UI の作成は完了です。
コミットしておきましょう。

$ git add . && git commit -m "Create UI for authentication screen"

おわりに

お疲れ様でした。
本 Chapter では、認証機能に関する UI を作成しました。
devise を使うと簡単にアプリに認証機能を付与することができますね。