简介
在本系列的前几部分中,我们了解了如何创建发票应用程序的用户界面,使用户可以创建和查看现有发票。在本系列的最后一部分,您将在客户端上设置持久用户会话,并为发票配置单一视图。
先决条件
要充分理解本文,您需要具备以下条件:
- 机器上已安装 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
文件中的 login
和 register
方法更新为如下所示:
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 和浏览器的本地存储来保持用户登录。我们还创建了单张发票的视图。