序章

シリーズの前のパートでは、ユーザーが既存の請求書を作成および表示できるようにする請求書発行アプリケーションのユーザーインターフェイスを作成する方法について説明しました。 シリーズのこの最後のパートでは、クライアントで永続的なユーザーセッションを設定し、請求書の単一のビューを構成します。

前提条件

この記事を適切にフォローするには、次のものが必要です。

  • マシンにインストールされているノード。
  • マシンにインストールされているNPM。
  • このシリーズの最初の2番目のの部分を読んだこと。

インストールを確認するには、次のコマンドを実行します。

  1. node --version
  2. npm --version

結果としてバージョン番号を取得した場合は、問題ありません。

ステップ1—JWTokenを使用したクライアントでのユーザーセッションの永続化

アプリケーションが安全であり、許可されたユーザーのみがリクエストを実行できることを確認するために、JWTokenを使用します。 JWTokens 、またはJSON Webトークンは、リクエストのヘッダー、ペイロード、署名を含む3つの部分からなる文字列で構成されます。 その中心的な考え方は、認証されたユーザーごとに、バックエンドサーバーへのリクエストを実行するときに使用するトークンを作成することです。

開始するには、invoicing-appディレクトリに移動します。 その後、JSONWebトークンの作成と検証に使用されるjsonwebtokenノードモジュールをインストールします。

cd invoicing-app 
npm install jsonwebtoken nodemon --save

nodemonは、ファイルの変更が発生するとサーバーを再起動するノードモジュールです。

次に、次を追加してserver.jsファイルを更新します。

server.js
    
    // import node modules
    [...]
    const jwt = require("jsonwebtoken");
    
    // create express app
    [...]
    app.set('appSecret', 'secretforinvoicingapp'); // this will be used later

次に行うことは、/registerおよび/loginルートを微調整してトークンを作成し、ユーザーが正常に登録またはログインしたときにトークンを返すことです。 これを行うには、server.jsファイルに以下を追加します。

server.js

    // edit the /register route
    app.post("/register", multipartMiddleware, function(req, res) {
      // check to make sure none of the fields are empty
      [...]
      bcrypt.hash(req.body.password, saltRounds, function(err, hash) {
        // create sql query 
        [...]
        db.run(sql, function(err) {
          if (err) {
            throw err;
          } else {
            let user_id = this.lastID;
            let query = `SELECT * FROM users WHERE id='${user_id}'`;
            db.all(query, [], (err, rows) => {
              if (err) {
                throw err;
              }
              let user = rows[0];
              delete user.password;
              //  create payload for JWT
              const payload = {
                user: user 
              }
              // create token
              let token = jwt.sign(payload, app.get("appSecret"), {
                expiresInMinutes: "24h" // expires in 24 hours
              });
              // send response back to client
              return res.json({
                status: true,
                token : token
              });
            });
          }
        });
        db.close();
      });
    });
    
    [...]

/loginルートについても同じようにします。

server.js
    app.post("/login", multipartMiddleware, function(req, res) {
      //  connect to db 
      [...]
      db.all(sql, [], (err, rows) => {
        // attempt to authenticate the user
        [...]
        if (authenticated) {
          //  create payload for JWT
          const payload = { user: user };
          // create token
          let token = jwt.sign( payload, app.get("appSecret"),{
            expiresIn: "24h" // expires in 24 hours
          });
          return res.json({
            status: true,
            token: token
          });
        }
        
        return res.json({
          status: false,
          message: "Wrong Password, please retry"
        });
      });
    });

これが完了したので、次に行うことはそれをテストすることです。 次のコマンドを使用してサーバーを実行します。

  1. nodemon server.js

これで、アプリはログインと登録が成功するとトークンを作成します。 次のステップは、着信要求のトークンを確認することです。 これを行うには、保護するルートの上に次のミドルウェアを追加します。

server.js
    
    [...]
    // unprotected routes
    
    [...]
    // Create middleware for protecting routes
    app.use(function(req, res, next) {
      // check header or url parameters or post parameters for token
      let token =
        req.body.token || req.query.token || req.headers["x-access-token"];
      // decode token
      if (token) {
        // verifies secret and checks exp
        jwt.verify(token, app.get("appSecret"), function(err, decoded) {
          if (err) {
            return res.json({
              success: false,
              message: "Failed to authenticate token."
            });
          } else {
            // if everything is good, save to request for use in other routes
            req.decoded = decoded;
            next();
          }
        });
      } else {
        // if there is no token
        // return an error
        return res.status(403).send({
          success: false,
          message: "No token provided."
        });
      }
    });
    
    // protected routes 
    [...]

SignUp.vueファイルでは、サーバーから取得したトークンとユーザーデータをlocalStorageに保存して、ユーザーがアプリケーションを使用しているときに異なるページ間で保持できるようにする必要があります。 これを行うには、frontend/src/components/SignUp.vueファイルのloginおよびregisterメソッドを次のように更新します。

フロントエンド/src/components/SignUp.vue
    [...]
    export default {
      name: "SignUp",
      [...]
      methods:{
        register(){
          const formData = new FormData();
          let valid = this.validate();
          if(valid){
            // prepare formData
            [...]
            // Post to server
            axios.post("http://localhost:3128/register", formData)
            .then(res => {
              // Post a status message
              this.loading = "";
              if (res.data.status == true) {
                // store the user token and user data in localStorage
                localStorage.setItem('token', res.data.token);
                localStorage.setItem('user', JSON.stringify(res.data.user));
                // now send the user to the next route
                this.$router.push({
                  name: "Dashboard",
                });
              } else {
                this.status = res.data.message;
              }
            });
          }
          else{
            alert("Passwords do not match");
          }
        }
        [...]

ログイン方法も更新しましょう。

フロントエンド/src/components/SignUp.vue
        login() {
          const formData = new FormData();
          formData.append("email", this.model.email);
          formData.append("password", this.model.password);
          this.loading = "Signing in";
          // Post to server
          axios.post("http://localhost:3128/login", formData).then(res => {
            // Post a status message
            console.log(res);
            this.loading = "";
            if (res.data.status == true) {
              // store the data in localStorage
              localStorage.setItem("token", res.data.token);
              localStorage.setItem("user", JSON.stringify(res.data.user));
              // now send the user to the next route
              this.$router.push({
                name: "Dashboard"
              });
            } else {
              this.status = res.data.message;
            }
          });

以前は、ユーザーデータはルートパラメータを使用して渡されていましたが、アプリはローカルストレージからデータを取得するようになりました。 これによりコンポーネントがどのように変化するかを見てみましょう。

Dashboardコンポーネントは、以前は次のようになりました。

フロントエンド/src/components/Dashboard.vue
    
    <script>
    import Header from "./Header";
    import CreateInvoice from "./CreateInvoice";
    import ViewInvoices from "./ViewInvoices";
    export default {
      name: "Dashboard",
      components: {
        Header,
        CreateInvoice,
        ViewInvoices,
      },
      data() {
        return {
          isactive: 'create',
          title: "Invoicing App",
          user : (this.$route.params.user) ? this.$route.params.user : null
        };
      }
    };
    </script>

これは、ユーザーがサインインまたは登録すると、Dashboardページにリダイレクトされ、Dashboardコンポーネントのuserプロパティがそれに応じて更新されることを意味します。 ユーザーがページを更新することを決定した場合、this.$route.params.userは存在しなくなるため、ユーザーを識別する方法はありません。

Dashboardコンポーネントを編集して、ブラウザのlocalStorageを使用するようにします。

フロントエンド/src/components/Dashboard.vue
    
    import Header from "./Header";
    import CreateInvoice from "./CreateInvoice";
    import ViewInvoices from "./ViewInvoices";
    export default {
      name: "Dashboard",
      components: {
        Header,
        CreateInvoice,
        ViewInvoices,
      },
      data() {
        return {
          isactive: 'create',
          title: "Invoicing App",
          user : null,
        };
      },
      mounted(){
        this.user = JSON.parse(localStorage.getItem("user"));
      }
    };

これで、ページを更新した後もユーザーデータが保持されます。 リクエストが行われるときは、トークンをリクエストに追加する必要もあります。

ViewInvoicesコンポーネントを見てください。 コンポーネントのJavaScriptは次のようになります。

フロントエンド/src/components/ViewInvoices.vue
    <script>
    import axios from "axios";
    export default {
      name: "ViewInvoices",
      components: {},
      data() {
        return {
          invoices: [],
\          user: '',
        };
      },
      mounted() {
        this.user = JSON.parse(localStorage.getItem('user'));
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}`)
          .then(res => {
            if (res.data.status == true) {
              console.log(res.data.invoices);
              this.invoices = res.data.invoices;
            }
          });
      }
    };
    </script>

現在ログインしているユーザーの請求書を表示しようとすると、トークンがないために請求書を取得するときにエラーが発生します。

これは、アプリケーションのinvoice/user/:user_idルートが、前に設定したトークンミドルウェアで保護されるようになったためです。 このエラーを修正するには、リクエストに追加してください。

フロントエンド/src/components/ViewInvoices.vue
    <script>
    import axios from "axios";
    export default {
      name: "ViewInvoices",
      components: {},
      data() {
        return {
          invoices: [],
          user: '',
        };
      },
      mounted() {
        this.user = JSON.parse(localStorage.getItem('user'));
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}`,
            {
              headers: {"x-access-token": localStorage.getItem("token")}
            }
          )
          .then(res => {
            if (res.data.status == true) {
              console.log(res.data.invoices);
              this.invoices = res.data.invoices;
            }
          });
      }
    };
    </script>

これを保存してブラウザに戻ると、請求書を正常に取得できるようになりました。

ステップ2—請求書の単一ビューを作成する

TO INVOICE ボタンをクリックしても、何も起こりません。 これを修正するには、SingleInvoice.vueファイルを作成し、次のように編集します。

    <template>
      <div class="single-page">
        <Header v-bind:user="user"/>
        <!--  display invoice data -->
        <div class="invoice">
          <!-- display invoice name here -->
          <div class="container">
            <div class="row">
                <div class="col-md-12">
                  <h3>Invoice #{{ invoice.id }} by {{ user.company_name }}</h3>
                  <table class="table">
                    <thead>
                      <tr>
                        <th scope="col">#</th>
                        <th scope="col">Transaction Name</th>
                        <th scope="col">Price ($)</th>
                      </tr>
                    </thead>
                    <tbody>
                      <template v-for="txn in transactions">
                        <tr :key="txn.id">
                          <th>{{ txn.id }}</th>
                          <td>{{ txn.name }}</td>
                          <td>{{ txn.price }} </td>
                        </tr>
                      </template>
                    </tbody>
                    <tfoot>
                      <td></td>
                      <td style="text-align: right">Total :</td>
                      <td><strong>$ {{ total_price }}</strong></td>
                    </tfoot>
                  </table>
                </div>
              </div>
            </div>
          </div>
      </div>
    </template>

v-forディレクティブは、特定の請求書に対してフェッチされたすべてのトランザクションをループできるようにするために使用されます。

コンポーネントの構造を以下に示します。 まず、必要なモジュールとコンポーネントをインポートします。 コンポーネントがmountedの場合、axiosを使用したPOST要求がバックエンドサーバーに対して行われ、データがフェッチされます。 応答が得られたら、それらをそれぞれのコンポーネントプロパティに割り当てます。

    <script>
    import Header from "./Header";
    import axios from "axios";
    export default {
      name: "SingleInvoice",
      components: {
        Header
      },
      data() {
        return {
          invoice: {},
          transactions: [],
          user: "",
          total_price: 0
        };
      },
      methods: {
        send() {}
      },
      mounted() {
        // make request to fetch invoice data
        this.user = JSON.parse(localStorage.getItem("user"));
        let token = localStorage.getItem("token");
        let invoice_id = this.$route.params.invoice_id;
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}/${invoice_id}`, {
            headers: {
              "x-access-token": token
            }
          })
          .then(res => {
            if (res.data.status == true) {
              this.transactions = res.data.transactions;
              this.invoice = res.data.invoice;
              let total = 0;
              this.transactions.forEach(element => {
                total += parseInt(element.price);
              });
              this.total_price = total;
            }
          });
      }
    };
    </script>

注:現在空のsend()メソッドがあります。 記事を読み進めると、必要な機能を追加する理由と方法について理解を深めることができます。

コンポーネントには、次のスコープスタイルがあります。

フロントエンド/src/components/SingleInvoice.vue
    <!-- Add "scoped" attribute to limit CSS to this component only -->
    <style scoped>
    h1,
    h2 {
      font-weight: normal;
    }
    ul {
      list-style-type: none;
      padding: 0;
    }
    li {
      display: inline-block;
      margin: 0 10px;
    }
    a {
      color: #426cb9;
    }
    .single-page {
      background-color: #ffffffe5;
    }
    .invoice{
      margin-top: 20px;
    }
    </style>

ここで、アプリケーションに戻ってView InvoicesタブのTO INVOICE ボタンをクリックすると、単一の請求書ビューが表示されます。

ステップ3—電子メールで請求書を送信する

これは、請求アプリケーションの最後のステップであり、ユーザーが請求書を送信できるようにすることです。 このステップでは、nodemailerモジュールを使用して、バックエンドサーバー上の指定された受信者に電子メールを送信します。 開始するには、最初にモジュールをインストールします。

  1. npm install nodemailer

モジュールがインストールされたので、server.jsファイルを次のように更新します。

server.js
    // import node modules
    [...]
    let nodemailer = require('nodemailer')
    
    // create mail transporter
    let transporter = nodemailer.createTransport({
      service: 'gmail',
      auth: {
        user: '[email protected]',
        pass: 'userpass'
      }
    });
    
    // create express app
    [...]

このメールはバックエンドサーバーに設定され、ユーザーに代わってメールを送信するアカウントになります。 また、セキュリティ設定でテスト目的のために、Gmailアカウントの非セキュアサインインを一時的に許可する必要があります。

server.js

    // configure app routes
    [...]
    app.post("/sendmail", multipartMiddleware, function(req, res) {
      // get name  and email of sender
      let sender = JSON.parse(req.body.user);
      let recipient = JSON.parse(req.body.recipient);
      let mailOptions = {
        from: "[email protected]",
        to: recipient.email,
        subject: `Hi, ${recipient.name}. Here's an Invoice from ${
          sender.company_name
        }`,
        text: `You owe ${sender.company_name}`
      };
      transporter.sendMail(mailOptions, function(error, info) {
        if (error) {
          return res.json({
            status: 200,
            message: `Error sending main to ${recipient.name}`
          });
        } else {
          return res.json({
            status: 200,
            message: `Email sent to ${recipient.name}`
          });
        }
      });
    });

この時点で、POSTリクエストが/sendmailルートに対して行われたときに機能するように電子メールを構成しました。 また、ユーザーがフロントエンドでこのアクションを実行できるようにし、受信者の電子メールアドレスを入力するためのフォームをユーザーに提供する必要があります。 これを行うには、次のようにしてSingleInvoiceコンポーネントを更新します。

フロントエンド/src/components/SingleInvoice.vue
    
    <template>
     <Header v-bind:user="user"/>
        <!--  display invoice data -->
        <div class="invoice">
          <!-- display invoice name here -->
          <div class="container">
            <div class="row">
              <div class="col-md-12">
                // display invoice
              </div>
            </div>
            <div class="row">
              <form @submit.prevent="send" class="col-md-12">
                <h3>Enter Recipient's Name and Email to Send Invoice</h3>
                <div class="form-group">
                  <label for="">Recipient Name</label>
                  <input type="text" required class="form-control" placeholder="eg Chris" v-model="recipient.name">
                </div>
                <div class="form-group">
                  <label for="">Recipient Email</label>
                  <input type="email" required placeholder="eg [email protected]" class="form-control" v-model="recipient.email">
                </div>
                <div class="form-group">
                    <button class="btn btn-primary" >Send Invoice</button>
                    {{ loading }}
                    {{ status }}
                </div>
              </form>
            </div>
          </div>
        </div> 
    </template>

また、コンポーネントのプロパティは次のように更新されます。

フロントエンド/src/components/SingleInvoice.vue
    
    <script>
    import Header from "./Header";
    import axios from "axios";
    export default {
      name: "SingleInvoice",
      components: {
        Header
      },
      data() {
        return {
          invoice: {},
          transactions: [],
          user: '',
          total_price: 0,
          recipient : {
            name: '',
            email: ''
          },
          loading : '',
          status: '',
        };
      },
      methods: {
        send() {
          this.status = "";
          this.loading = "Sending Invoice, please wait....";
          const formData = new FormData();
          formData.append("user", JSON.stringify(this.user));
          formData.append("recipient", JSON.stringify(this.recipient));
          axios.post("http://localhost:3128/sendmail", formData, {
            headers: {"x-access-token": localStorage.getItem("token")}
          }).then(res => {
            this.loading = '';
            this.status = res.data.message
          }); 
        }
      },
      mounted() {
        // make request to fetch invoice data
      }
    };
    </script>

これらの変更を行った後、ユーザーは受信者の電子メールを入力し、アプリから「送信済み請求書」通知を受信できるようになります。

ノードメーラーガイドを確認すると、メールをさらに編集できます。

結論

シリーズのこのパートでは、JWTokenとブラウザーのローカルストレージを使用してユーザーのサインインを維持する方法について説明しました。 また、単一の請求書のビューも作成しました。