[Ruby on Rails]AdminLTEのログインテンプレートを使用してみる

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

以前のブログにてAdminLTEについて紹介しました。今回はアプリケーションを作成する上で実装することが多いと思われるログイン・ログアウト周りの機能を、AdminLTEのテンプレートを用いて実装してみました。この実装のクライアント側について、主に書いていきたいと思います。

サーバについては、以下の記事で作成したsorceryを使用した認証APIと同じソースとなるので、必要な場合はそちらを参考にしてください。
[Ruby on Rails]sorceryによる認証 – (5)APIでの認証 #1 実装の概要
[Ruby on Rails]sorceryによる認証 – (5)APIでの認証 #2 コントローラ
[Ruby on Rails]sorceryによる認証 – (5)APIでの認証 #3 モデル
[Ruby on Rails]sorceryによる認証 – (7)APIでのパスワードリセット

アプリの概要

以前の記事と同様、サーバとクライアントを明確に分離させ

  • サーバ    ・・・ RailsによるAPIサーバで、認証の結果をHTTPステータスで返却する
  • クライアント ・・・ AdminLTEのライブラリとHTMLのみで、サーバより結果を取得してビューを作成する

という構成にしました。クライアントについては、今回はオーソドックスなjQueryによる実装としてあります。クライアントの構成は[Ruby on Rails]AdminLTEをテンプレートとして使用する(1) – アプリの概要を踏襲しているので、未読の場合は一読をお勧めします。

フォルダ構成

以下のようなフォルダ構成としました。

.
├── client ・・・クライアント
│   ├── assets
│   │   └── javascript
│   │       └── config.js              ・・・共通の定義、メソッドを記述
│   ├── lib
│   │   ├── adminlte           ・・・AdminLTEのライブラリをそのまま格納
│   │       ├── bootstrap
│   │       ├── dist
│   │       ├── plugins
│   ├── pages
│   │   └── examples
│   │       ├── 500.html                ・・・エラーページ
│   │       ├── blank.html              ・・・ログインした場合のみ表示する画面
│   │       ├── login.html              ・・・ログイン画面
│   │       ├── register.html           ・・・ユーザ登録画面
│   │       └── reset_password.html     ・・・パスワードリセット画面
│   └── starter.html
└── server ・・・サーバ(Rails)
    ├── app
    │   ├── controllers
    │   │   ├── api
    │   │   │   └── v1
    │   │   │       ├── application_base_controller.rb
    │   │   │       ├── auth_check_controller.rb
    │   │   │       ├── password_resets_controller.rb
    │   │   │       ├── user_sessions_controller.rb
    │   │   │       └── users_controller.rb
    │   │   ├── application_controller.rb
    │   │   └── concerns
    │   ├── mailers
    │   │   ├── application_mailer.rb
    │   │   └── user_mailer.rb
    │   ├── models
    │   │   ├── api_key.rb
    │   │   ├── concerns
    │   │   └── user.rb
    │   └── views
    │       └── user_mailer
    │           └── reset_password_email.text.erb
    ├── config
   (以降略)

クライアントについては/pages/examplesフォルダ内に、今回必要なhtmlを配置しました。サーバについては上記にリンクした記事を参考にしてください。

画面フロー

今回作成する機能のイメージを掴みやすくするため、ログイン〜ログアウト等の画面を処理順に並べておきます。

1.ユーザの作成

AdminLTE_LoginTemplate_Register

2.ログイン

AdminLTE_LoginTemplate_Login

3.ログイン後の画面遷移

AdminLTE_LoginTemplate_AfterRegister
ログイン後にはblank.htmlに遷移します。

4.ログアウト

AdminLTE_LoginTemplate_Logout
上記のblank.htmlの右上より「Sign Out」ボタンを表示し、押下することでログアウトします。

5.パスワードリセット

AdminLTE_LoginTemplate_ForgotPassword
「I forgot my password」リンクを押下すると、トークン付きのパスワードリセット画面のURLをメールで送信します。
AdminLTE_LoginTemplate_SendEmail
sorcery_api_password_reset_mail2
メールに記述されたURLにアクセスすると、パスワードリセット画面を表示します。 AdminLTE_LoginTemplate_ResetPassword
パスワードリセットが完了すると、ログイン画面に遷移してリセットした旨を画面に表示します。 AdminLTE_LoginTemplate_PasswordResetSuccess

クライアントの実装

ではクライアントの実装についてです。AdminLTEのページよりテンプレートをダウンロードします。テンプレート内の「pages/examples」フォルダ内より、先のフォルダ構成図に載っているhtmlをコピーします。

1.ユーザの作成

「register.html」になります。AdminLTEのオリジナルのhtmlから変更した箇所は以下の通りです。

register.html
<!DOCTYPE html>
<html>
  <head>
    (中略)
  </head>
  <body class="hold-transition register-page">
    <div class="register-box">
      <div class="register-logo">
        <a href="../../lib/adminlte/index2.html"><b>Admin</b>LTE</a>
      </div>

      <div id="warning" class="callout callout-danger">
        <h4>Warning!</h4>
        <div id="message">
          This is a message.
          This is a message.
          This is a message.
        </div>
      </div>
      (中略)
        <form>   
        (中略)
          <div class="row">
            <div class="col-xs-8">
            </div><!-- /.col -->
            <div class="col-xs-4">
              <button id="register" type="button" class="btn btn-primary btn-block btn-flat">Register</button>
            </div><!-- /.col -->
          </div>
        </form> 
        (中略)  
    <!-- common -->
    <script src="../../assets/javascript/config.js"></script>
    <script>
      $(function () {
        $('input').iCheck({
          checkboxClass: 'icheckbox_square-blue',
          radioClass: 'iradio_square-blue',
          increaseArea: '20%' // optional
        });

        $("#warning").css("display", "none");

        $("#register").click(function(){
          var input = {
            user: {
              email: $("#email").val(),
              name: $("#name").val(),
              password: $("#password").val(),
              password_confirmation: $("#password_confirmation").val()
            }
          };

          $.ajax({
            url: requestUrl('api/v1/users.json'),
            type: 'POST',
            data: input,
            dataType: 'json',
            complete: function(XMLHttpRequest, textStatus, errorThrown) {
              switch (XMLHttpRequest.status){
                case 201:
                  sessionStorage.info = 'Login user is registered. Please login.';
                  document.location.href = "login.html";
                  break;
                case 400:
                  var messages = [];
                  messages = XMLHttpRequest.responseJSON;
                  var message = "";
                  for(i = 0; i < messages.length; i++){
                    message += messages[i] + "\n";
                  }

                  $("#warning").toggle();
                  $("#message").text(message);
                  break;
                default:
                  console.log("error");
                  break;
              }
            }
          });

        });
      });
    </script>
  </body>
</html>        

12〜19行目にメッセージを表示するためのdivタグを定義しています。また54行目以降で「Register」ボタンが押下された場合にサーバ側のAPIを呼び出し、ユーザを登録しています。登録後は「login.html」に遷移しています(63行目)。

呼び出しているAPIのURL、レスポンスの例は以下の通りです。

$ curl -i -X POST http://localhost:3000/api/v1/users.json -d 'user[email]=sample@ggg.com' -d 'user[name]=sample' -d 'user[password]=password' -d 'user[password_confirmation]=password'
HTTP/1.1 201 Created 

2.ログイン

「login.html」になります。AdminLTEのオリジナルのhtmlから変更した箇所は以下の通りです。

login.html
<!DOCTYPE html>
<html>
(中略)
  <body class="hold-transition login-page">
    <div class="login-box">
      <div class="login-logo">
        <a href="../../lib/adminlte/index2.html"><b>Admin</b>LTE</a>
      </div><!-- /.login-logo -->

      <div id="warning" class="callout callout-danger">
        <h4>Warning!</h4>
        <div id="warning_message">
          This is a message.
        </div>
      </div>

      <div id="info" class="callout callout-info">
        <h4>Info</h4>
        <div id="info_message">
          This is a message.
        </div>
      </div>
      (中略)
        <form>
          <div class="row">
            <div class="col-xs-8">
              <div class="checkbox icheck">
                <label>
                  <input id="remember_me" type="checkbox"> Remember Me
                </label>
              </div>
            </div><!-- /.col -->
            <div class="col-xs-4">
              <button id="login" type="button" class="btn btn-primary btn-block btn-flat">Sign In</button>
            </div><!-- /.col -->
          </div>
        </form>

        <a id="forgot_password" href="#">I forgot my password</a><br>
        <a href="register.html" class="text-center">Register a new membership</a>

      </div><!-- /.login-box-body -->
    </div><!-- /.login-box -->

    <!-- jQuery 2.1.4 -->
    <script src="../../lib/adminlte/plugins/jQuery/jQuery-2.1.4.min.js"></script>
    <!-- Bootstrap 3.3.5 -->
    <script src="../../lib/adminlte/bootstrap/js/bootstrap.min.js"></script>
    <!-- iCheck -->
    <script src="../../lib/adminlte/plugins/iCheck/icheck.min.js"></script>
    <!-- cookie -->
    <script src="../../lib/jquery-cookie/jquery.cookie.js"></script>
    <!-- common -->
    <script src="../../assets/javascript/config.js"></script>
    <script>
      function clearDisplays(){
        $("#warning").css("display", "none");
        $("#info").css("display", "none");
      }

      function displayWarning(message){
        clearDisplays();
        $("#warning").toggle();
        $("#warning_message").text(message);
      }

      function displayInfo(message){
        clearDisplays();
        $("#info").toggle();
        $("#info_message").text(message);
      }

      $(function () {
        $('input').iCheck({
          checkboxClass: 'icheckbox_square-blue',
          radioClass: 'iradio_square-blue',
          increaseArea: '20%' // optional
        });

        // set warning message.
        if(sessionStorage.warning === undefined){
          clearDisplays();
        }else{
          displayWarning(sessionStorage.warning);
          delete sessionStorage.warning;
        }

        // set info message.
        if(sessionStorage.info === undefined){
          clearDisplays();
        }else{
          displayInfo(sessionStorage.info);
          delete sessionStorage.info;
        }

        // login.
        $("#login").click(function(){
          var input = {
            user: {
              email: $("#email").val(),
              password: $("#password").val()
            }
          };

          $.ajax({
            url: requestUrl('api/v1/user_sessions.json'),
            type: 'POST',
            data: input,
            dataType: 'json',
            complete: function(XMLHttpRequest, textStatus, errorThrown) {
              switch (XMLHttpRequest.status){
                case 200:
                  if($("#remember_me").prop('checked')){
                    $.cookie('adminlte_rails_sample_access_token', XMLHttpRequest.responseJSON.access_token, { expires: 7, path: '/' });
                  }else{
                    $.cookie('adminlte_rails_sample_access_token', XMLHttpRequest.responseJSON.access_token, { path: '/' });
                  }
                  sessionStorage.adminlte_rails_sample_user_name = XMLHttpRequest.responseJSON.user.name;
                  document.location.href = "blank.html";
                  break;
                case 404:
                  displayWarning('Login failed. Check your email or password.');
                  break;
                default:
                  console.log("error");
                  break;
              }
            }
          });
        });
        // login.
      });
    </script>
  </body>
</html>                             

96〜131行目で「Sign in」ボタンを押下したときの動作を記述しています。ログイン用のAPIを呼び出し(106行目)、成功したら次のページに遷移しています(119行目)。また「Remember Me」にチェックを入れた場合、一定期間トークンをブラウザに保持することで、ログイン画面を経由することなくログイン状態としています(113〜117行目)。

「Sign in」で呼び出すAPIのURL、レスポンスの例は以下の通りです。

$ curl -i -X POST http://localhost:3000/api/v1/user_sessions.json -d 'user[email]=sample@ggg.com' -d  'user[password]=password'
HTTP/1.1 200 OK 
(中略)
{"user":{"id":51,"email":"sample@ggg.com","name":"sample"},"access_token":"8835b89b52db281a9988847a0cda2e58"}

3.ログイン後の画面遷移、4.ログアウト

「blank.htm」の表示時に、ログイン済みのユーザかをAPIを呼び出すことで確認しています。またログアウトも今回は「blank.htm」の中に記述しました。画面共通のヘッダー部となるため、共通のhtmlの中に本来は記述するべきかもしれません。

blank.html
<!DOCTYPE html>
<html>
  (中略)
  <body class="hold-transition skin-blue sidebar-mini">
    <!-- Site wrapper -->
    <div class="wrapper">

      <header class="main-header">
      (中略)
                  <!-- Menu Footer-->
                  <li class="user-footer">
                    <div class="pull-left">
                      <a href="#" class="btn btn-default btn-flat">Profile</a>
                    </div>
                    <div class="pull-right">
                      <a id="logout" class="btn btn-default btn-flat">Sign out</a>
                    </div>
                  </li>
      (中略)
      </header>
      (中略)
    <!-- cookie -->
    <script src="../../lib/jquery-cookie/jquery.cookie.js"></script>
    <!-- common -->
    <script src="../../assets/javascript/config.js"></script>
    <script>
      $(function () {
        // access token check.
        var access_token = '';
        
        if($.cookie('adminlte_rails_sample_access_token') !== undefined){
          access_token = $.cookie('adminlte_rails_sample_access_token');
        }

        $.ajax({
          url: requestUrl('api/v1/auth_check/restrict.json'),
          type: 'GET',
          headers: {
            'ACCESS_TOKEN': access_token
          },
          dataType: 'json',
          complete: function(XMLHttpRequest, textStatus, errorThrown) {
            switch (XMLHttpRequest.status){
              case 200:
                break;
              case 401:
                sessionStorage.warning = "You have to login.";
                document.location.href = "login.html";
                break;
              default:
                console.log("error");
                document.location.href = "500.html";
                break;
            }
          }
        });

        // set user name.
        var user_name = sessionStorage.adminlte_rails_sample_user_name;
        $("#user_name_left").text(user_name);
        $("#user_name_top").text(user_name);

        // logout
        $("#logout").click(function(){
          $.ajax({
            url: requestUrl('api/v1/user_sessions.json'),
            type: 'DELETE',
            headers: {
              'ACCESS_TOKEN': access_token
            },
            dataType: 'json',
            complete: function(XMLHttpRequest, textStatus, errorThrown) {
              switch (XMLHttpRequest.status){
                case 200:
                  $.removeCookie('adminlte_rails_sample_access_token');
                  sessionStorage.info = "Logout success.";
                  document.location.href = "login.html";
                  break;
                case 401:
                  sessionStorage.warning = "You have to login.";
                  document.location.href = "login.html";
                  break;
                default:
                  console.log("error");
                  document.location.href = "500.html";
                  break;
              }
            }
          });
        });

      });
    </script>
    (中略)

28〜56行目でAPIにトークンを渡し、ログイン済みかをチェックしています。64〜89行目ではログアウト用のAPIを呼び出し、ログアウト処理をしています。それぞれで呼び出すAPIのURL、レスポンスの例は以下の通りです。

$ curl -i -X GET -H 'ACCESS_TOKEN: 8835b89b52db281a9988847a0cda2e58' http://localhost:3000/api/v1/sample/restrict.json
HTTP/1.1 200 OK 
(中略)
{"message":"authorized"}
$ curl -i -X DELETE -H 'ACCESS_TOKEN: 8835b89b52db281a9988847a0cda2e58' http://localhost:3000/api/v1/user_sessions.json
HTTP/1.1 200 OK 

5.パスワードリセット

「login.html」の「Forgot password」リンクを押下すると、パスワードリセット用のAPIを呼び出します。

login.html
    <script>
        // forgot_password.
        $("#forgot_password").click(function(){
          $.ajax({
            url: requestUrl('api/v1/password_resets.json'),
            type: 'POST',
            data: 'email=' + $("#email").val(),
            dataType: 'json',
            complete: function(XMLHttpRequest, textStatus, errorThrown) {
              switch (XMLHttpRequest.status){
                case 201:
                  displayInfo('Email is send. Please Check your Email.');
                  break;
                case 404:
                  displayWarning('Email is not found.');
                  break;
                default:
                  console.log("error");
                  break;
              }
            }
          });
        });
        // forgot_password
      });
    </script>

呼び出すAPIのURL、レスポンスの例は以下の通りです。

$ curl -i -X POST http://localhost:3000/api/v1/password_resets.json -d 'email=xxxx@xxxx.co.jp'
HTTP/1.1 201 Created 

次にパスワードを再設定するための画面です。「reset_password.html」ですが、これはAdminLTEの「login.html」を流用して作成しました。主なソースは以下のようになります。

reset_password.html
<!DOCTYPE html>
<html>
(中略)
  <body class="hold-transition register-page">
    <div class="register-box">
      <div class="register-logo">
        <a href="../../lib/adminlte/index2.html"><b>Admin</b>LTE</a>
      </div>

      <div id="warning" class="callout callout-danger">
        <h4>Warning!</h4>
        <div id="warning_message">
          This is a message.
          This is a message.
          This is a message.
        </div>
      </div>

      <div class="register-box-body">
        <p class="login-box-msg">Reset Password</p>
        <form>
          <div class="form-group has-feedback">
            <input id="password" type="password" class="form-control" placeholder="Password">
            <span class="glyphicon glyphicon-lock form-control-feedback"></span>
          </div>
          <div class="form-group has-feedback">
            <input id="password_confirmation" type="password" class="form-control" placeholder="Retype password">
            <span class="glyphicon glyphicon-log-in form-control-feedback"></span>
          </div>
          <div class="row">
            <div class="col-xs-8">
            </div><!-- /.col -->
            <div class="col-xs-4">
              <button id="register" type="button" class="btn btn-primary btn-block btn-flat">Register</button>
            </div><!-- /.col -->
          </div>
        </form>

        <a href="login.html" class="text-center">I already have a membership</a>
      </div><!-- /.form-box -->
    </div><!-- /.register-box -->

    <!-- jQuery 2.1.4 -->
    <script src="../../lib/adminlte/plugins/jQuery/jQuery-2.1.4.min.js"></script>
    <!-- Bootstrap 3.3.5 -->
    <script src="../../lib/adminlte/bootstrap/js/bootstrap.min.js"></script>
    <!-- iCheck -->
    <script src="../../lib/adminlte/plugins/iCheck/icheck.min.js"></script>
    <!-- common -->
    <script src="../../assets/javascript/config.js"></script>
    <script>
      // reset passoword function.
      function reset_password(token){
        var input = {
          user: {
            password: $("#password").val(),
            password_confirmation: $("#password_confirmation").val()
          }
        };

        $.ajax({
          url: requestUrl('api/v1/password_resets/' + token + '.json'),
          type: 'PUT',
          data: input,
          dataType: 'json',
          complete: function(XMLHttpRequest, textStatus, errorThrown) {
            switch (XMLHttpRequest.status){
              case 200:
                sessionStorage.info = 'Reset password success. Please login.';
                document.location.href = "login.html";
                break;
              case 404:
                $("#warning").toggle();
                $("#warning_message").text('Request is not found.');
                break;
              case 406:
                $("#warning").toggle();
                $("#warning_message").text('Reset password failed. Please check your passwords.');
              default:
                console.log("error");
                break;
            }
          }
        });
      }

      $(function () {
        $('input').iCheck({
          checkboxClass: 'icheckbox_square-blue',
          radioClass: 'iradio_square-blue',
          increaseArea: '20%' // optional
        });

        $("#warning").css("display", "none");

        // register.
        $("#register").click(function(){
          var parameters = location.href.split("?");
          var token = parameters[1].split("=")[1];

          // token check, reset password.
          $.ajax({
            url: requestUrl('api/v1/password_resets/' + token + '/edit.json'),
            type: 'GET',
            dataType: 'json',
            complete: function(XMLHttpRequest, textStatus, errorThrown) {
              switch (XMLHttpRequest.status){
                case 200:
                  reset_password(token);
                  break;
                case 404:
                  $("#warning").toggle();
                  $("#warning_message").text('Request is not found.');
                  break;
                default:
                  console.log("error");
                  break;
              }
            }
          });

        });
      });
    </script>
  </body>
</html>

97行目以降で「Register」ボタンが押下された時のパスワードリセットの動作を記述していますが、2回APIを呼び出しています。まずは103行目でパスワードリセット用のトークンが正しいかのチェックを行い、正しい場合は108行目で「reset_password」メソッドを呼び出します。53行目から始まるreset_password」メソッドで、パスワードリセット用のAPIを呼び出しています。それぞれで呼び出すAPIのURL、レスポンスの例は以下の通りです。

$ curl -i -X GET http://0.0.0.0:3000/api/v1/password_resets/CpYDpyuHKrLVUJFjUzAE/edit.json
HTTP/1.1 200 OK 
$ curl -i -X PUT http://0.0.0.0:3000/api/v1/password_resets/CpYDpyuHKrLVUJFjUzAE.json -d 'user[password]=password' -d 'user[password_confirmation]=password'
HTTP/1.1 200 OK 

まとめ

以前にも書きましたが、サーバとクライアントを完全に分離することで、今回はクライアントの開発に専念することができました(サーバは以前に作成した処理を流量したため)。クライアントの開発に専念できる=AdminLTEなどのテンプレートを崩さずに使うことができることが、この構成のメリットだと思います。

今回作成したソースコードは以下のGithubに置いてあります。全ソースを見たい方は参考にしてください。
adminlte_rails_sample