chore: add in the WGDashboard assets
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
proxy.js
|
||||
@@ -0,0 +1,29 @@
|
||||
# client
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="./img/Logo-2-128x128.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WGDashboard Client</title>
|
||||
<base href="./client/">
|
||||
<style>
|
||||
*{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
#preloader{
|
||||
width: 100vw; height: 100vh; display: flex
|
||||
}
|
||||
#preloader_placeholder{
|
||||
width: 50px; height: 50px; background-color: #000; margin: auto;
|
||||
animation: 3s ease-in-out fadeInPreloader infinite;
|
||||
}
|
||||
@keyframes fadeInPreloader {
|
||||
0%{
|
||||
opacity: 0;
|
||||
}
|
||||
50%{
|
||||
opacity: 1;
|
||||
}
|
||||
100%{
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const isViteDevMode = document.querySelector('script[src*="@vite/client"]') !== null;
|
||||
const base = document.querySelector('base');
|
||||
if (base && isViteDevMode) {
|
||||
base.href = '/';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="preloader">
|
||||
<div id="preloader_placeholder">
|
||||
<img style="width: 100%" src="./img/Logo-2-128x128.png" alt="WGDashboard Client" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "v1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"bootstrap": "^5.3.6",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import './assets/main.css'
|
||||
import NotificationList from "@/components/Notification/notificationList.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-bs-theme="dark" class="text-body bg-body vw-100 vh-100 bg-body">
|
||||
<div class="d-flex vw-100 p-sm-4 overflow-y-scroll innerContainer d-flex flex-column">
|
||||
<div class="mx-auto my-sm-auto position-relative" id="listContainer">
|
||||
<Suspense>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="app" type="transition" mode="out-in">
|
||||
<Component :is="Component"></Component>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem" class="text-center text-muted">
|
||||
<small>
|
||||
Background image by <a href="https://unsplash.com/photos/body-of-water-aExT3y92x5o">Fabrizio Conti</a>
|
||||
</small><br>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationList></NotificationList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
#listContainer{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 992px) {
|
||||
#listContainer{
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
.innerContainer{
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@supports(height: 100dvh) {
|
||||
.innerContainer { height: 100dvh; }
|
||||
}
|
||||
|
||||
.bg-body[data-bs-theme="dark"]{
|
||||
background: linear-gradient(rgba(48, 48, 48, 0.5), rgba(0, 0, 0, 0.5)), url("/img/fabrizio-conti-aExT3y92x5o-unsplash.jpg") fixed;
|
||||
background-size: cover;
|
||||
background-position: top;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,77 @@
|
||||
Plus Jakarta Sans Variable Font
|
||||
===============================
|
||||
|
||||
This download contains Plus Jakarta Sans as both variable fonts and static fonts.
|
||||
|
||||
Plus Jakarta Sans is a variable font with this axis:
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
PlusJakartaSans-VariableFont_wght.ttf
|
||||
PlusJakartaSans-Italic-VariableFont_wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Plus Jakarta Sans:
|
||||
static/PlusJakartaSans-ExtraLight.ttf
|
||||
static/PlusJakartaSans-Light.ttf
|
||||
static/PlusJakartaSans-Regular.ttf
|
||||
static/PlusJakartaSans-Medium.ttf
|
||||
static/PlusJakartaSans-SemiBold.ttf
|
||||
static/PlusJakartaSans-Bold.ttf
|
||||
static/PlusJakartaSans-ExtraBold.ttf
|
||||
static/PlusJakartaSans-ExtraLightItalic.ttf
|
||||
static/PlusJakartaSans-LightItalic.ttf
|
||||
static/PlusJakartaSans-Italic.ttf
|
||||
static/PlusJakartaSans-MediumItalic.ttf
|
||||
static/PlusJakartaSans-SemiBoldItalic.ttf
|
||||
static/PlusJakartaSans-BoldItalic.ttf
|
||||
static/PlusJakartaSans-ExtraBoldItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,148 @@
|
||||
@import "bootstrap/dist/css/bootstrap.css";
|
||||
@import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
src: url(./fonts/Plus_Jakarta_Sans/PlusJakartaSans-VariableFont_wght.ttf) format('ttf');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: italic;
|
||||
font-weight: 200 800;
|
||||
src: url(./fonts/Plus_Jakarta_Sans/PlusJakartaSans-Italic-VariableFont_wght.ttf) format('ttf');
|
||||
}
|
||||
|
||||
*{
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important;
|
||||
}
|
||||
|
||||
@property --brandColor1 {
|
||||
syntax: '<color>';
|
||||
initial-value: #009dff;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --brandColor2 {
|
||||
syntax: '<color>';
|
||||
initial-value: #F94647;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --distance1{
|
||||
syntax: '<percentage>';
|
||||
initial-value: 0%;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --degree{
|
||||
syntax: '<angle>';
|
||||
initial-value: 234deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
.btn-brand{
|
||||
/*background: rgb(23,139,255);*/
|
||||
background: linear-gradient(var(--degree), var(--brandColor1) var(--distance1), var(--brandColor2) 100%);
|
||||
border: 0 !important;
|
||||
transition: --brandColor1 0.3s, --brandColor2 0.3s !important;
|
||||
}
|
||||
|
||||
.btn-brand:hover{
|
||||
--brandColor1: rgb(0, 142, 216);
|
||||
--brandColor2: rgba(249, 70, 71)
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
.slide-right-enter-from,
|
||||
.slide-right-leave-to{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-enter-from{
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
.slide-right-leave-to{
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.app-enter-active,
|
||||
.app-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
.app-enter-from,
|
||||
.app-leave-to{
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.app-enter-from{
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.app-leave-to{
|
||||
transform: translateY(-20px)
|
||||
}
|
||||
|
||||
.btn-outline-body{
|
||||
color: #000000;
|
||||
border-color: #000000 !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .btn-outline-body{
|
||||
color: #ffffff;
|
||||
border-color: #ffffff !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.btn-body{
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.btn-body:hover{
|
||||
border-color: #373737 !important;
|
||||
color: #ffffff !important;
|
||||
background-color: #373737 !important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .btn-body{
|
||||
border-color: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .btn-body:hover{
|
||||
border-color: #e8e8e8 !important;
|
||||
color: #000000 !important;
|
||||
background-color: #e8e8e8 !important;
|
||||
}
|
||||
|
||||
.form-control{
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.amneziawgBg{
|
||||
background: rgb(145,199,193);
|
||||
background: linear-gradient(90deg, rgba(145,199,193,1) 0%, rgba(107,95,161,1) 50%, rgba(227,142,65,1) 100%);
|
||||
}
|
||||
|
||||
.wireguardBg{
|
||||
background: rgb(125,32,32);
|
||||
background: linear-gradient(90deg, rgba(125,32,32,1) 0%, rgba(255,56,56,1) 100%);
|
||||
}
|
||||
|
||||
.form-control{
|
||||
background-color: rgba(0, 0, 0, 0.25) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import {computed, ref} from "vue";
|
||||
import ConfigurationQRCode from "@/components/Configuration/configurationQRCode.vue";
|
||||
import dayjs from "dayjs";
|
||||
import Duration from 'dayjs/plugin/Duration'
|
||||
dayjs.extend(Duration);
|
||||
const props = defineProps([
|
||||
'config'
|
||||
])
|
||||
const showQRCode = ref(false)
|
||||
|
||||
const dateJobs = computed(() => {
|
||||
return props.config.jobs.filter(x => x.Field === 'date').sort((x, y) => {
|
||||
if (dayjs(x.Value).isBefore(y.Value)){
|
||||
return -1
|
||||
}else if (dayjs(x.Value).isAfter(y.Value)){
|
||||
return 1
|
||||
}else{
|
||||
return 0
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const totalDataJobs = computed(() => {
|
||||
return props.config.jobs.filter(x => x.Field === "total_data").sort((x, y) => {
|
||||
return parseFloat(y.Value) - parseFloat(x.Value)
|
||||
})
|
||||
});
|
||||
|
||||
const dateLimit = computed(() => {
|
||||
if (dateJobs.value.length > 0){
|
||||
return dateJobs.value[0].Value
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const totalDataLimit = computed(() => {
|
||||
if (totalDataJobs.value.length > 0){
|
||||
return totalDataJobs.value[0].Value
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const totalDataPercentage = computed(() => {
|
||||
if (!totalDataLimit.value) return 100
|
||||
return ( props.config.data / totalDataLimit.value ) * 100
|
||||
})
|
||||
window.dayjs = dayjs
|
||||
|
||||
const emits = defineEmits(['select'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card rounded-3 border-0 shadow">
|
||||
<div class="card-header rounded-top-3 border-0 align-items-center d-flex p-3 flex-column flex-sm-row gap-2">
|
||||
<small class="fw-bold">
|
||||
{{ props.config.name }}
|
||||
</small>
|
||||
<span class="badge rounded-3 ms-sm-auto"
|
||||
:class="[props.config.protocol === 'wg' ? 'wireguardBg' : 'amneziawgBg' ]"
|
||||
>
|
||||
{{ props.config.protocol === 'wg' ? 'WireGuard': 'AmneziaWG' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-3 d-flex gap-3 flex-column">
|
||||
<div>
|
||||
<div class="mb-1 d-flex align-items-center">
|
||||
<small class="text-muted ">
|
||||
<i class="bi bi-bar-chart-fill me-1"></i> Data Usage
|
||||
</small>
|
||||
<small class="fw-bold ms-auto">
|
||||
{{ props.config.data.toFixed(4) }} / {{ totalDataLimit ? parseFloat(totalDataLimit).toFixed(4) : 'Unlimited'}} GB
|
||||
</small>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100" style="height: 6px">
|
||||
<div class="progress-bar bg-primary"
|
||||
:style="{'width': '' + totalDataPercentage + '%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 d-flex align-items-center">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-calendar me-1"></i> Valid Until
|
||||
</small>
|
||||
<small class="fw-bold ms-auto">
|
||||
{{ dateLimit ? dateLimit : 'Unlimited Time' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-body rounded-3 flex-grow-1 fw-bold w-100" @click="emits('select')">
|
||||
<i class="bi bi-link-45deg me-2"></i><small>Connect</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.button-group a:hover{
|
||||
background-color: #ffffff20;
|
||||
}
|
||||
|
||||
.dot{
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50px;
|
||||
display: inline-block;
|
||||
margin-left: auto !important;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: #28a745 !important;
|
||||
box-shadow: 0 0 0 .2rem #28a74545;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import Qrcode from "@/components/SignIn/qrcode.vue";
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps([
|
||||
'qrcodeData', 'protocol'
|
||||
])
|
||||
|
||||
const emits = defineEmits([
|
||||
'back'
|
||||
])
|
||||
|
||||
const amneziaVPN = computed(() => {
|
||||
if (props.qrcodeData.amneziaVPN){
|
||||
return btoa(props.qrcodeData.amneziaVPN)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const download = computed(() => {
|
||||
return URL.createObjectURL(new Blob([props.qrcodeData.file], {
|
||||
type: 'text/conf'
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 position-fixed top-0 start-0 vw-100 vh-100 d-flex qrcodeContainer p-3 overflow-scroll flex-column">
|
||||
<div>
|
||||
<a role="button" @click="emits('back')" class="btn btn-outline-body rounded-3 btn-sm">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
<div class="m-auto d-flex gap-3 flex-column p-3" style="max-width: 400px">
|
||||
|
||||
<div class="d-flex flex-column gap-2 align-items-center">
|
||||
<Qrcode :content="props.qrcodeData.file"></Qrcode>
|
||||
<small>
|
||||
Scan with {{ protocol === "wg" ? 'WireGuard':'AmneziaWG'}} App
|
||||
</small>
|
||||
|
||||
<div v-if="amneziaVPN" class="d-flex flex-column gap-2 align-items-center">
|
||||
<Qrcode :content="amneziaVPN"></Qrcode>
|
||||
<small>
|
||||
Scan with AmneziaVPN App
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr class="border-white w-100 my-2">
|
||||
<a
|
||||
:download="props.qrcodeData.fileName + '.conf'"
|
||||
:href="download"
|
||||
class="btn bg-primary-subtle border-primary-subtle rounded-3">
|
||||
<i class="bi bi-download me-2"></i>Download
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.qrcodeContainer{
|
||||
background-color: #00000050;
|
||||
backdrop-filter: blur(8px) brightness(0.7);
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
notificationData: {
|
||||
id: "",
|
||||
show: true,
|
||||
content: "",
|
||||
time: "",
|
||||
status: ""
|
||||
}
|
||||
})
|
||||
|
||||
let timeout = undefined;
|
||||
const show = () => {
|
||||
props.notificationData.show = true;
|
||||
timeout = setTimeout(() => {
|
||||
dismiss()
|
||||
}, 5000)
|
||||
}
|
||||
const clearTime = () => clearTimeout(timeout)
|
||||
const dismiss = () => props.notificationData.show = false;
|
||||
|
||||
onMounted(() => {
|
||||
show()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
@mouseenter="clearTime()"
|
||||
@mouseleave="notificationData.show ? show():undefined"
|
||||
:class="{
|
||||
'text-bg-success': notificationData.status === 'success',
|
||||
'text-bg-warning': notificationData.status === 'warning',
|
||||
'text-bg-danger': notificationData.status === 'danger'
|
||||
}"
|
||||
class="card shadow rounded-3 position-relative message ms-auto notification">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<small>
|
||||
{{ notificationData.time.format("hh:mm A") }}
|
||||
</small>
|
||||
<small class="ms-auto">
|
||||
<a role="button" @click="dismiss()">
|
||||
Dismiss<i class="bi bi-x-lg ms-2"></i>
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<span class="fw-medium">{{ notificationData.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification{
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 576px) {
|
||||
.notification{
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import Notification from "@/components/Notification/notification.vue";
|
||||
import {computed, onMounted} from "vue";
|
||||
const store = clientStore()
|
||||
const notifications = computed(() => {
|
||||
return store.notifications.filter(x => x.show).slice().reverse()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="messageCentre text-body position-absolute d-flex">
|
||||
<TransitionGroup name="message" tag="div"
|
||||
class="position-relative flex-sm-grow-0 flex-grow-1 d-flex align-items-end ms-sm-auto flex-column gap-2">
|
||||
<Notification v-for="n in notifications"
|
||||
:notificationData="n" :key="n.id"></Notification>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-move, /* apply transition to moving elements */
|
||||
.message-enter-active,
|
||||
.message-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
|
||||
.message-enter-from,
|
||||
.message-leave-to {
|
||||
filter: blur(2px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-enter-from{
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
.message-leave-to{
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.messageCentre{
|
||||
z-index: 9999;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.messageCentre{
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
const store = clientStore()
|
||||
const ProfileLabels = {
|
||||
Name: "Name",
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3">
|
||||
<h5>
|
||||
Profile
|
||||
</h5>
|
||||
<div v-for="(val, key) in store.clientProfile.Profile">
|
||||
<label :for="key" class="text-muted form-label">
|
||||
<small>{{ ProfileLabels[key] }}</small>
|
||||
</label>
|
||||
<input :id="key" class="form-control rounded-3" v-model="store.clientProfile.Profile[key]">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import {reactive, ref} from "vue";
|
||||
import {axiosPost} from "@/utilities/request.js";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
|
||||
const Password = reactive({
|
||||
CurrentPassword: "",
|
||||
NewPassword: "",
|
||||
ConfirmNewPassword: ""
|
||||
})
|
||||
|
||||
const ResetPasswordFields = () => {
|
||||
Password.CurrentPassword = ""
|
||||
Password.NewPassword = ""
|
||||
Password.ConfirmNewPassword = ""
|
||||
}
|
||||
const store = clientStore()
|
||||
const UpdatePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll("#updatePasswordForm input").forEach(x => x.blur())
|
||||
const data = await axiosPost('/api/settings/updatePassword', Password)
|
||||
if(data){
|
||||
if (!data.status){
|
||||
formInvalid.value = true;
|
||||
formInvalidMessage.value = data.message;
|
||||
}else{
|
||||
formInvalid.value = false
|
||||
store.newNotification("Password updated!", "success")
|
||||
ResetPasswordFields()
|
||||
}
|
||||
}else{
|
||||
formInvalid.value = true;
|
||||
formInvalidMessage.value = "Error occurred"
|
||||
}
|
||||
}
|
||||
|
||||
const showPassword = ref(false)
|
||||
const formInvalid = ref(false)
|
||||
const formInvalidMessage = ref("")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit="(e) => UpdatePassword(e)"
|
||||
id="updatePasswordForm"
|
||||
@reset="ResetPasswordFields()" class="p-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<h5>
|
||||
Update Password
|
||||
</h5>
|
||||
<a role="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="text-muted ms-auto text-decoration-none">
|
||||
<small>
|
||||
<i
|
||||
:class="[showPassword ? 'bi-eye-slash-fill':'bi-eye-fill']"
|
||||
class="bi me-2"></i>{{ showPassword ? 'Hide':'Show'}} Password
|
||||
</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="alert alert-danger rounded-3 mt-3" v-if="formInvalid">
|
||||
{{ formInvalidMessage }}
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-12">
|
||||
<label
|
||||
class="text-muted form-label" for="CurrentPassword">
|
||||
<small>Current Password</small>
|
||||
</label>
|
||||
<input class="form-control rounded-3" :class="{'is-invalid': formInvalid}" required
|
||||
:type="showPassword ? 'text':'password'" autocomplete="current-password" id="CurrentPassword" v-model="Password.CurrentPassword">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="text-muted form-label" for="NewPassword">
|
||||
<small>New Password</small>
|
||||
</label>
|
||||
<input class="form-control rounded-3"
|
||||
required
|
||||
:class="{'is-invalid': formInvalid}"
|
||||
:type="showPassword ? 'text':'password'"
|
||||
id="NewPassword" autocomplete="new-password" v-model="Password.NewPassword">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="text-muted form-label" for="ConfirmNewPassword">
|
||||
<small>Confirm New Password</small>
|
||||
</label>
|
||||
<input class="form-control rounded-3"
|
||||
required
|
||||
:class="{'is-invalid': formInvalid}"
|
||||
:type="showPassword ? 'text':'password'"
|
||||
id="ConfirmNewPassword" autocomplete="new-password" v-model="Password.ConfirmNewPassword">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-secondary rounded-3 ms-auto" type="reset">Clear</button>
|
||||
<button class="btn btn-sm btn-danger rounded-3" type="submit">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup async>
|
||||
|
||||
import {axiosGet} from "@/utilities/request.js";
|
||||
import {ref} from "vue";
|
||||
import OidcBtn from "@/components/SignIn/oidc/oidcBtn.vue";
|
||||
|
||||
const providerExist = ref(false)
|
||||
const providers = ref(undefined)
|
||||
const getProviders = await axiosGet("/api/signin/oidc/providers")
|
||||
if (getProviders && Object.keys(getProviders.data).length > 0){
|
||||
providerExist.value = true;
|
||||
providers.value = getProviders.data
|
||||
console.log(providers.value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="providers">
|
||||
<hr>
|
||||
<h6 class="text-center text-muted mb-3">
|
||||
<small>Sign in with</small>
|
||||
</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<suspense>
|
||||
<OidcBtn :provider="provider" :name="name" v-for="(provider, name) in providers"></OidcBtn>
|
||||
<template #fallback>
|
||||
<a class="btn btn-sm btn-outline-body rounded-3 w-100 disabled">
|
||||
Loading...
|
||||
</a>
|
||||
</template>
|
||||
</suspense>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup async>
|
||||
import axios from "axios";
|
||||
import {ref} from "vue";
|
||||
|
||||
const props = defineProps(['provider', 'name'])
|
||||
const providerAvailable = ref(false)
|
||||
const providerConfiguration = ref({})
|
||||
const params = new URLSearchParams({
|
||||
client_id: props.provider.client_id,
|
||||
redirect_uri: window.location.protocol + '//' + window.location.host + window.location.pathname,
|
||||
response_type: 'code',
|
||||
state: props.name,
|
||||
scope: 'openid email profile'
|
||||
}).toString()
|
||||
const authUrl = ref(undefined)
|
||||
|
||||
try{
|
||||
const getProviderConfiguration = await axios(`${props.provider.issuer}/.well-known/openid-configuration`)
|
||||
console.log(getProviderConfiguration)
|
||||
providerAvailable.value = true
|
||||
providerConfiguration.value = getProviderConfiguration.data
|
||||
console.log(providerConfiguration.value)
|
||||
authUrl.value = new URL(providerConfiguration.value.authorization_endpoint)
|
||||
authUrl.value.search = params
|
||||
} catch (error){
|
||||
console.log("Provider not available", props.provider)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="btn btn-sm btn-outline-body rounded-3"
|
||||
v-if="providerAvailable"
|
||||
:href="authUrl"
|
||||
style="flex: 1 1 0px;" >
|
||||
{{ name }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import * as uuid from "uuid";
|
||||
|
||||
const props = defineProps([
|
||||
"content"
|
||||
])
|
||||
const id = uuid.v4().toString()
|
||||
|
||||
onMounted(() => {
|
||||
QRCode.toCanvas(document.getElementById(`qrcode_${id}`), props.content, function (error) {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex gap-2 flex-column">
|
||||
<canvas :id="'qrcode_' + id" class="rounded-3"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
canvas{
|
||||
width: 250px !important;
|
||||
height: 250px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import {computed, reactive, ref} from "vue";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import axios from "axios";
|
||||
import {axiosPost, requestURl} from "@/utilities/request.js";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import Oidc from "@/components/SignIn/oidc/oidc.vue";
|
||||
const loading = ref(false)
|
||||
const formData = reactive({
|
||||
Email: "",
|
||||
Password: ""
|
||||
});
|
||||
const emits = defineEmits(['totpToken'])
|
||||
|
||||
const totpToken = ref("")
|
||||
const store = clientStore()
|
||||
const signIn = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formFilled){
|
||||
store.newNotification("Please fill in all fields", "warning")
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
const data = await axiosPost("/api/signin", formData)
|
||||
if (!data.status){
|
||||
store.newNotification(data.message, "danger")
|
||||
loading.value = false;
|
||||
}else{
|
||||
emits("totpToken", data.message)
|
||||
}
|
||||
}
|
||||
|
||||
const formFilled = computed(() => {
|
||||
return Object.values(formData).find(x => !x) === undefined
|
||||
})
|
||||
|
||||
// const router = useRouter()
|
||||
const route = useRoute()
|
||||
if (route.query.Email){
|
||||
formData.Email = route.query.Email
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Welcome</h1>
|
||||
<p class="text-muted">Sign in to access your <strong>WGDashboard Client</strong> account</p>
|
||||
</div>
|
||||
<Oidc></Oidc>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => signIn(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
class="form-control rounded-3 border-0" id="email" placeholder="email">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
class="form-control rounded-3 border-0" id="password" placeholder="Password">
|
||||
<label for="password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<RouterLink to="forgotPassword"
|
||||
|
||||
class="text-body text-decoration-none ms-auto btn btn-sm rounded-3">
|
||||
Forgot Password?
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!formFilled || loading"
|
||||
class="btn btn-primary rounded-3 btn-body px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Sign In
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...<i class="ms-2 spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="store.serverInformation.SignUp.enable">
|
||||
<hr class="my-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted">
|
||||
Don't have an account yet?
|
||||
</span>
|
||||
<RouterLink to="/signup"
|
||||
class="text-body text-decoration-none ms-auto fw-bold btn btn-sm btn-outline-body rounded-3">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup async>
|
||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
||||
import axios from "axios";
|
||||
import {axiosPost, requestURl} from "@/utilities/request.js";
|
||||
import {useRouter} from "vue-router";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import Qrcode from "@/components/SignIn/qrcode.vue";
|
||||
|
||||
const props = defineProps([
|
||||
'totpToken'
|
||||
])
|
||||
const totpKey = ref("")
|
||||
const formData = reactive({
|
||||
TOTP: ""
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const replace = () => {
|
||||
formData.TOTP = formData.TOTP.replace(/\D/i, "")
|
||||
}
|
||||
const formFilled = computed(() => {
|
||||
return /^[0-9]{6}$/.test(formData.TOTP)
|
||||
})
|
||||
|
||||
const store = clientStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
axios.get(requestURl('/api/signin/totp'), {
|
||||
params: {
|
||||
Token: props.totpToken
|
||||
}
|
||||
}).then(res => {
|
||||
let data = res.data
|
||||
loading.value = false
|
||||
if (data.status){
|
||||
if (data.message){
|
||||
totpKey.value = data.message
|
||||
}
|
||||
}else{
|
||||
store.newNotification(data.message, "danger")
|
||||
router.push('/signin')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emits = defineEmits(['clearToken'])
|
||||
const verify = async (e) => {
|
||||
if (e) e.preventDefault()
|
||||
if (formFilled){
|
||||
loading.value = true
|
||||
const data = await axiosPost('/api/signin/totp', {
|
||||
Token: props.totpToken,
|
||||
UserProvidedTOTP: formData.TOTP
|
||||
})
|
||||
loading.value = false
|
||||
if (data){
|
||||
if (data.status){
|
||||
store.clientProfile = data.data;
|
||||
router.push('/')
|
||||
}else{
|
||||
store.newNotification(data.message, "danger")
|
||||
}
|
||||
}else{
|
||||
store.newNotification("Sign in status is invalid", "danger")
|
||||
emits('clearToken')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(formFilled, () => {
|
||||
verify()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="d-flex flex-column gap-3" @submit="e => verify(e)">
|
||||
<div>
|
||||
<a role="button" @click="emits('clearToken')" class="btn btn-outline-body btn-sm rounded-3">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="mb-3 text-center">Multi-Factor Authentication (MFA)</h1>
|
||||
<div class="card rounded-3 text-center" v-if="totpKey">
|
||||
<div class="card-body d-flex gap-3 flex-column align-items-center">
|
||||
<h2 class="mb-0">Initial Setup</h2>
|
||||
<p class="mb-0">Please scan the following QR Code to generate TOTP with your choice of authenticator</p>
|
||||
<Qrcode :content="totpKey"></Qrcode>
|
||||
<p class="mb-0">Or you can click the link below:</p>
|
||||
<div class="card rounded-3 w-100">
|
||||
<div class="card-body">
|
||||
<a :href="totpKey" style="text-wrap: auto">
|
||||
{{ totpKey }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mb-0 rounded-3">
|
||||
<strong>
|
||||
Please note: You won't be able to see this QR Code again, so please save it somewhere safe in case you need to recover your TOTP key
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="totpKey">
|
||||
<div class="d-flex flex-column gap-3 text-center">
|
||||
<label for="totp">Enter the TOTP generated by your authenticator to verify</label>
|
||||
<input class="form-control form-control-lg rounded-3 text-center"
|
||||
id="totp"
|
||||
:disabled="loading"
|
||||
autofocus
|
||||
@keyup="replace()"
|
||||
maxlength="6" type="text" inputmode="numeric"
|
||||
placeholder="- - - - - -"
|
||||
autocomplete="one-time-code" v-model="formData.TOTP">
|
||||
|
||||
<button
|
||||
:disabled="!formFilled || loading"
|
||||
class="btn btn-body rounded-3 px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...
|
||||
<i class="ms-2 spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card{
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from "@/router/router.js";
|
||||
import {createPinia} from "pinia";
|
||||
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.js'
|
||||
import {axiosGet, axiosPost} from "@/utilities/request.js";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const state = params.get('state')
|
||||
const code = params.get('code')
|
||||
|
||||
const initApp = async () => {
|
||||
const app = createApp(App)
|
||||
const serverInformation = await axiosGet("/api/serverInformation", {})
|
||||
app.use(createPinia())
|
||||
if (serverInformation){
|
||||
const store = clientStore()
|
||||
store.serverInformation = serverInformation.data;
|
||||
}
|
||||
app.use(router)
|
||||
app.mount("#app")
|
||||
}
|
||||
|
||||
if (state && code){
|
||||
await axiosPost("/api/signin/oidc", {
|
||||
provider: state,
|
||||
code: code,
|
||||
redirect_uri: window.location.protocol + '//' + window.location.host + window.location.pathname
|
||||
}).then(async (data) => {
|
||||
let url = new URL(window.location.href);
|
||||
url.search = '';
|
||||
history.replaceState({}, document.title, url.toString());
|
||||
|
||||
await initApp()
|
||||
if (!data.status){
|
||||
const store = clientStore()
|
||||
store.newNotification(data.message, 'danger')
|
||||
}
|
||||
})
|
||||
}else{
|
||||
await initApp()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {createWebHashHistory, createRouter} from "vue-router";
|
||||
import Index from "@/views/index.vue";
|
||||
import SignIn from "@/views/signin.vue";
|
||||
import SignUp from "@/views/signup.vue";
|
||||
import axios from "axios";
|
||||
import {axiosGet, requestURl} from "@/utilities/request.js";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import Settings from "@/views/settings.vue";
|
||||
import ForgotPassword from "@/views/forgotPassword.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Index,
|
||||
meta: {
|
||||
auth: true
|
||||
},
|
||||
name: "Home"
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
meta: {
|
||||
auth: true
|
||||
},
|
||||
name: "Settings"
|
||||
},
|
||||
{
|
||||
path: '/signin',
|
||||
component: SignIn,
|
||||
name: "Sign In"
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
component: SignUp,
|
||||
name: "Sign Up"
|
||||
},
|
||||
{
|
||||
path: '/signout',
|
||||
name: "Sign Out"
|
||||
},
|
||||
{
|
||||
path: '/forgotPassword',
|
||||
name: "Forgot Password",
|
||||
component: ForgotPassword
|
||||
}
|
||||
]
|
||||
})
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const store = clientStore()
|
||||
if (to.path === "/signup" && !store.serverInformation.SignUp.enable){
|
||||
next('/signin')
|
||||
store.newNotification("Sign up is disabled. Please contact administrator for more information", "warning")
|
||||
}
|
||||
|
||||
if (to.path === '/signout'){
|
||||
await axios.get(requestURl('/api/signout')).then(() => {
|
||||
next('/signin')
|
||||
}).catch(() => {
|
||||
next('/signin')
|
||||
});
|
||||
store.newNotification("Sign in session ended, please sign in again", "warning")
|
||||
}else{
|
||||
if (to.meta.auth){
|
||||
const status = await axiosGet('/api/validateAuthentication')
|
||||
if (status){
|
||||
next()
|
||||
}else{
|
||||
store.newNotification("Sign in session ended, please sign in again", "warning")
|
||||
next('/signin')
|
||||
}
|
||||
}else{
|
||||
next()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to, from, next) => {
|
||||
document.title = to.name + ' | WGDashboard Client'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,46 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {onMounted, reactive, ref} from "vue";
|
||||
import {v4} from "uuid"
|
||||
import dayjs from "dayjs";
|
||||
import {axiosGet} from "@/utilities/request.js";
|
||||
|
||||
|
||||
export const clientStore = defineStore('clientStore', {
|
||||
state: () => ({
|
||||
serverInformation: {},
|
||||
notifications: [],
|
||||
configurations: [],
|
||||
clientProfile: {
|
||||
Email: "",
|
||||
SignInMethod: "",
|
||||
Profile: {}
|
||||
}
|
||||
}),
|
||||
actions: {
|
||||
newNotification(content, status){
|
||||
this.notifications.push({
|
||||
id: v4().toString(),
|
||||
status: status,
|
||||
content: content,
|
||||
time: dayjs(),
|
||||
show: true
|
||||
})
|
||||
},
|
||||
async getClientProfile(){
|
||||
const data = await axiosGet('/api/settings/getClientProfile')
|
||||
if (data){
|
||||
this.clientProfile = data.data
|
||||
}else{
|
||||
this.newNotification("Failed to fetch client profile", "danger")
|
||||
}
|
||||
},
|
||||
async getConfigurations(){
|
||||
const data = await axiosGet("/api/configurations")
|
||||
if (data){
|
||||
this.configurations = data.data
|
||||
}else{
|
||||
this.newNotification("Failed to fetch configurations", "danger")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const requestURl = (url) => {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
return '/client' + url;
|
||||
}
|
||||
return `./.${url}`;
|
||||
}
|
||||
|
||||
export const axiosPost = async (URL, body = {}) => {
|
||||
try{
|
||||
const res = await axios.post(requestURl(URL), body)
|
||||
return res.data
|
||||
} catch (error){
|
||||
console.log(error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const axiosGet = async (URL, query = {}) => {
|
||||
try{
|
||||
const res = await axios.get(requestURl(URL), query)
|
||||
return res.data
|
||||
} catch (error){
|
||||
console.log(error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from "vue";
|
||||
import {axiosPost, requestURl} from "@/utilities/request.js";
|
||||
const email = ref("")
|
||||
const loading = ref(false)
|
||||
const verifyCode = ref(false)
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import {useRouter} from "vue-router";
|
||||
const store = clientStore()
|
||||
const resendInterval = ref(undefined)
|
||||
const resendCountdown = ref(120)
|
||||
const requestToken = async (e) => {
|
||||
if (e) e.preventDefault()
|
||||
loading.value = true
|
||||
const result = await axiosPost("/api/resetPassword/generateResetToken", {
|
||||
Email: email.value
|
||||
})
|
||||
loading.value = false
|
||||
if (result.status){
|
||||
verifyCode.value = true
|
||||
resendCountdown.value = 120;
|
||||
resendInterval.value = setInterval(() => {
|
||||
resendCountdown.value --;
|
||||
if (resendCountdown.value === 0) clearInterval(resendInterval.value)
|
||||
}, 1000)
|
||||
}else{
|
||||
store.newNotification(result.message, "danger")
|
||||
}
|
||||
}
|
||||
|
||||
const code = ref("")
|
||||
const parseCode = () => {
|
||||
code.value = code.value.replace(/\D/i, "")
|
||||
code.value = code.value.slice(0, 6)
|
||||
}
|
||||
const codeReady = computed(() => {
|
||||
return /^[0-9]{6}$/.test(code.value)
|
||||
})
|
||||
const codeValidated = ref(false)
|
||||
const validateCode = async (e) => {
|
||||
if (e) e.preventDefault()
|
||||
loading.value = true
|
||||
let codeValidation = await axiosPost("/api/resetPassword/validateResetToken", {
|
||||
Email: email.value,
|
||||
Token: code.value
|
||||
})
|
||||
loading.value = false
|
||||
if (codeValidation.status){
|
||||
codeValidated.value = true
|
||||
}else{
|
||||
store.newNotification("Your verification code is either invalid or expired", "danger")
|
||||
}
|
||||
}
|
||||
|
||||
const password = ref("")
|
||||
const confirmPassword = ref("")
|
||||
const passwordReady = computed(() => {
|
||||
return password.value && confirmPassword.value && password.value === confirmPassword.value
|
||||
})
|
||||
const router = useRouter()
|
||||
const resetPassword = async (e) => {
|
||||
if (e) e.preventDefault()
|
||||
loading.value = true;
|
||||
let reset = await axiosPost("/api/resetPassword", {
|
||||
Email: email.value,
|
||||
Token: code.value,
|
||||
Password: password.value,
|
||||
ConfirmPassword: confirmPassword.value
|
||||
})
|
||||
if (reset.status){
|
||||
store.newNotification("Password reset! Now you can sign in with your new password", "success")
|
||||
|
||||
await router.push('/signin')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 p-sm-5">
|
||||
<Transition name="app" mode="out-in">
|
||||
<div v-if="!verifyCode">
|
||||
<RouterLink to="signin"
|
||||
role="button"
|
||||
class="btn btn-outline-body btn-sm rounded-3">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</RouterLink>
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">No worries</h1>
|
||||
<p class="text-muted">Enter the email address of your <strong>WGDashboard Client</strong> account below to receive a verification code</p>
|
||||
</div>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => requestToken(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
class="form-control rounded-3 border-0" id="email" placeholder="email">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!email || loading"
|
||||
type="submit"
|
||||
class="btn btn-primary rounded-3 btn-body px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="bi bi-arrow-right ms-2"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...<i class="ms-2 spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="verifyCode && !codeValidated">
|
||||
<a role="button" class="text-decoration-none text-body" @click="verifyCode = false; code = ''">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</a>
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Almost there</h1>
|
||||
<p class="text-muted">Enter the code you received below to retrieve a reset your password</p>
|
||||
<p class="text-muted" v-if="resendCountdown > 0">Didn't get the code? Maybe check your Spam/Junk mailbox. You can get another code in {{ resendCountdown }} seconds.</p>
|
||||
<a role="button"
|
||||
:class="{disabled: loading}"
|
||||
@click="requestToken()" v-else-if="resendCountdown === 0 && !loading">Resend</a>
|
||||
</div>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => validateCode(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="code"
|
||||
@keyup="parseCode()"
|
||||
autocomplete="one-time-code"
|
||||
autofocus
|
||||
class="form-control rounded-3 border-0" id="token" placeholder="token">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
6 Digits Verification Code
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!codeReady || loading"
|
||||
type="submit"
|
||||
class="btn btn-primary rounded-3 btn-body px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="bi bi-arrow-right ms-2"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...<i class="ms-2 spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="verifyCode && codeValidated">
|
||||
<a role="button" class="text-decoration-none text-body" @click="verifyCode = false; code = ''; codeValidated = false">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</a>
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Last step</h1>
|
||||
<p class="text-muted">Enter your new password below</p>
|
||||
</div>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="(e) => resetPassword(e)">
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="password" placeholder="password">
|
||||
<label for="password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="confirmPassword"
|
||||
name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="confirm_password" placeholder="confirm_password">
|
||||
<label for="confirm_password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div id="validationServer03Feedback" class="invalid-feedback">
|
||||
Passwords does not match
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!passwordReady || loading"
|
||||
type="submit"
|
||||
class="btn btn-primary rounded-3 btn-body px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="bi bi-arrow-right ms-2"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...<i class="ms-2 spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted">
|
||||
Don't have an account yet?
|
||||
</span>
|
||||
<RouterLink to="/signup"
|
||||
class="text-body text-decoration-none ms-auto fw-bold btn btn-sm btn-outline-body rounded-3">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup async>
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {axiosGet, requestURl} from "@/utilities/request.js";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import Configuration from "@/components/Configuration/configuration.vue";
|
||||
import {onBeforeRouteLeave, useRouter} from "vue-router";
|
||||
import axios from "axios";
|
||||
import ConfigurationQRCode from "@/components/Configuration/configurationQRCode.vue";
|
||||
const store = clientStore()
|
||||
const loading = ref(true)
|
||||
|
||||
const configurations = computed(() => {
|
||||
return store.configurations
|
||||
});
|
||||
const refreshInterval = ref(undefined)
|
||||
await store.getClientProfile();
|
||||
onMounted(async () => {
|
||||
await store.getConfigurations()
|
||||
loading.value = false;
|
||||
|
||||
refreshInterval.value = setInterval(async () => {
|
||||
await store.getConfigurations()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
clearInterval(refreshInterval.value)
|
||||
});
|
||||
const router = useRouter()
|
||||
const signingOut = ref(false)
|
||||
const signOut = async () => {
|
||||
clearInterval(refreshInterval.value)
|
||||
signingOut.value = true;
|
||||
await axios.get(requestURl('/api/signout')).then(() => {
|
||||
router.push('/signin')
|
||||
}).catch(() => {
|
||||
router.push('/signin')
|
||||
});
|
||||
store.newNotification("Sign out successful", "success")
|
||||
}
|
||||
|
||||
const selectedConfig = ref(undefined)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-sm-3">
|
||||
<div class="w-100 d-flex align-items-center">
|
||||
<a class="nav-link text-body border-start-0" aria-current="page" href="#">
|
||||
<strong>
|
||||
Hi, {{ store.clientProfile.Profile.Name ? store.clientProfile.Profile.Name : store.clientProfile.Email}}
|
||||
</strong>
|
||||
</a>
|
||||
<div class="ms-auto px-3 d-flex gap-2 nav-links">
|
||||
<RouterLink to="/settings" class=" text-body btn btn-outline-body rounded-3 ms-auto btn-sm" aria-current="page" href="#">
|
||||
<i class="bi bi-gear-fill me-sm-2"></i>
|
||||
|
||||
|
||||
<span>Settings</span>
|
||||
</RouterLink>
|
||||
<a role="button" @click="signOut()" class="btn btn-outline-danger rounded-3 btn-sm"
|
||||
:class="{disabled: signingOut}"
|
||||
aria-current="page">
|
||||
<i class="bi bi-box-arrow-left me-sm-2"></i>
|
||||
<span>
|
||||
{{ signingOut ? 'Signing out...':'Sign Out'}}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="app" mode="out-in">
|
||||
<div class="d-flex flex-column gap-3" v-if="!loading">
|
||||
<div class="p-3 d-flex flex-column gap-3" v-if="configurations.length > 0">
|
||||
<Configuration
|
||||
@select="selectedConfig = config"
|
||||
v-for="config in configurations" :config="config"></Configuration>
|
||||
</div>
|
||||
<div class="text-center text-muted" v-else>
|
||||
<small>No configuration available</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="d-flex p-3">
|
||||
<div class="bg-body rounded-3 d-flex" style="width: 100%; height: 211px;">
|
||||
<div class="spinner-border m-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="app">
|
||||
<ConfigurationQRCode
|
||||
v-if="selectedConfig !== undefined"
|
||||
:config="selectedConfig.config"
|
||||
:protocol="selectedConfig.protocol"
|
||||
@back="selectedConfig = undefined"
|
||||
:qrcode-data="selectedConfig.peer_configuration_data"
|
||||
>
|
||||
</ConfigurationQRCode>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-link{
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
.nav-links a span{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card{
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup async>
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import Profile from "@/components/Settings/profile.vue";
|
||||
import UpdatePassword from "@/components/Settings/updatePassword.vue";
|
||||
const store = clientStore()
|
||||
await store.getClientProfile();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-sm-3">
|
||||
<div class="w-100 d-flex align-items-center p-3">
|
||||
<RouterLink to="/" class=" text-body btn btn-outline-body rounded-3 btn-sm" aria-current="page" href="#">
|
||||
<i class="bi bi-chevron-left me-sm-2"></i>
|
||||
<span>Back</span>
|
||||
</RouterLink>
|
||||
<strong class="ms-auto">Settings</strong>
|
||||
</div>
|
||||
<UpdatePassword v-if="store.clientProfile.SignInMethod === 'local'"></UpdatePassword>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import SignInForm from "@/components/SignIn/signInForm.vue";
|
||||
import {ref} from "vue";
|
||||
import TotpForm from "@/components/SignIn/totpForm.vue";
|
||||
|
||||
const checkTotp = ref("")
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 p-sm-5">
|
||||
<Transition name="app" mode="out-in">
|
||||
<SignInForm
|
||||
@totpToken="token => { checkTotp = token }"
|
||||
v-if="!checkTotp"></SignInForm>
|
||||
<TotpForm
|
||||
@clearToken="checkTotp = ''"
|
||||
:totp-token="checkTotp"
|
||||
v-else></TotpForm>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, reactive, ref} from "vue";
|
||||
import axios from "axios";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import {requestURl} from "@/utilities/request.js";
|
||||
import {useRouter} from "vue-router";
|
||||
const store = clientStore()
|
||||
|
||||
const formData = reactive({
|
||||
Email: "",
|
||||
Password: "",
|
||||
ConfirmPassword: ""
|
||||
})
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const signUp = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!formFilled){
|
||||
store.newNotification("Please fill in all fields", "warning")
|
||||
return;
|
||||
}
|
||||
if (validatePassword){
|
||||
loading.value = true
|
||||
await axios.post(requestURl('/api/signup'), formData).then((res) => {
|
||||
let data = res.data
|
||||
if (!data.status){
|
||||
store.newNotification(data.message, "danger")
|
||||
loading.value = false
|
||||
}else{
|
||||
store.newNotification("Sign up successfully!", "success")
|
||||
router.push({
|
||||
path: '/signin',
|
||||
query: {
|
||||
Email: formData.Email
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const validatePassword = computed(() => {
|
||||
if (formData.Password && formData.ConfirmPassword){
|
||||
return formData.Password === formData.ConfirmPassword;
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formFilled = computed(() => {
|
||||
return Object.values(formData).find(x => !x) === undefined
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.querySelectorAll("input[type=password]").forEach(e => e.addEventListener('blur', () => {
|
||||
if (formData.Password && formData.ConfirmPassword){
|
||||
document.querySelectorAll("input[type=password]").forEach(x => {
|
||||
if (!validatePassword.value){
|
||||
x.classList.add("is-invalid")
|
||||
}else {
|
||||
x.classList.remove("is-invalid")
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 p-sm-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Hi, nice to meet you</h1>
|
||||
<p class="text-muted">Sign up to use <strong>WGDashboard Client</strong></p>
|
||||
</div>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => signUp(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
:disabled="loading"
|
||||
required
|
||||
v-model="formData.Email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="email" placeholder="email">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<div class="row gx-3">
|
||||
<div class="col-6">
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="password" placeholder="password">
|
||||
<label for="password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.ConfirmPassword"
|
||||
name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="confirm_password" placeholder="confirm_password">
|
||||
<label for="confirm_password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div id="validationServer03Feedback" class="invalid-feedback">
|
||||
Passwords does not match
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
:disabled="!formFilled || !validatePassword || loading"
|
||||
class=" btn btn-primary rounded-3 btn-body px-3 py-2 fw-bold">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...
|
||||
<i class="spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted">
|
||||
Already have an account?
|
||||
</span>
|
||||
<RouterLink to="/signin" class="text-body text-decoration-none ms-auto fw-bold btn btn-sm btn-outline-body rounded-3">
|
||||
Sign In
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import {proxy} from "./proxy.js";
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
{
|
||||
name: 'rename',
|
||||
enforce: 'post',
|
||||
generateBundle(options, bundle) {
|
||||
bundle['index.html'].fileName = "client.html"
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server:{
|
||||
proxy: {
|
||||
'/client': proxy,
|
||||
},
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
outDir: '../dist/WGDashboardClient',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: `assets/[name]-[hash].js`,
|
||||
chunkFileNames: `assets/[name]-[hash].js`,
|
||||
assetFileNames: `assets/[name]-[hash].[ext]`
|
||||
}
|
||||
}
|
||||
},
|
||||
base: './'
|
||||
})
|
||||