From 3d6c8b7f05f392adb9d6e2a409d67c5780afa640 Mon Sep 17 00:00:00 2001
From: Bert Verhelst <verhelstbert@gmail.com>
Date: Fri, 1 Oct 2021 12:49:49 +0200
Subject: [PATCH 1/6] fix(heartbeat-bar): cleanup css styling and minor syntax
 issues

---
 src/assets/app.scss             |  4 ++--
 src/components/HeartbeatBar.vue | 23 +++++++++++++----------
 src/components/MonitorList.vue  | 24 ++++++++++++------------
 src/components/Uptime.vue       | 16 ++++++++--------
 4 files changed, 35 insertions(+), 32 deletions(-)

diff --git a/src/assets/app.scss b/src/assets/app.scss
index 34a4560..80ec08c 100644
--- a/src/assets/app.scss
+++ b/src/assets/app.scss
@@ -321,7 +321,7 @@ h2 {
     .item {
         display: block;
         text-decoration: none;
-        padding: 13px 15px 10px 15px;
+        padding: 10px 15px 10px 15px;
         border-radius: 10px;
         transition: all ease-in-out 0.15s;
 
@@ -413,4 +413,4 @@ h2 {
 
 // Localization
 
-@import "localization.scss";
\ No newline at end of file
+@import "localization.scss";
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index 4dc2c71..459a4ad 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -38,7 +38,7 @@ export default {
             beatMargin: 4,
             move: false,
             maxBeat: -1,
-        }
+        };
     },
     computed: {
 
@@ -69,12 +69,12 @@ export default {
             if (start < 0) {
                 // Add empty placeholder
                 for (let i = start; i < 0; i++) {
-                    placeholders.push(0)
+                    placeholders.push(0);
                 }
                 start = 0;
             }
 
-            return placeholders.concat(this.beatList.slice(start))
+            return placeholders.concat(this.beatList.slice(start));
         },
 
         wrapStyle() {
@@ -84,7 +84,7 @@ export default {
             return {
                 padding: `${topBottom}px ${leftRight}px`,
                 width: "100%",
-            }
+            };
         },
 
         barStyle() {
@@ -94,12 +94,12 @@ export default {
                 return {
                     transition: "all ease-in-out 0.25s",
                     transform: `translateX(${width}px)`,
-                }
+                };
 
             }
             return {
                 transform: "translateX(0)",
-            }
+            };
 
         },
 
@@ -109,7 +109,7 @@ export default {
                 height: this.beatHeight + "px",
                 margin: this.beatMargin + "px",
                 "--hover-scale": this.hoverScale,
-            }
+            };
         },
 
     },
@@ -120,7 +120,7 @@ export default {
 
                 setTimeout(() => {
                     this.move = false;
-                }, 300)
+                }, 300);
             },
             deep: true,
         },
@@ -162,7 +162,7 @@ export default {
     methods: {
         resize() {
             if (this.$refs.wrap) {
-                this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2))
+                this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2));
             }
         },
 
@@ -170,7 +170,7 @@ export default {
             return `${this.$root.datetime(beat.time)} - ${beat.msg}`;
         }
     },
-}
+};
 </script>
 
 <style lang="scss" scoped>
@@ -183,6 +183,9 @@ export default {
 }
 
 .hp-bar-big {
+    display: flex;
+    justify-content: flex-end;
+
     .beat {
         display: inline-block;
         background-color: $primary;
diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue
index fb3fcfb..54c5873 100644
--- a/src/components/MonitorList.vue
+++ b/src/components/MonitorList.vue
@@ -3,10 +3,10 @@
         <div class="list-header">
             <div class="placeholder"></div>
             <div class="search-wrapper">
-                <a v-if="searchText == ''" class="search-icon">
+                <a v-if="!searchText" class="search-icon">
                     <font-awesome-icon icon="search" />
                 </a>
-                <a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
+                <a v-if="searchText" class="search-icon" @click="clearSearchText">
                     <font-awesome-icon icon="times" />
                 </a>
                 <input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" />
@@ -19,21 +19,21 @@
 
             <router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }">
                 <div class="row">
-                    <div class="col-6 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
+                    <div class="col-6 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar === 'bottom' || $root.userHeartbeatBar === 'none' }">
                         <div class="info">
                             <Uptime :monitor="item" type="24" :pill="true" />
-                            {{ item.name }}
+                            <span class="ms-1">{{ item.name }}</span>
                         </div>
                         <div class="tags">
                             <Tag v-for="tag in item.tags" :key="tag" :item="tag" :size="'sm'" />
                         </div>
                     </div>
-                    <div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-6 col-md-4">
+                    <div v-show="$root.userHeartbeatBar === 'normal'" :key="$root.userHeartbeatBar" class="col-6 col-md-4 small-padding">
                         <HeartbeatBar size="small" :monitor-id="item.id" />
                     </div>
                 </div>
 
-                <div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
+                <div v-if="$root.userHeartbeatBar === 'bottom'" class="row">
                     <div class="col-12">
                         <HeartbeatBar size="small" :monitor-id="item.id" />
                     </div>
@@ -62,7 +62,7 @@ export default {
     data() {
         return {
             searchText: "",
-        }
+        };
     },
     computed: {
         sortedMonitorList() {
@@ -91,17 +91,17 @@ export default {
                 }
 
                 return m1.name.localeCompare(m2.name);
-            })
+            });
 
             // Simple filter by search text
             // finds monitor name, tag name or tag value
-            if (this.searchText != "") {
+            if (this.searchText) {
                 const loweredSearchText = this.searchText.toLowerCase();
                 result = result.filter(monitor => {
                     return monitor.name.toLowerCase().includes(loweredSearchText)
                     || monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
-                    || tag.value?.toLowerCase().includes(loweredSearchText))
-                })
+                    || tag.value?.toLowerCase().includes(loweredSearchText));
+                });
             }
 
             return result;
@@ -115,7 +115,7 @@ export default {
             this.searchText = "";
         }
     },
-}
+};
 </script>
 
 <style lang="scss" scoped>
diff --git a/src/components/Uptime.vue b/src/components/Uptime.vue
index a4bf22f..a7931c1 100644
--- a/src/components/Uptime.vue
+++ b/src/components/Uptime.vue
@@ -22,33 +22,33 @@ export default {
                 return Math.round(this.$root.uptimeList[key] * 10000) / 100 + "%";
             }
 
-            return this.$t("notAvailableShort")
+            return this.$t("notAvailableShort");
         },
 
         color() {
             if (this.lastHeartBeat.status === 0) {
-                return "danger"
+                return "danger";
             }
 
             if (this.lastHeartBeat.status === 1) {
-                return "primary"
+                return "primary";
             }
 
             if (this.lastHeartBeat.status === 2) {
-                return "warning"
+                return "warning";
             }
 
-            return "secondary"
+            return "secondary";
         },
 
         lastHeartBeat() {
             if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
-                return this.$root.lastHeartbeatList[this.monitor.id]
+                return this.$root.lastHeartbeatList[this.monitor.id];
             }
 
             return {
                 status: -1,
-            }
+            };
         },
 
         className() {
@@ -59,7 +59,7 @@ export default {
             return "";
         },
     },
-}
+};
 </script>
 
 <style>

From 13bdfefa9d36b299b1852b475f85793f007dfaef Mon Sep 17 00:00:00 2001
From: Nelson Chan <chakflying@hotmail.com>
Date: Fri, 1 Oct 2021 18:44:32 +0800
Subject: [PATCH 2/6] Feat: Improve Certificaet Info Display

---
 server/prometheus.js                  |   2 +-
 server/util-server.js                 |  56 ++++++------
 src/components/CertificateInfo.vue    |  30 +++++++
 src/components/CertificateInfoRow.vue | 106 +++++++++++++++++++++++
 src/icon.js                           |   4 +
 src/mixins/socket.js                  |   4 +-
 src/pages/Details.vue                 | 118 ++++++++++----------------
 7 files changed, 216 insertions(+), 104 deletions(-)
 create mode 100644 src/components/CertificateInfo.vue
 create mode 100644 src/components/CertificateInfoRow.vue

diff --git a/server/prometheus.js b/server/prometheus.js
index 3e4767b..c27f87f 100644
--- a/server/prometheus.js
+++ b/server/prometheus.js
@@ -59,7 +59,7 @@ class Prometheus {
             }
 
             try {
-                monitor_cert_days_remaining.set(this.monitorLabelValues, tlsInfo.daysRemaining)
+                monitor_cert_days_remaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining)
             } catch (e) {
                 console.error(e)
             }
diff --git a/server/util-server.js b/server/util-server.js
index 29e4b11..66c50d8 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -185,38 +185,42 @@ const getDaysRemaining = (validFrom, validTo) => {
     return daysRemaining;
 };
 
-exports.checkCertificate = function (res) {
-    const {
-        valid_from,
-        valid_to,
-        subjectaltname,
-        issuer,
-        fingerprint,
-    } = res.request.res.socket.getPeerCertificate(false);
-
-    if (!valid_from || !valid_to || !subjectaltname) {
-        throw {
-            message: "No TLS certificate in response",
-        };
+// Fix certificate Info for display
+// param: info -  the chain obtained from getPeerCertificate()
+const parseCertificateInfo = function (info) {
+    let link = info;
+
+    while (link) {
+        if (!link.valid_from || !link.valid_to) {
+            break;
+        }
+        link.validTo = new Date(link.valid_to);
+        link.validFor = link.subjectaltname?.replace(/DNS:|IP Address:/g, "").split(", ");
+        link.daysRemaining = getDaysRemaining(new Date(), link.validTo);
+
+        // Move up the chain until loop is encountered
+        if (link.issuerCertificate == null) {
+            break;
+        } else if (link.fingerprint == link.issuerCertificate.fingerprint) {
+            link.issuerCertificate = null;
+            break;
+        } else {
+            link = link.issuerCertificate;
+        }
     }
 
-    const valid = res.request.res.socket.authorized || false;
-
-    const validTo = new Date(valid_to);
+    return info;
+};
 
-    const validFor = subjectaltname
-        .replace(/DNS:|IP Address:/g, "")
-        .split(", ");
+exports.checkCertificate = function (res) {
+    const info = res.request.res.socket.getPeerCertificate(true);
+    const valid = res.request.res.socket.authorized || false;
 
-    const daysRemaining = getDaysRemaining(new Date(), validTo);
+    const parsedInfo = parseCertificateInfo(info);
 
     return {
-        valid,
-        validFor,
-        validTo,
-        daysRemaining,
-        issuer,
-        fingerprint,
+        valid: valid,
+        certInfo: parsedInfo
     };
 };
 
diff --git a/src/components/CertificateInfo.vue b/src/components/CertificateInfo.vue
new file mode 100644
index 0000000..0b23c95
--- /dev/null
+++ b/src/components/CertificateInfo.vue
@@ -0,0 +1,30 @@
+<template>
+    <div>
+        <h4>{{ $t("Certificate Info") }}</h4>
+        {{ $t("Certificate Chain") }}:
+        <div v-if="valid" class="rounded d-inline-flex ms-2 py-1 px-3 bg-success text-white">{{ $t("Valid") }}</div>
+        <div v-if="!valid" class="rounded d-inline-flex ms-2 py-1 px-3 bg-danger text-white">{{ $t("Invalid") }}</div>
+        <certificate-info-row :cert="certInfo" />
+    </div>
+</template>
+
+<script>
+import CertificateInfoRow from "./CertificateInfoRow.vue";
+export default {
+    components: {
+        CertificateInfoRow,
+    },
+    props: {
+        certInfo: {
+            type: Object,
+            required: true,
+        },
+        valid: {
+            type: Boolean,
+            required: true,
+        },
+    },
+};
+</script>
+
+<style></style>
diff --git a/src/components/CertificateInfoRow.vue b/src/components/CertificateInfoRow.vue
new file mode 100644
index 0000000..2b37d6e
--- /dev/null
+++ b/src/components/CertificateInfoRow.vue
@@ -0,0 +1,106 @@
+<template>
+    <div>
+        <div class="d-flex flex-row align-items-center p-1 overflow-hidden">
+            <div class="m-3 ps-3">
+                <font-awesome-icon class="cert-icon" icon="file-contract" />
+            </div>
+            <div class="m-3">
+                <table class="text-start">
+                    <tbody>
+                        <tr class="my-3">
+                            <td class="px-3">Subject:</td>
+                            <td>{{ formatSubject(cert.subject) }}</td>
+                        </tr>
+                        <tr class="my-3">
+                            <td class="px-3">Valid To:</td>
+                            <td><Datetime :value="cert.validTo" /></td>
+                        </tr>
+                        <tr class="my-3">
+                            <td class="px-3">Days Remaining:</td>
+                            <td>{{ cert.daysRemaining }}</td>
+                        </tr>
+                        <tr class="my-3">
+                            <td class="px-3">Issuer:</td>
+                            <td>{{ formatSubject(cert.issuer) }}</td>
+                        </tr>
+                        <tr class="my-3">
+                            <td class="px-3">Fingerprint:</td>
+                            <td>{{ cert.fingerprint }}</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+        <div class="d-flex">
+            <font-awesome-icon
+                v-if="cert.issuerCertificate"
+                class="m-2 ps-6 link-icon"
+                icon="link"
+            />
+        </div>
+        <certificate-info-row
+            v-if="cert.issuerCertificate"
+            :cert="cert.issuerCertificate"
+        />
+    </div>
+</template>
+
+<script>
+import Datetime from "../components/Datetime.vue";
+export default {
+    name: "CertificateInfoRow",
+    components: {
+        Datetime,
+    },
+    props: {
+        cert: {
+            type: Object,
+            required: true,
+        },
+    },
+    methods: {
+        formatSubject(subject) {
+            if (subject.O && subject.CN && subject.C) {
+                return `${subject.CN} - ${subject.O} (${subject.C})`;
+            } else if (subject.O && subject.CN) {
+                return `${subject.CN} - ${subject.O}`;
+            } else if (subject.CN) {
+                return subject.CN;
+            } else {
+                return "no info";
+            }
+        },
+    },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+table {
+    overflow: hidden;
+}
+
+.cert-icon {
+    font-size: 70px;
+    color: $link-color;
+    opacity: 0.5;
+
+    .dark & {
+        color: $dark-font-color;
+        opacity: 0.3;
+    }
+}
+
+.link-icon {
+    font-size: 20px;
+    margin-left: 50px !important;
+    color: $link-color;
+    opacity: 0.5;
+
+    .dark & {
+        color: $dark-font-color;
+        opacity: 0.3;
+    }
+}
+</style>
diff --git a/src/icon.js b/src/icon.js
index 6fb9149..5ac3511 100644
--- a/src/icon.js
+++ b/src/icon.js
@@ -30,6 +30,8 @@ import {
     faUpload,
     faCopy,
     faCheck,
+    faFileContract,
+    faLink,
 } from "@fortawesome/free-solid-svg-icons";
 
 library.add(
@@ -59,6 +61,8 @@ library.add(
     faUpload,
     faCopy,
     faCheck,
+    faFileContract,
+    faLink,
 );
 
 export { FontAwesomeIcon };
diff --git a/src/mixins/socket.js b/src/mixins/socket.js
index a14771c..8a12ae4 100644
--- a/src/mixins/socket.js
+++ b/src/mixins/socket.js
@@ -30,7 +30,7 @@ export default {
             importantHeartbeatList: { },
             avgPingList: { },
             uptimeList: { },
-            certInfoList: {},
+            tlsInfoList: {},
             notificationList: [],
             connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...",
         };
@@ -154,7 +154,7 @@ export default {
             });
 
             socket.on("certInfo", (monitorID, data) => {
-                this.certInfoList[monitorID] = JSON.parse(data);
+                this.tlsInfoList[monitorID] = JSON.parse(data);
             });
 
             socket.on("importantHeartbeatList", (monitorID, data, overwrite) => {
diff --git a/src/pages/Details.vue b/src/pages/Details.vue
index e4aeb28..ee0c494 100644
--- a/src/pages/Details.vue
+++ b/src/pages/Details.vue
@@ -73,11 +73,11 @@
                         <span class="num"><Uptime :monitor="monitor" type="720" /></span>
                     </div>
 
-                    <div v-if="certInfo" class="col">
+                    <div v-if="tlsInfo" class="col">
                         <h4>{{ $t("Cert Exp.") }}</h4>
-                        <p>(<Datetime :value="certInfo.validTo" date-only />)</p>
+                        <p>(<Datetime :value="tlsInfo.certInfo.validTo" date-only />)</p>
                         <span class="num">
-                            <a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ certInfo.daysRemaining }} {{ $t("days") }}</a>
+                            <a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $t("days") }}</a>
                         </span>
                     </div>
                 </div>
@@ -87,41 +87,7 @@
                 <div v-if="showCertInfoBox" class="shadow-box big-padding text-center">
                     <div class="row">
                         <div class="col">
-                            <h4>{{ $t("Certificate Info") }}</h4>
-                            <table class="text-start">
-                                <tbody>
-                                    <tr class="my-3">
-                                        <td class="px-3">
-                                            Valid:
-                                        </td>
-                                        <td>{{ certInfo.valid }}</td>
-                                    </tr>
-                                    <tr class="my-3">
-                                        <td class="px-3">
-                                            Valid To:
-                                        </td>
-                                        <td><Datetime :value="certInfo.validTo" /></td>
-                                    </tr>
-                                    <tr class="my-3">
-                                        <td class="px-3">
-                                            Days Remaining:
-                                        </td>
-                                        <td>{{ certInfo.daysRemaining }}</td>
-                                    </tr>
-                                    <tr class="my-3">
-                                        <td class="px-3">
-                                            Issuer:
-                                        </td>
-                                        <td>{{ certInfo.issuer }}</td>
-                                    </tr>
-                                    <tr class="my-3">
-                                        <td class="px-3">
-                                            Fingerprint:
-                                        </td>
-                                        <td>{{ certInfo.fingerprint }}</td>
-                                    </tr>
-                                </tbody>
-                            </table>
+                            <certificate-info :certInfo="tlsInfo.certInfo" :valid="tlsInfo.valid" />
                         </div>
                     </div>
                 </div>
@@ -207,8 +173,8 @@
 
 <script>
 import { defineAsyncComponent } from "vue";
-import { useToast } from "vue-toastification"
-const toast = useToast()
+import { useToast } from "vue-toastification";
+const toast = useToast();
 import Confirm from "../components/Confirm.vue";
 import HeartbeatBar from "../components/HeartbeatBar.vue";
 import Status from "../components/Status.vue";
@@ -218,6 +184,7 @@ import Uptime from "../components/Uptime.vue";
 import Pagination from "v-pagination-3";
 const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
 import Tag from "../components/Tag.vue";
+import CertificateInfo from "../components/CertificateInfo.vue";
 
 export default {
     components: {
@@ -230,6 +197,7 @@ export default {
         Pagination,
         PingChart,
         Tag,
+        CertificateInfo,
     },
     data() {
         return {
@@ -239,32 +207,32 @@ export default {
             toggleCertInfoBox: false,
             showPingChartBox: true,
             paginationConfig: {
-                texts:{
-                    count:`${this.$t("Showing {from} to {to} of {count} records")}|{count} ${this.$t("records")}|${this.$t("One record")}`,
-                    first:this.$t("First"),
-                    last:this.$t("Last"),
-                    nextPage:'>',
-                    nextChunk:'>>',
-                    prevPage:'<',
-                    prevChunk:'<<'
+                texts: {
+                    count: `${this.$t("Showing {from} to {to} of {count} records")}|{count} ${this.$t("records")}|${this.$t("One record")}`,
+                    first: this.$t("First"),
+                    last: this.$t("Last"),
+                    nextPage: ">",
+                    nextChunk: ">>",
+                    prevPage: "<",
+                    prevChunk: "<<"
                 }
             }
-        }
+        };
     },
     computed: {
         monitor() {
-            let id = this.$route.params.id
+            let id = this.$route.params.id;
             return this.$root.monitorList[id];
         },
 
         lastHeartBeat() {
             if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
-                return this.$root.lastHeartbeatList[this.monitor.id]
+                return this.$root.lastHeartbeatList[this.monitor.id];
             }
 
             return {
                 status: -1,
-            }
+            };
         },
 
         ping() {
@@ -272,7 +240,7 @@ export default {
                 return this.lastHeartBeat.ping;
             }
 
-            return this.$t("notAvailableShort")
+            return this.$t("notAvailableShort");
         },
 
         avgPing() {
@@ -280,14 +248,14 @@ export default {
                 return this.$root.avgPingList[this.monitor.id];
             }
 
-            return this.$t("notAvailableShort")
+            return this.$t("notAvailableShort");
         },
 
         importantHeartBeatList() {
             if (this.$root.importantHeartbeatList[this.monitor.id]) {
                 // eslint-disable-next-line vue/no-side-effects-in-computed-properties
                 this.heartBeatList = this.$root.importantHeartbeatList[this.monitor.id];
-                return this.$root.importantHeartbeatList[this.monitor.id]
+                return this.$root.importantHeartbeatList[this.monitor.id];
             }
 
             return [];
@@ -295,22 +263,22 @@ export default {
 
         status() {
             if (this.$root.statusList[this.monitor.id]) {
-                return this.$root.statusList[this.monitor.id]
+                return this.$root.statusList[this.monitor.id];
             }
 
-            return { }
+            return { };
         },
 
-        certInfo() {
-            if (this.$root.certInfoList[this.monitor.id]) {
-                return this.$root.certInfoList[this.monitor.id]
+        tlsInfo() {
+            if (this.$root.tlsInfoList[this.monitor.id]) {
+                return this.$root.tlsInfoList[this.monitor.id];
             }
 
-            return null
+            return null;
         },
 
         showCertInfoBox() {
-            return this.certInfo != null && this.toggleCertInfoBox;
+            return this.tlsInfo != null && this.toggleCertInfoBox;
         },
 
         displayedRecords() {
@@ -324,8 +292,8 @@ export default {
     },
     methods: {
         testNotification() {
-            this.$root.getSocket().emit("testNotification", this.monitor.id)
-            toast.success("Test notification is requested.")
+            this.$root.getSocket().emit("testNotification", this.monitor.id);
+            toast.success("Test notification is requested.");
         },
 
         pauseDialog() {
@@ -334,14 +302,14 @@ export default {
 
         resumeMonitor() {
             this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
-                this.$root.toastRes(res)
-            })
+                this.$root.toastRes(res);
+            });
         },
 
         pauseMonitor() {
             this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
-                this.$root.toastRes(res)
-            })
+                this.$root.toastRes(res);
+            });
         },
 
         deleteDialog() {
@@ -360,11 +328,11 @@ export default {
             this.$root.deleteMonitor(this.monitor.id, (res) => {
                 if (res.ok) {
                     toast.success(res.msg);
-                    this.$router.push("/dashboard")
+                    this.$router.push("/dashboard");
                 } else {
                     toast.error(res.msg);
                 }
-            })
+            });
         },
 
         clearEvents() {
@@ -372,7 +340,7 @@ export default {
                 if (! res.ok) {
                     toast.error(res.msg);
                 }
-            })
+            });
         },
 
         clearHeartbeats() {
@@ -380,13 +348,13 @@ export default {
                 if (! res.ok) {
                     toast.error(res.msg);
                 }
-            })
+            });
         },
 
         pingTitle(average = false) {
-            let translationPrefix = ""
+            let translationPrefix = "";
             if (average) {
-                translationPrefix = "Avg. "
+                translationPrefix = "Avg. ";
             }
 
             if (this.monitor.type === "http") {
@@ -396,7 +364,7 @@ export default {
             return this.$t(translationPrefix + "Ping");
         },
     },
-}
+};
 </script>
 
 <style lang="scss" scoped>

From 1c2adf8723e6664501a31d03a376f35030631da6 Mon Sep 17 00:00:00 2001
From: Bert Verhelst <verhelstbert@gmail.com>
Date: Fri, 1 Oct 2021 15:43:20 +0200
Subject: [PATCH 3/6] fix(monitor-list): increase padding to keep same monitor
 item height

---
 src/assets/app.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/assets/app.scss b/src/assets/app.scss
index 80ec08c..7e4fc07 100644
--- a/src/assets/app.scss
+++ b/src/assets/app.scss
@@ -321,7 +321,7 @@ h2 {
     .item {
         display: block;
         text-decoration: none;
-        padding: 10px 15px 10px 15px;
+        padding: 14px 15px;
         border-radius: 10px;
         transition: all ease-in-out 0.15s;
 

From b7568e9caa14400d6972b892278eb4398a629b81 Mon Sep 17 00:00:00 2001
From: Nelson Chan <chakflying@hotmail.com>
Date: Fri, 1 Oct 2021 22:29:22 +0800
Subject: [PATCH 4/6] Fix: Update Certificate Icon

---
 src/components/CertificateInfoRow.vue | 18 +++++++++++++++++-
 src/icon.js                           |  6 ++++--
 2 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/src/components/CertificateInfoRow.vue b/src/components/CertificateInfoRow.vue
index 2b37d6e..df726eb 100644
--- a/src/components/CertificateInfoRow.vue
+++ b/src/components/CertificateInfoRow.vue
@@ -2,7 +2,10 @@
     <div>
         <div class="d-flex flex-row align-items-center p-1 overflow-hidden">
             <div class="m-3 ps-3">
-                <font-awesome-icon class="cert-icon" icon="file-contract" />
+                <div class="cert-icon">
+                    <font-awesome-icon icon="file" />
+                    <font-awesome-icon class="award-icon" icon="award" />
+                </div>
             </div>
             <div class="m-3">
                 <table class="text-start">
@@ -82,6 +85,7 @@ table {
 }
 
 .cert-icon {
+    position: relative;
     font-size: 70px;
     color: $link-color;
     opacity: 0.5;
@@ -92,6 +96,18 @@ table {
     }
 }
 
+.award-icon {
+    position: absolute;
+    font-size: 0.5em;
+    bottom: 20%;
+    left: 12%;
+    color: white;
+
+    .dark & {
+        color: $dark-bg;
+    }
+}
+
 .link-icon {
     font-size: 20px;
     margin-left: 50px !important;
diff --git a/src/icon.js b/src/icon.js
index 5ac3511..e78992f 100644
--- a/src/icon.js
+++ b/src/icon.js
@@ -30,7 +30,8 @@ import {
     faUpload,
     faCopy,
     faCheck,
-    faFileContract,
+    faFile,
+    faAward,
     faLink,
 } from "@fortawesome/free-solid-svg-icons";
 
@@ -61,7 +62,8 @@ library.add(
     faUpload,
     faCopy,
     faCheck,
-    faFileContract,
+    faFile,
+    faAward,
     faLink,
 );
 

From 668fd58af37318c2cdbbe0f324821280ea3efcc6 Mon Sep 17 00:00:00 2001
From: Nelson Chan <chakflying@hotmail.com>
Date: Fri, 1 Oct 2021 22:43:09 +0800
Subject: [PATCH 5/6] Fix: Slightly improve validity styling

---
 src/components/CertificateInfo.vue | 28 +++++++++++++++++++++++++---
 1 file changed, 25 insertions(+), 3 deletions(-)

diff --git a/src/components/CertificateInfo.vue b/src/components/CertificateInfo.vue
index 0b23c95..bb10f15 100644
--- a/src/components/CertificateInfo.vue
+++ b/src/components/CertificateInfo.vue
@@ -2,8 +2,18 @@
     <div>
         <h4>{{ $t("Certificate Info") }}</h4>
         {{ $t("Certificate Chain") }}:
-        <div v-if="valid" class="rounded d-inline-flex ms-2 py-1 px-3 bg-success text-white">{{ $t("Valid") }}</div>
-        <div v-if="!valid" class="rounded d-inline-flex ms-2 py-1 px-3 bg-danger text-white">{{ $t("Invalid") }}</div>
+        <div
+            v-if="valid"
+            class="rounded d-inline-flex ms-2 text-white tag-valid"
+        >
+            {{ $t("Valid") }}
+        </div>
+        <div
+            v-if="!valid"
+            class="rounded d-inline-flex ms-2 text-white tag-invalid"
+        >
+            {{ $t("Invalid") }}
+        </div>
         <certificate-info-row :cert="certInfo" />
     </div>
 </template>
@@ -27,4 +37,16 @@ export default {
 };
 </script>
 
-<style></style>
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.tag-valid {
+    padding: 2px 25px;
+    background-color: $primary;
+}
+
+.tag-invalid {
+    padding: 2px 25px;
+    background-color: $danger;
+}
+</style>

From 259bcf94268ac088b5732544927e5aac1431900e Mon Sep 17 00:00:00 2001
From: LouisLam <louislam@users.noreply.github.com>
Date: Tue, 5 Oct 2021 15:57:13 +0800
Subject: [PATCH 6/6] [SMTP] "To Email" is not required if CC/BCC is set.
 (#461)

---
 package-lock.json                     | 615 +++++++++++++++++++++++++-
 src/components/notifications/SMTP.vue |  19 +-
 2 files changed, 626 insertions(+), 8 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index ca515d7..05d2608 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,14 +62,16 @@
                 "@vitejs/plugin-legacy": "~1.5.3",
                 "@vitejs/plugin-vue": "~1.9.1",
                 "@vue/compiler-sfc": "~3.2.16",
+                "@vue/test-utils": "^2.0.0-rc.15",
+                "@vue/vue3-jest": "^27.0.0-alpha.1",
                 "core-js": "~3.18.0",
                 "cross-env": "~7.0.3",
                 "dns2": "~2.0.1",
                 "eslint": "~7.32.0",
                 "eslint-plugin-vue": "~7.18.0",
-                "jest": "^27.2.4",
-                "jest-puppeteer": "^6.0.0",
-                "puppeteer": "^10.4.0",
+                "jest": "~27.2.4",
+                "jest-puppeteer": "~6.0.0",
+                "puppeteer": "~10.4.0",
                 "sass": "~1.42.1",
                 "stylelint": "~13.13.1",
                 "stylelint-config-standard": "~22.0.0",
@@ -639,6 +641,24 @@
                 "@babel/core": "^7.0.0-0"
             }
         },
+        "node_modules/@babel/plugin-transform-modules-commonjs": {
+            "version": "7.15.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.15.4.tgz",
+            "integrity": "sha512-qg4DPhwG8hKp4BbVDvX1s8cohM8a6Bvptu4l6Iingq5rW+yRUAhe/YRup/YcW2zCOlrysEWVhftIcKzrEZv3sA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-module-transforms": "^7.15.4",
+                "@babel/helper-plugin-utils": "^7.14.5",
+                "@babel/helper-simple-access": "^7.15.4",
+                "babel-plugin-dynamic-import-node": "^2.3.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
         "node_modules/@babel/standalone": {
             "version": "7.15.7",
             "resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.15.7.tgz",
@@ -1653,6 +1673,18 @@
             "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
             "dev": true
         },
+        "node_modules/@types/strip-bom": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
+            "integrity": "sha1-FKjsOVbC6B7bdSB5CuzyHCkK69I=",
+            "dev": true
+        },
+        "node_modules/@types/strip-json-comments": {
+            "version": "0.0.30",
+            "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz",
+            "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
+            "dev": true
+        },
         "node_modules/@types/unist": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -1822,6 +1854,125 @@
             "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.19.tgz",
             "integrity": "sha512-Knqhx7WieLdVgwCAZgTVrDCXZ50uItuecLh9JdLC8O+a5ayaSyIQYveUK3hCRNC7ws5zalHmZwfdLMGaS8r4Ew=="
         },
+        "node_modules/@vue/test-utils": {
+            "version": "2.0.0-rc.15",
+            "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.0.0-rc.15.tgz",
+            "integrity": "sha512-cb+Ri4PDRhtGCJuaLyl1HO9jXcwEj6AFwcNXace8FhhwelDzOdjyIgOb25xtDiUojzWjPuzGLKZQr/5WB7MLew==",
+            "dev": true,
+            "peerDependencies": {
+                "vue": "^3.0.1"
+            }
+        },
+        "node_modules/@vue/vue3-jest": {
+            "version": "27.0.0-alpha.1",
+            "resolved": "https://registry.npmjs.org/@vue/vue3-jest/-/vue3-jest-27.0.0-alpha.1.tgz",
+            "integrity": "sha512-V4erTP0LvI0B4kM/cgSiusF0yahByrqJCAUQKDvpW3104J4njoNUY1HwSMqvv5SASOrzxer4ui3EDWyl8Pw2Lg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/plugin-transform-modules-commonjs": "^7.2.0",
+                "chalk": "^2.1.0",
+                "convert-source-map": "^1.6.0",
+                "extract-from-css": "^0.4.4",
+                "source-map": "0.5.6",
+                "tsconfig": "^7.0.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "7.x",
+                "babel-jest": "27.x",
+                "jest": "27.x",
+                "ts-jest": "27.x",
+                "typescript": ">= 3.x",
+                "vue": "^3.0.0-0"
+            },
+            "peerDependenciesMeta": {
+                "ts-jest": {
+                    "optional": true
+                },
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/ansi-styles": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+            "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+            "dev": true,
+            "dependencies": {
+                "color-convert": "^1.9.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/chalk": {
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+            "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^3.2.1",
+                "escape-string-regexp": "^1.0.5",
+                "supports-color": "^5.3.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/color-convert": {
+            "version": "1.9.3",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+            "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+            "dev": true,
+            "dependencies": {
+                "color-name": "1.1.3"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/color-name": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+            "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+            "dev": true
+        },
+        "node_modules/@vue/vue3-jest/node_modules/escape-string-regexp": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+            "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+            "dev": true,
+            "engines": {
+                "node": ">=0.8.0"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/has-flag": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+            "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/source-map": {
+            "version": "0.5.6",
+            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
+            "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/@vue/vue3-jest/node_modules/supports-color": {
+            "version": "5.5.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+            "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+            "dev": true,
+            "dependencies": {
+                "has-flag": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/abab": {
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
@@ -2076,6 +2227,18 @@
             "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
             "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
         },
+        "node_modules/atob": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+            "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+            "dev": true,
+            "bin": {
+                "atob": "bin/atob.js"
+            },
+            "engines": {
+                "node": ">= 4.5.0"
+            }
+        },
         "node_modules/autoprefixer": {
             "version": "9.8.7",
             "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.7.tgz",
@@ -2170,6 +2333,15 @@
             "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz",
             "integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU="
         },
+        "node_modules/babel-plugin-dynamic-import-node": {
+            "version": "2.3.3",
+            "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+            "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+            "dev": true,
+            "dependencies": {
+                "object.assign": "^4.1.0"
+            }
+        },
         "node_modules/babel-plugin-istanbul": {
             "version": "6.0.0",
             "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz",
@@ -2565,6 +2737,19 @@
                 "node": ">= 0.8"
             }
         },
+        "node_modules/call-bind": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.0.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
         "node_modules/callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3026,6 +3211,18 @@
                 "node": ">= 8"
             }
         },
+        "node_modules/css": {
+            "version": "2.2.4",
+            "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
+            "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==",
+            "dev": true,
+            "dependencies": {
+                "inherits": "^2.0.3",
+                "source-map": "^0.6.1",
+                "source-map-resolve": "^0.5.2",
+                "urix": "^0.1.0"
+            }
+        },
         "node_modules/cssesc": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3198,6 +3395,15 @@
             "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==",
             "dev": true
         },
+        "node_modules/decode-uri-component": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+            "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10"
+            }
+        },
         "node_modules/dedent": {
             "version": "0.7.0",
             "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -3219,6 +3425,18 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/define-properties": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+            "dev": true,
+            "dependencies": {
+                "object-keys": "^1.0.12"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
         "node_modules/delayed-stream": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4072,6 +4290,19 @@
             "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
             "devOptional": true
         },
+        "node_modules/extract-from-css": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/extract-from-css/-/extract-from-css-0.4.4.tgz",
+            "integrity": "sha1-HqffLnx8brmSL6COitrqSG9vj5I=",
+            "dev": true,
+            "dependencies": {
+                "css": "^2.1.0"
+            },
+            "engines": {
+                "node": ">=0.10.0",
+                "npm": ">=2.0.0"
+            }
+        },
         "node_modules/extract-zip": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -4502,6 +4733,20 @@
                 "node": "6.* || 8.* || >= 10.*"
             }
         },
+        "node_modules/get-intrinsic": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
         "node_modules/get-package-type": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -4745,6 +4990,18 @@
                 "node": ">=8"
             }
         },
+        "node_modules/has-symbols": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
         "node_modules/has-unicode": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -7362,6 +7619,33 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/object-keys": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/object.assign": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.0",
+                "define-properties": "^1.1.3",
+                "has-symbols": "^1.0.1",
+                "object-keys": "^1.1.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
         "node_modules/on-finished": {
             "version": "2.3.0",
             "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -8520,6 +8804,13 @@
                 "node": ">=4"
             }
         },
+        "node_modules/resolve-url": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+            "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=",
+            "deprecated": "https://github.com/lydell/resolve-url#deprecated",
+            "dev": true
+        },
         "node_modules/reusify": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -8966,6 +9257,19 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/source-map-resolve": {
+            "version": "0.5.3",
+            "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+            "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+            "dev": true,
+            "dependencies": {
+                "atob": "^2.1.2",
+                "decode-uri-component": "^0.2.0",
+                "resolve-url": "^0.2.1",
+                "source-map-url": "^0.4.0",
+                "urix": "^0.1.0"
+            }
+        },
         "node_modules/source-map-support": {
             "version": "0.5.20",
             "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz",
@@ -8976,6 +9280,12 @@
                 "source-map": "^0.6.0"
             }
         },
+        "node_modules/source-map-url": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+            "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+            "dev": true
+        },
         "node_modules/sourcemap-codec": {
             "version": "1.4.8",
             "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
@@ -9757,6 +10067,36 @@
                 "url": "https://github.com/sponsors/wooorm"
             }
         },
+        "node_modules/tsconfig": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
+            "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==",
+            "dev": true,
+            "dependencies": {
+                "@types/strip-bom": "^3.0.0",
+                "@types/strip-json-comments": "0.0.30",
+                "strip-bom": "^3.0.0",
+                "strip-json-comments": "^2.0.0"
+            }
+        },
+        "node_modules/tsconfig/node_modules/strip-bom": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+            "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tsconfig/node_modules/strip-json-comments": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+            "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/tslib": {
             "version": "2.3.1",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
@@ -9946,6 +10286,13 @@
                 "punycode": "^2.1.0"
             }
         },
+        "node_modules/urix": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+            "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
+            "deprecated": "Please see https://github.com/lydell/urix#deprecated",
+            "dev": true
+        },
         "node_modules/util-deprecate": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11167,6 +11514,18 @@
                 "@babel/helper-plugin-utils": "^7.14.5"
             }
         },
+        "@babel/plugin-transform-modules-commonjs": {
+            "version": "7.15.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.15.4.tgz",
+            "integrity": "sha512-qg4DPhwG8hKp4BbVDvX1s8cohM8a6Bvptu4l6Iingq5rW+yRUAhe/YRup/YcW2zCOlrysEWVhftIcKzrEZv3sA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-module-transforms": "^7.15.4",
+                "@babel/helper-plugin-utils": "^7.14.5",
+                "@babel/helper-simple-access": "^7.15.4",
+                "babel-plugin-dynamic-import-node": "^2.3.3"
+            }
+        },
         "@babel/standalone": {
             "version": "7.15.7",
             "resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.15.7.tgz",
@@ -12020,6 +12379,18 @@
             "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
             "dev": true
         },
+        "@types/strip-bom": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
+            "integrity": "sha1-FKjsOVbC6B7bdSB5CuzyHCkK69I=",
+            "dev": true
+        },
+        "@types/strip-json-comments": {
+            "version": "0.0.30",
+            "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz",
+            "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
+            "dev": true
+        },
         "@types/unist": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -12175,6 +12546,91 @@
             "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.19.tgz",
             "integrity": "sha512-Knqhx7WieLdVgwCAZgTVrDCXZ50uItuecLh9JdLC8O+a5ayaSyIQYveUK3hCRNC7ws5zalHmZwfdLMGaS8r4Ew=="
         },
+        "@vue/test-utils": {
+            "version": "2.0.0-rc.15",
+            "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.0.0-rc.15.tgz",
+            "integrity": "sha512-cb+Ri4PDRhtGCJuaLyl1HO9jXcwEj6AFwcNXace8FhhwelDzOdjyIgOb25xtDiUojzWjPuzGLKZQr/5WB7MLew==",
+            "dev": true,
+            "requires": {}
+        },
+        "@vue/vue3-jest": {
+            "version": "27.0.0-alpha.1",
+            "resolved": "https://registry.npmjs.org/@vue/vue3-jest/-/vue3-jest-27.0.0-alpha.1.tgz",
+            "integrity": "sha512-V4erTP0LvI0B4kM/cgSiusF0yahByrqJCAUQKDvpW3104J4njoNUY1HwSMqvv5SASOrzxer4ui3EDWyl8Pw2Lg==",
+            "dev": true,
+            "requires": {
+                "@babel/plugin-transform-modules-commonjs": "^7.2.0",
+                "chalk": "^2.1.0",
+                "convert-source-map": "^1.6.0",
+                "extract-from-css": "^0.4.4",
+                "source-map": "0.5.6",
+                "tsconfig": "^7.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "3.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+                    "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+                    "dev": true,
+                    "requires": {
+                        "color-convert": "^1.9.0"
+                    }
+                },
+                "chalk": {
+                    "version": "2.4.2",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+                    "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^3.2.1",
+                        "escape-string-regexp": "^1.0.5",
+                        "supports-color": "^5.3.0"
+                    }
+                },
+                "color-convert": {
+                    "version": "1.9.3",
+                    "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+                    "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+                    "dev": true,
+                    "requires": {
+                        "color-name": "1.1.3"
+                    }
+                },
+                "color-name": {
+                    "version": "1.1.3",
+                    "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+                    "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+                    "dev": true
+                },
+                "escape-string-regexp": {
+                    "version": "1.0.5",
+                    "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+                    "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+                    "dev": true
+                },
+                "has-flag": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+                    "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+                    "dev": true
+                },
+                "source-map": {
+                    "version": "0.5.6",
+                    "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
+                    "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=",
+                    "dev": true
+                },
+                "supports-color": {
+                    "version": "5.5.0",
+                    "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+                    "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+                    "dev": true,
+                    "requires": {
+                        "has-flag": "^3.0.0"
+                    }
+                }
+            }
+        },
         "abab": {
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
@@ -12368,6 +12824,12 @@
             "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
             "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
         },
+        "atob": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+            "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+            "dev": true
+        },
         "autoprefixer": {
             "version": "9.8.7",
             "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.7.tgz",
@@ -12441,6 +12903,15 @@
             "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz",
             "integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU="
         },
+        "babel-plugin-dynamic-import-node": {
+            "version": "2.3.3",
+            "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz",
+            "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==",
+            "dev": true,
+            "requires": {
+                "object.assign": "^4.1.0"
+            }
+        },
         "babel-plugin-istanbul": {
             "version": "6.0.0",
             "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz",
@@ -12752,6 +13223,16 @@
             "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
             "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
         },
+        "call-bind": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.0.2"
+            }
+        },
         "callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -13100,6 +13581,18 @@
                 "which": "^2.0.1"
             }
         },
+        "css": {
+            "version": "2.2.4",
+            "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
+            "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==",
+            "dev": true,
+            "requires": {
+                "inherits": "^2.0.3",
+                "source-map": "^0.6.1",
+                "source-map-resolve": "^0.5.2",
+                "urix": "^0.1.0"
+            }
+        },
         "cssesc": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -13234,6 +13727,12 @@
             "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==",
             "dev": true
         },
+        "decode-uri-component": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+            "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+            "dev": true
+        },
         "dedent": {
             "version": "0.7.0",
             "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -13252,6 +13751,15 @@
             "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
             "dev": true
         },
+        "define-properties": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+            "dev": true,
+            "requires": {
+                "object-keys": "^1.0.12"
+            }
+        },
         "delayed-stream": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -13927,6 +14435,15 @@
             "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
             "devOptional": true
         },
+        "extract-from-css": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/extract-from-css/-/extract-from-css-0.4.4.tgz",
+            "integrity": "sha1-HqffLnx8brmSL6COitrqSG9vj5I=",
+            "dev": true,
+            "requires": {
+                "css": "^2.1.0"
+            }
+        },
         "extract-zip": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -14261,6 +14778,17 @@
             "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
             "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
         },
+        "get-intrinsic": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.1"
+            }
+        },
         "get-package-type": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -14438,6 +14966,12 @@
             "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
             "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
         },
+        "has-symbols": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+            "dev": true
+        },
         "has-unicode": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -16426,6 +16960,24 @@
             "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
             "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
         },
+        "object-keys": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+            "dev": true
+        },
+        "object.assign": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.0",
+                "define-properties": "^1.1.3",
+                "has-symbols": "^1.0.1",
+                "object-keys": "^1.1.1"
+            }
+        },
         "on-finished": {
             "version": "2.3.0",
             "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -17302,6 +17854,12 @@
             "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
             "dev": true
         },
+        "resolve-url": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+            "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=",
+            "dev": true
+        },
         "reusify": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -17640,6 +18198,19 @@
             "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz",
             "integrity": "sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug=="
         },
+        "source-map-resolve": {
+            "version": "0.5.3",
+            "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
+            "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+            "dev": true,
+            "requires": {
+                "atob": "^2.1.2",
+                "decode-uri-component": "^0.2.0",
+                "resolve-url": "^0.2.1",
+                "source-map-url": "^0.4.0",
+                "urix": "^0.1.0"
+            }
+        },
         "source-map-support": {
             "version": "0.5.20",
             "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz",
@@ -17650,6 +18221,12 @@
                 "source-map": "^0.6.0"
             }
         },
+        "source-map-url": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
+            "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+            "dev": true
+        },
         "sourcemap-codec": {
             "version": "1.4.8",
             "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
@@ -18280,6 +18857,32 @@
             "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
             "dev": true
         },
+        "tsconfig": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
+            "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==",
+            "dev": true,
+            "requires": {
+                "@types/strip-bom": "^3.0.0",
+                "@types/strip-json-comments": "0.0.30",
+                "strip-bom": "^3.0.0",
+                "strip-json-comments": "^2.0.0"
+            },
+            "dependencies": {
+                "strip-bom": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+                    "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+                    "dev": true
+                },
+                "strip-json-comments": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+                    "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
+                    "dev": true
+                }
+            }
+        },
         "tslib": {
             "version": "2.3.1",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
@@ -18421,6 +19024,12 @@
                 "punycode": "^2.1.0"
             }
         },
+        "urix": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+            "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
+            "dev": true
+        },
         "util-deprecate": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/src/components/notifications/SMTP.vue b/src/components/notifications/SMTP.vue
index b86a626..0656b13 100644
--- a/src/components/notifications/SMTP.vue
+++ b/src/components/notifications/SMTP.vue
@@ -45,17 +45,17 @@
 
     <div class="mb-3">
         <label for="to-email" class="form-label">To Email</label>
-        <input id="to-email" v-model="$parent.notification.smtpTo" type="text" class="form-control" required autocomplete="false" placeholder="example2@kuma.pet, example3@kuma.pet">
+        <input id="to-email" v-model="$parent.notification.smtpTo" type="text" class="form-control" autocomplete="false" placeholder="example2@kuma.pet, example3@kuma.pet" :required="!hasRecipient">
     </div>
 
     <div class="mb-3">
         <label for="to-cc" class="form-label">CC</label>
-        <input id="to-cc" v-model="$parent.notification.smtpCC" type="text" class="form-control" autocomplete="false">
+        <input id="to-cc" v-model="$parent.notification.smtpCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
     </div>
 
     <div class="mb-3">
         <label for="to-bcc" class="form-label">BCC</label>
-        <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false">
+        <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
     </div>
 </template>
 
@@ -66,10 +66,19 @@ export default {
     components: {
         HiddenInput,
     },
+    computed: {
+        hasRecipient() {
+            if (this.$parent.notification.smtpTo || this.$parent.notification.smtpCC || this.$parent.notification.smtpBCC) {
+                return true;
+            } else {
+                return false;
+            }
+        }
+    },
     mounted() {
         if (typeof this.$parent.notification.smtpSecure === "undefined") {
             this.$parent.notification.smtpSecure = false;
         }
-    },
-}
+    }
+};
 </script>