如何使用 Vue 和 Node 构建轻量级发票应用程序?JWT 身份验证和发送发票

简介

在本系列的前几部分中,我们了解了如何创建发票应用程序的用户界面,使用户可以创建和查看现有发票。在本系列的最后一部分,您将在客户端上设置持久用户会话,并为发票配置单一视图。

先决条件

要充分理解本文,您需要具备以下条件:

  • 机器上已安装 Node。
  • 机器上已安装 NPM。
  • 已阅读本系列的 [第一部分](/community/tutorials/how-to-build-a-lightweight-invoicing-app-with-vue-and-node-database-and-api)和 [第二部分](/community/tutorials/how-to-build-a-lightweight-invoicing-app-with-vue-and-node-user-interface)。

要确认安装,请运行以下命令:

1node --version
2npm --version

如果结果是版本号,那就没问题了。

第 1 步 - 使用 JWTokens 在客户端上持久保存用户会话

为了验证我们的应用程序是否安全,只有授权用户才能发出请求,我们将使用 JWTokens。_JWTokens_或 JSON Web 标记由三部分字符串组成,其中包含请求的标题、有效载荷和签名。其核心理念是为每个通过身份验证的用户创建一个令牌,以便他们在向后端服务器发送请求时使用。

要开始使用,请进入 invoicing-app 目录。之后,安装 jsonwebtoken 节点模块,该模块将用于创建和验证我们的 JSON 网络令牌:

1cd invoicing-app 
2npm install jsonwebtoken nodemon --save

nodemon "是一个节点模块,一旦文件发生变化,它就会重启服务器。

现在,更新 server.js 文件,添加以下内容:

1[label server.js]
2
3    // import node modules
4    [...]
5    const jwt = require("jsonwebtoken");
6
7    // create express app
8    [...]
9    app.set('appSecret', 'secretforinvoicingapp'); // this will be used later

接下来要做的是调整 /register/login 路由,以创建令牌,并在用户成功注册或登录后将令牌传回。为此,请在 server.js 文件中添加以下内容:

 1[label server.js]
 2
 3    // edit the /register route
 4    app.post("/register", multipartMiddleware, function(req, res) {
 5      // check to make sure none of the fields are empty
 6      [...]
 7      bcrypt.hash(req.body.password, saltRounds, function(err, hash) {
 8        // create sql query 
 9        [...]
10        db.run(sql, function(err) {
11          if (err) {
12            throw err;
13          } else {
14            let user_id = this.lastID;
15            let query = `SELECT * FROM users WHERE id='${user_id}'`;
16            db.all(query, [], (err, rows) => {
17              if (err) {
18                throw err;
19              }
20              let user = rows[0];
21              delete user.password;
22              //  create payload for JWT
23              const payload = {
24                user: user 
25              }
26              // create token
27              let token = jwt.sign(payload, app.get("appSecret"), {
28                expiresInMinutes: "24h" // expires in 24 hours
29              });
30              // send response back to client
31              return res.json({
32                status: true,
33                token : token
34              });
35            });
36          }
37        });
38        db.close();
39      });
40    });
41
42    [...]

/login 路由执行同样的操作:

 1[label server.js]
 2    app.post("/login", multipartMiddleware, function(req, res) {
 3      //  connect to db 
 4      [...]
 5      db.all(sql, [], (err, rows) => {
 6        // attempt to authenticate the user
 7        [...]
 8        if (authenticated) {
 9          //  create payload for JWT
10          const payload = { user: user };
11          // create token
12          let token = jwt.sign( payload, app.get("appSecret"),{
13            expiresIn: "24h" // expires in 24 hours
14          });
15          return res.json({
16            status: true,
17            token: token
18          });
19        }
20
21        return res.json({
22          status: false,
23          message: "Wrong Password, please retry"
24        });
25      });
26    });

现在,测试工作已经完成。使用以下命令运行服务器:

1nodemon server.js

现在,您的应用程序将在成功登录和注册时创建令牌。下一步是验证传入请求的令牌。为此,请在要保护的路由上方添加以下中间件:

 1[label server.js]
 2
 3    [...]
 4    // unprotected routes
 5
 6    [...]
 7    // Create middleware for protecting routes
 8    app.use(function(req, res, next) {
 9      // check header or url parameters or post parameters for token
10      let token =
11        req.body.token || req.query.token || req.headers["x-access-token"];
12      // decode token
13      if (token) {
14        // verifies secret and checks exp
15        jwt.verify(token, app.get("appSecret"), function(err, decoded) {
16          if (err) {
17            return res.json({
18              success: false,
19              message: "Failed to authenticate token."
20            });
21          } else {
22            // if everything is good, save to request for use in other routes
23            req.decoded = decoded;
24            next();
25          }
26        });
27      } else {
28        // if there is no token
29        // return an error
30        return res.status(403).send({
31          success: false,
32          message: "No token provided."
33        });
34      }
35    });
36
37    // protected routes 
38    [...]

SignUp.vue 文件中,您需要将从服务器获取的令牌和用户数据存储在 localStorage 中,以便用户在使用应用程序时,这些数据可以在不同页面之间持久存在。为此,请将 frontend/src/components/SignUp.vue 文件中的 loginregister 方法更新为如下所示:

 1[label frontend/src/components/SignUp.vue]
 2    [...]
 3    export default {
 4      name: "SignUp",
 5      [...]
 6      methods:{
 7        register(){
 8          const formData = new FormData();
 9          let valid = this.validate();
10          if(valid){
11            // prepare formData
12            [...]
13            // Post to server
14            axios.post("http://localhost:3128/register", formData)
15            .then(res => {
16              // Post a status message
17              this.loading = "";
18              if (res.data.status == true) {
19                // store the user token and user data in localStorage
20                localStorage.setItem('token', res.data.token);
21                localStorage.setItem('user', JSON.stringify(res.data.user));
22                // now send the user to the next route
23                this.$router.push({
24                  name: "Dashboard",
25                });
26              } else {
27                this.status = res.data.message;
28              }
29            });
30          }
31          else{
32            alert("Passwords do not match");
33          }
34        }
35        [...]

我们还要更新登录方法:

 1[label frontend/src/components/SignUp.vue]
 2        login() {
 3          const formData = new FormData();
 4          formData.append("email", this.model.email);
 5          formData.append("password", this.model.password);
 6          this.loading = "Signing in";
 7          // Post to server
 8          axios.post("http://localhost:3128/login", formData).then(res => {
 9            // Post a status message
10            console.log(res);
11            this.loading = "";
12            if (res.data.status == true) {
13              // store the data in localStorage
14              localStorage.setItem("token", res.data.token);
15              localStorage.setItem("user", JSON.stringify(res.data.user));
16              // now send the user to the next route
17              this.$router.push({
18                name: "Dashboard"
19              });
20            } else {
21              this.status = res.data.message;
22            }
23          });

以前,用户数据通过路由参数传递,而现在应用程序从本地存储中获取数据。让我们看看这将如何改变我们的组件。

仪表盘 "组件以前的样子是这样的:

 1[label frontend/src/components/Dashboard.vue]
 2
 3    <script>
 4    import Header from "./Header";
 5    import CreateInvoice from "./CreateInvoice";
 6    import ViewInvoices from "./ViewInvoices";
 7    export default {
 8      name: "Dashboard",
 9      components: {
10        Header,
11        CreateInvoice,
12        ViewInvoices,
13      },
14      data() {
15        return {
16          isactive: 'create',
17          title: "Invoicing App",
18          user : (this.$route.params.user) ? this.$route.params.user : null
19        };
20      }
21    };
22    </script>

这意味着当用户登录或注册时,他们会被重定向到 "仪表板 "页面,然后 "仪表板 "组件的 "user "属性会相应地更新。如果用户决定刷新页面,由于 this.$route.params.user 不再存在,因此无法识别该用户。

编辑您的 Dashboard 组件,使其使用浏览器的 localStorage

 1[label frontend/src/components/Dashboard.vue]
 2
 3    import Header from "./Header";
 4    import CreateInvoice from "./CreateInvoice";
 5    import ViewInvoices from "./ViewInvoices";
 6    export default {
 7      name: "Dashboard",
 8      components: {
 9        Header,
10        CreateInvoice,
11        ViewInvoices,
12      },
13      data() {
14        return {
15          isactive: 'create',
16          title: "Invoicing App",
17          user : null,
18        };
19      },
20      mounted(){
21        this.user = JSON.parse(localStorage.getItem("user"));
22      }
23    };

现在,用户数据将在刷新页面后持续存在。在请求时,还必须在请求中添加令牌。

请看 "ViewInvoices "组件。下面是该组件的 JavaScript:

 1[label frontend/src/components/ViewInvoices.vue]
 2    <script>
 3    import axios from "axios";
 4    export default {
 5      name: "ViewInvoices",
 6      components: {},
 7      data() {
 8        return {
 9          invoices: [],
10\          user: '',
11        };
12      },
13      mounted() {
14        this.user = JSON.parse(localStorage.getItem('user'));
15        axios
16          .get(`http://localhost:3128/invoice/user/${this.user.id}`)
17          .then(res => {
18            if (res.data.status == true) {
19              console.log(res.data.invoices);
20              this.invoices = res.data.invoices;
21            }
22          });
23      }
24    };
25    </script>

如果当前尝试查看已登录用户的发票,则会在检索发票时因缺少令牌而出错。

这是因为应用程序的 "invoice/user/:user_id "路由现在受到了您先前设置的令牌中间件的保护。将其添加到请求中可修复此错误:

 1[label frontend/src/components/ViewInvoices.vue]
 2    <script>
 3    import axios from "axios";
 4    export default {
 5      name: "ViewInvoices",
 6      components: {},
 7      data() {
 8        return {
 9          invoices: [],
10          user: '',
11        };
12      },
13      mounted() {
14        this.user = JSON.parse(localStorage.getItem('user'));
15        axios
16          .get(`http://localhost:3128/invoice/user/${this.user.id}`,
17            {
18              headers: {"x-access-token": localStorage.getItem("token")}
19            }
20          )
21          .then(res => {
22            if (res.data.status == true) {
23              console.log(res.data.invoices);
24              this.invoices = res.data.invoices;
25            }
26          });
27      }
28    };
29    </script>

保存后返回浏览器,就可以成功获取发票了:

第 2 步 - 为发票创建单一视图

当点击 TO INVOICE 按钮时,什么也不会发生。要解决这个问题,请创建一个 SingleInvoice.vue 文件并按如下方式编辑:

 1<template>
 2      <div class="single-page">
 3        <Header v-bind:user="user"/>
 4        <!--  display invoice data -->
 5        <div class="invoice">
 6          <!-- display invoice name here -->
 7          <div class="container">
 8            <div class="row">
 9                <div class="col-md-12">
10                  <h3>Invoice #{{ invoice.id }} by {{ user.company_name }}</h3>
11                  <table class="table">
12                    <thead>
13                      <tr>
14                        <th scope="col">#</th>
15                        <th scope="col">Transaction Name</th>
16                        <th scope="col">Price ($)</th>
17                      </tr>
18                    </thead>
19                    <tbody>
20                      <template v-for="txn in transactions">
21                        <tr :key="txn.id">
22                          <th>{{ txn.id }}</th>
23                          <td>{{ txn.name }}</td>
24                          <td>{{ txn.price }} </td>
25                        </tr>
26                      </template>
27                    </tbody>
28                    <tfoot>
29                      <td></td>
30                      <td style="text-align: right">Total :</td>
31                      <td><strong>$ {{ total_price }}</strong></td>
32                    </tfoot>
33                  </table>
34                </div>
35              </div>
36            </div>
37          </div>
38      </div>
39    </template>

使用 v-for 指令可以循环查看特定发票的所有已获取交易。

组件结构如下所示。首先要导入必要的模块和组件。当组件被 "挂载 "后,使用 "axios "向backend服务器发出 "POST "请求以获取数据。获得响应后,我们将其分配给相应的组件属性。

 1<script>
 2    import Header from "./Header";
 3    import axios from "axios";
 4    export default {
 5      name: "SingleInvoice",
 6      components: {
 7        Header
 8      },
 9      data() {
10        return {
11          invoice: {},
12          transactions: [],
13          user: "",
14          total_price: 0
15        };
16      },
17      methods: {
18        send() {}
19      },
20      mounted() {
21        // make request to fetch invoice data
22        this.user = JSON.parse(localStorage.getItem("user"));
23        let token = localStorage.getItem("token");
24        let invoice_id = this.$route.params.invoice_id;
25        axios
26          .get(`http://localhost:3128/invoice/user/${this.user.id}/${invoice_id}`, {
27            headers: {
28              "x-access-token": token
29            }
30          })
31          .then(res => {
32            if (res.data.status == true) {
33              this.transactions = res.data.transactions;
34              this.invoice = res.data.invoice;
35              let total = 0;
36              this.transactions.forEach(element => {
37                total += parseInt(element.price);
38              });
39              this.total_price = total;
40            }
41          });
42      }
43    };
44    </script>

<$>[注] 注: 有一个 send() 方法目前是空的。当您继续阅读本文时,您将更好地理解为什么以及如何添加必要的功能。 <$>

该组件具有以下范围样式:

 1[label frontend/src/components/SingleInvoice.vue]
 2    <!-- Add "scoped" attribute to limit CSS to this component only -->
 3    <style scoped>
 4    h1,
 5    h2 {
 6      font-weight: normal;
 7    }
 8    ul {
 9      list-style-type: none;
10      padding: 0;
11    }
12    li {
13      display: inline-block;
14      margin: 0 10px;
15    }
16    a {
17      color: #426cb9;
18    }
19    .single-page {
20      background-color: #ffffffe5;
21    }
22    .invoice{
23      margin-top: 20px;
24    }
25    </style>

现在,如果您返回应用程序并单击 "查看发票 "选项卡中的TO INVOICE 按钮,您将看到单张发票视图。

步骤 3 - 通过电子邮件发送发票

发票应用程序的最后一步是允许用户发送发票。在这一步中,您将使用 "nodemailer "模块向后台服务器上的指定收件人发送电子邮件。要开始使用,首先安装模块:

1npm install nodemailer

现在模块已安装,请按以下步骤更新 server.js 文件:

 1[label server.js]
 2    // import node modules
 3    [...]
 4    let nodemailer = require('nodemailer')
 5
 6    // create mail transporter
 7    let transporter = nodemailer.createTransport({
 8      service: 'gmail',
 9      auth: {
10        user: '[email protected]',
11        pass: 'userpass'
12      }
13    });
14
15    // create express app
16    [...]

该电子邮件将设置在后台服务器上,并由该账户代表用户发送电子邮件。此外,出于测试目的,您需要暂时允许非安全登录 Gmail 帐户在安全设置中

 1[label server.js]
 2
 3    // configure app routes
 4    [...]
 5    app.post("/sendmail", multipartMiddleware, function(req, res) {
 6      // get name and email of sender
 7      let sender = JSON.parse(req.body.user);
 8      let recipient = JSON.parse(req.body.recipient);
 9      let mailOptions = {
10        from: "[email protected]",
11        to: recipient.email,
12        subject: `Hi, ${recipient.name}. Here's an Invoice from ${
13          sender.company_name
14        }`,
15        text: `You owe ${sender.company_name}`
16      };
17      transporter.sendMail(mailOptions, function(error, info) {
18        if (error) {
19          return res.json({
20            status: 200,
21            message: `Error sending main to ${recipient.name}`
22          });
23        } else {
24          return res.json({
25            status: 200,
26            message: `Email sent to ${recipient.name}`
27          });
28        }
29      });
30    });

至此,您已将电子邮件配置为在向 /sendmail 路由发出 POST 请求时起作用。您还需要允许用户在前端执行此操作,并为他们提供一个输入收件人电子邮件地址的表单。为此,请按以下步骤更新 SingleInvoice 组件:

 1[label frontend/src/components/SingleInvoice.vue]
 2
 3    <template>
 4     <Header v-bind:user="user"/>
 5        <!--  display invoice data -->
 6        <div class="invoice">
 7          <!-- display invoice name here -->
 8          <div class="container">
 9            <div class="row">
10              <div class="col-md-12">
11                // display invoice
12              </div>
13            </div>
14            <div class="row">
15              <form @submit.prevent="send" class="col-md-12">
16                <h3>Enter Recipient's Name and Email to Send Invoice</h3>
17                <div class="form-group">
18                  <label for="">Recipient Name</label>
19                  <input type="text" required class="form-control" placeholder="eg Chris" v-model="recipient.name">
20                </div>
21                <div class="form-group">
22                  <label for="">Recipient Email</label>
23                  <input type="email" required placeholder="eg [email protected]" class="form-control" v-model="recipient.email">
24                </div>
25                <div class="form-group">
26                    <button class="btn btn-primary" >Send Invoice</button>
27                    {{ loading }}
28                    {{ status }}
29                </div>
30              </form>
31            </div>
32          </div>
33        </div> 
34    </template>

此外,组件属性也会更新如下:

 1[label frontend/src/components/SingleInvoice.vue]
 2
 3    <script>
 4    import Header from "./Header";
 5    import axios from "axios";
 6    export default {
 7      name: "SingleInvoice",
 8      components: {
 9        Header
10      },
11      data() {
12        return {
13          invoice: {},
14          transactions: [],
15          user: '',
16          total_price: 0,
17          recipient : {
18            name: '',
19            email: ''
20          },
21          loading : '',
22          status: '',
23        };
24      },
25      methods: {
26        send() {
27          this.status = "";
28          this.loading = "Sending Invoice, please wait....";
29          const formData = new FormData();
30          formData.append("user", JSON.stringify(this.user));
31          formData.append("recipient", JSON.stringify(this.recipient));
32          axios.post("http://localhost:3128/sendmail", formData, {
33            headers: {"x-access-token": localStorage.getItem("token")}
34          }).then(res => {
35            this.loading = '';
36            this.status = res.data.message
37          }); 
38        }
39      },
40      mounted() {
41        // make request to fetch invoice data
42      }
43    };
44    </script>

进行这些更改后,用户就可以输入收件人电子邮件,并从应用程序中收到 "已发送发票 "通知。

您可以查看 nodemailer指南,进一步编辑电子邮件。

结论

在本系列的这一部分,我们了解了如何使用 JWTokens 和浏览器的本地存储来保持用户登录。我们还创建了单张发票的视图。

Published At
Categories with 技术
Tagged with
comments powered by Disqus