V2 alpha - initial version (#172)

Initial alpha codebase for version 2 of WireGuard Portal.
This version is considered unstable and incomplete (for example, no public REST API)! 
Use with care!


Fixes/Implements the following issues:
 - OAuth support #154, #1 
 - New Web UI with internationalisation support #98, #107, #89, #62
 - Postgres Support #49 
 - Improved Email handling #47, #119 
 - DNS Search Domain support #46 
 - Bugfixes #94, #48 

---------

Co-authored-by: Fabian Wechselberger <wechselbergerf@hotmail.com>
This commit is contained in:
h44z
2023-08-04 13:34:18 +02:00
committed by GitHub
parent b3a5f2ac60
commit 8b820a5adf
788 changed files with 46139 additions and 11281 deletions

View File

@@ -0,0 +1 @@
VITE_SOME_EXAMPLE_VAR=http://localhost:5000 (can be used internally like: import.meta.env.VITE_SOME_EXAMPLE_VAR)

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://wgportal.server.com

28
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/extensions.json
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"]
}

29
frontend/README.md Normal file
View File

@@ -0,0 +1,29 @@
# frontend
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=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.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
```

35
frontend/index.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="/favicon.ico" rel="icon" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>WireGuard Portal</title>
<meta content="WireGuard VPN Management Portal" name="description">
<script>
// global config, will be overridden by backend if available
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
let WGPORTAL_VERSION="unknown";
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script>
<script src="/api/v0/config/frontend.js"></script>
</head>
<body class="d-flex flex-column min-vh-100">
<noscript>
<strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<!-- vue teleport will add toasts here -->
<div id="toasts"></div>
<!-- main application -->
<div id="app"></div>
<!-- vue teleport will add modals and dialogs here -->
<div id="modals"></div>
<div id="dialogs"></div>
<script src="/src/main.js" type="module"></script>
</body>
</html>

1684
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build-dev": "vite build --mode development --base=/app/",
"build": "vite build --base=/app/",
"preview": "vite preview --port 5050"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"@kyvg/vue3-notification": "^2.9.1",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.0",
"bootswatch": "^5.3.0",
"flag-icons": "^6.7.0",
"is-cidr": "^5.0.3",
"is-ip": "^5.0.0",
"pinia": "^2.1.4",
"prismjs": "^1.29.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.2.2",
"vue3-tags-input": "^1.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.3.9"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

128
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,128 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import {computed, getCurrentInstance, onMounted, ref} from "vue";
import {authStore} from "./stores/auth";
import {securityStore} from "./stores/security";
import {settingsStore} from "@/stores/settings";
const appGlobal = getCurrentInstance().appContext.config.globalProperties
const auth = authStore()
const sec = securityStore()
const settings = settingsStore()
onMounted(async () => {
console.log("Starting WireGuard Portal frontend...");
await sec.LoadSecurityProperties();
await auth.LoadProviders();
let wasLoggedIn = auth.IsAuthenticated;
try {
await auth.LoadSession();
await settings.LoadSettings(); // only logs errors, does not throw
console.log("WireGuard Portal session is valid");
} catch (e) {
if (wasLoggedIn) {
console.log("WireGuard Portal invalid - logging out");
await auth.Logout();
}
}
console.log("WireGuard Portal ready!");
})
const switchLanguage = function (lang) {
if (appGlobal.$i18n.locale !== lang) {
localStorage.setItem('wgLang', lang);
appGlobal.$i18n.locale = lang;
}
}
const languageFlag = computed(() => {
// `this` points to the component instance
let lang = appGlobal.$i18n.locale.toLowerCase();
if (lang === "en") {
lang = "us";
}
return "fi-" + lang;
})
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear())
</script>
<template>
<notifications :duration="3000" :ignore-duplicates="true" position="top right" />
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img alt="WireGuard Portal" src="/img/header-logo.png" /></a>
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div id="navbarTop" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<RouterLink :to="{ name: 'home' }" class="nav-link">{{ $t('menu.home') }}</RouterLink>
</li>
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
<RouterLink :to="{ name: 'interfaces' }" class="nav-link">{{ $t('menu.interfaces') }}</RouterLink>
</li>
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
</li>
</ul>
<div class="navbar-nav d-flex justify-content-end">
<div v-if="auth.IsAuthenticated" class="nav-item dropdown">
<a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#"
role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
<div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout">
<i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}
</a>
</div>
</div>
<div v-if="!auth.IsAuthenticated" class="nav-item">
<RouterLink :to="{ name: 'login' }" class="nav-link">
<i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}
</RouterLink>
</div>
</div>
</div>
</div>
</nav>
<div class="container mt-5 flex-shrink-0">
<RouterView />
</div>
<footer class="page-footer mt-auto">
<div class="container mt-5">
<div class="row align-items-center">
<div class="col-6">Copyright © {{ companyName }} {{ currentYear }} <span v-if="auth.IsAuthenticated"> - version {{ wgVersion }}</span></div>
<div class="col-6 text-end">
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
<div class="btn-group" role="group">
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0" data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
</template>
<style>
</style>

View File

@@ -0,0 +1,5 @@
a.disabled {
pointer-events: none;
cursor: default;
color: #888888;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,54 @@
<script setup>
import {ref} from "vue";
import {useI18n} from "vue-i18n";
const { t } = useI18n()
const title = ref("Default Title")
const question = ref("Default Question")
const visible = ref(true)
const emit = defineEmits(['no', 'yes'])
function showDialog(titleStr, questionStr) {
visible.value = true
title.value = titleStr
question.value = questionStr
}
function yes() {
visible.value = false
emit('yes')
}
function no() {
visible.value = false
emit('no')
}
</script>
<template>
<Teleport to="#dialogs">
<div v-if="visible" class="modal-backdrop fade show">
<div class="modal fade show" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable" @click.stop="">
<div class="modal-content" ref="body">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
</div>
<div class="modal-body">
{{ question }}
</div>
<div class="modal-footer pt-0 border-top-0">
<button type="button" class="btn btn-primary" @click="no">{{ $t('general.no') }}</button>
<button type="button" class="btn btn-success" @click="yes">{{ $t('general.yes') }}</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style>
</style>

View File

@@ -0,0 +1,513 @@
<script setup>
import Modal from "./Modal.vue";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from 'vue3-tags-input';
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshInterface } from '@/helpers/models';
import {peerStore} from "@/stores/peers";
const { t } = useI18n()
const interfaces = interfaceStore()
const peers = peerStore()
const props = defineProps({
interfaceId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
return interfaces.Find(props.interfaceId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value) {
return t("modals.interface-edit.headline-edit") + " " + selectedInterface.value.Identifier
}
return t("modals.interface-edit.headline-new")
})
const formData = ref(freshInterface())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
if (!selectedInterface.value) {
await interfaces.PrepareInterface()
// fill form data
formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode
formData.value.PublicKey = interfaces.Prepared.PublicKey
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
formData.value.ListenPort = interfaces.Prepared.ListenPort
formData.value.Addresses = interfaces.Prepared.Addresses
formData.value.Dns = interfaces.Prepared.Dns
formData.value.DnsSearch = interfaces.Prepared.DnsSearch
formData.value.Mtu = interfaces.Prepared.Mtu
formData.value.FirewallMark = interfaces.Prepared.FirewallMark
formData.value.RoutingTable = interfaces.Prepared.RoutingTable
formData.value.PreUp = interfaces.Prepared.PreUp
formData.value.PostUp = interfaces.Prepared.PostUp
formData.value.PreDown = interfaces.Prepared.PreDown
formData.value.PostDown = interfaces.Prepared.PostDown
formData.value.SaveConfig = interfaces.Prepared.SaveConfig
formData.value.PeerDefNetwork = interfaces.Prepared.PeerDefNetwork
formData.value.PeerDefDns = interfaces.Prepared.PeerDefDns
formData.value.PeerDefDnsSearch = interfaces.Prepared.PeerDefDnsSearch
formData.value.PeerDefEndpoint = interfaces.Prepared.PeerDefEndpoint
formData.value.PeerDefAllowedIPs = interfaces.Prepared.PeerDefAllowedIPs
formData.value.PeerDefMtu = interfaces.Prepared.PeerDefMtu
formData.value.PeerDefPersistentKeepalive = interfaces.Prepared.PeerDefPersistentKeepalive
formData.value.PeerDefFirewallMark = interfaces.Prepared.PeerDefFirewallMark
formData.value.PeerDefRoutingTable = interfaces.Prepared.PeerDefRoutingTable
formData.value.PeerDefPreUp = interfaces.Prepared.PeerDefPreUp
formData.value.PeerDefPostUp = interfaces.Prepared.PeerDefPostUp
formData.value.PeerDefPreDown = interfaces.Prepared.PeerDefPreDown
formData.value.PeerDefPostDown = interfaces.Prepared.PeerDefPostDown
} else { // fill existing userdata
formData.value.Disabled = selectedInterface.value.Disabled
formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode
formData.value.PublicKey = selectedInterface.value.PublicKey
formData.value.PrivateKey = selectedInterface.value.PrivateKey
formData.value.ListenPort = selectedInterface.value.ListenPort
formData.value.Addresses = selectedInterface.value.Addresses
formData.value.Dns = selectedInterface.value.Dns
formData.value.DnsSearch = selectedInterface.value.DnsSearch
formData.value.Mtu = selectedInterface.value.Mtu
formData.value.FirewallMark = selectedInterface.value.FirewallMark
formData.value.RoutingTable = selectedInterface.value.RoutingTable
formData.value.PreUp = selectedInterface.value.PreUp
formData.value.PostUp = selectedInterface.value.PostUp
formData.value.PreDown = selectedInterface.value.PreDown
formData.value.PostDown = selectedInterface.value.PostDown
formData.value.SaveConfig = selectedInterface.value.SaveConfig
formData.value.PeerDefNetwork = selectedInterface.value.PeerDefNetwork
formData.value.PeerDefDns = selectedInterface.value.PeerDefDns
formData.value.PeerDefDnsSearch = selectedInterface.value.PeerDefDnsSearch
formData.value.PeerDefEndpoint = selectedInterface.value.PeerDefEndpoint
formData.value.PeerDefAllowedIPs = selectedInterface.value.PeerDefAllowedIPs
formData.value.PeerDefMtu = selectedInterface.value.PeerDefMtu
formData.value.PeerDefPersistentKeepalive = selectedInterface.value.PeerDefPersistentKeepalive
formData.value.PeerDefFirewallMark = selectedInterface.value.PeerDefFirewallMark
formData.value.PeerDefRoutingTable = selectedInterface.value.PeerDefRoutingTable
formData.value.PeerDefPreUp = selectedInterface.value.PeerDefPreUp
formData.value.PeerDefPostUp = selectedInterface.value.PeerDefPostUp
formData.value.PeerDefPreDown = selectedInterface.value.PeerDefPreDown
formData.value.PeerDefPostDown = selectedInterface.value.PeerDefPostDown
}
}
}
)
function close() {
formData.value = freshInterface()
emit('close')
}
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Addresses = tags
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Dns = tags
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch = tags
}
function handleChangePeerDefNetwork(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefNetwork = tags
}
}
function handleChangePeerDefAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefAllowedIPs = tags
}
}
function handleChangePeerDefDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.PeerDefDns = tags
}
}
function handleChangePeerDefDnsSearch(tags) {
formData.value.PeerDefDnsSearch = tags
}
async function save() {
try {
if (props.interfaceId!=='#NEW#') {
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
} else {
await interfaces.CreateInterface(formData.value)
}
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to save interface!",
text: e.toString(),
type: 'error',
})
}
}
async function applyPeerDefaults() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
try {
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
notify({
title: "Peer Defaults Applied",
text: "Applied current peer defaults to all available peers.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload all peers after applying the defaults
} catch (e) {
console.log(e)
notify({
title: "Failed to apply peer defaults!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to delete interface!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#interface">{{ $t('modals.interface-edit.tab-interface') }}</a>
</li>
<li v-if="formData.Mode==='server'" class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#peerdefaults">{{ $t('modals.interface-edit.tab-peerdef') }}</a>
</li>
</ul>
<div id="interfaceTabs" class="tab-content">
<div id="interface" class="tab-pane fade active show">
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-general') }}</legend>
<div v-if="props.interfaceId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.identifier.label') }}</label>
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
<select v-model="formData.Mode" class="form-select">
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
<input v-model="formData.DisplayName" class="form-control" :placeholder="$t('modals.interface-edit.display-name.placeholder')" type="text">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-network') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.interface-edit.ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
</div>
<div v-if="formData.Mode==='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.listen-port.label') }}</label>
<input v-model="formData.ListenPort" class="form-control" :placeholder="$t('modals.interface-edit.listen-port.placeholder')" type="number">
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
</div>
<div v-if="formData.Mode!=='server'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangeDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
<small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small>
</div>
<div class="form-group col-md-6">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
<textarea v-model="formData.PreUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-up.label') }}</label>
<textarea v-model="formData.PostUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-down.label') }}</label>
<textarea v-model="formData.PreDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-down.label') }}</label>
<textarea v-model="formData.PostDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-state') }}</legend>
<div class="form-check form-switch">
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
</div>
</fieldset>
</div>
<div id="peerdefaults" class="tab-pane fade">
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-network') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.endpoint.label') }}</label>
<input v-model="formData.PeerDefEndpoint" class="form-control" :placeholder="$t('modals.interface-edit.defaults.endpoint.placeholder')" type="text">
<small class="form-text text-muted">{{ $t('modals.interface-edit.defaults.endpoint.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.networks.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefNetwork"
:placeholder="$t('modals.interface-edit.defaults.networks.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefNetwork"/>
<small class="form-text text-muted">{{ $t('modals.interface-edit.defaults.networks.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefAllowedIPs"
:placeholder="$t('modals.interface-edit.defaults.allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangePeerDefAllowedIPs"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDns"
:placeholder="$t('modals.interface-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangePeerDefDns"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.PeerDefDnsSearch"
:placeholder="$t('modals.interface-edit.dns-search.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangePeerDefDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.mtu.label') }}</label>
<input v-model="formData.PeerDefMtu" class="form-control" :placeholder="$t('modals.interface-edit.defaults.mtu.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.PeerDefFirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.PeerDefRoutingTable" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.defaults.keep-alive.label') }}</label>
<input v-model="formData.PeerDefPersistentKeepalive" class="form-control" :placeholder="$t('modals.interface-edit.defaults.keep-alive.placeholder')" type="number">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-peer-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
<textarea v-model="formData.PeerDefPreUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-up.label') }}</label>
<textarea v-model="formData.PeerDefPostUp" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-down.label') }}</label>
<textarea v-model="formData.PeerDefPreDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.post-down.label') }}</label>
<textarea v-model="formData.PeerDefPostDown" class="form-control" rows="2" :placeholder="$t('modals.interface-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
<hr class="mt-4">
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">{{ $t('modals.interface-edit.button-apply-defaults') }}</button>
</fieldset>
</div>
</div>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import Modal from "./Modal.vue";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import {interfaceStore} from "@/stores/interfaces";
import Prism from 'vue-prism-component'
import 'prismjs/components/prism-ini'
const { t } = useI18n()
const interfaces = interfaceStore()
const props = defineProps({
interfaceId: String,
visible: Boolean,
})
const configString = ref("")
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
return interfaces.Find(props.interfaceId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
return t("modals.interface-view.headline") + " " + selectedInterface.value.Identifier
})
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
await interfaces.LoadInterfaceConfig(selectedInterface.value.Identifier)
configString.value = interfaces.configuration
}
}
)
function close() {
emit('close')
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<Prism language="ini" :code="configString"></Prism>
</template>
<template #footer>
<button class="btn btn-primary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,59 @@
<template>
<Teleport to="#modals">
<div v-show="visible" class="modal-backdrop fade show" @click="closeBackdrop">
<div class="modal fade show" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" @click.stop="">
<div class="modal-content" ref="body">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button @click="closeModal" class="btn-close" aria-label="Close"></button>
</div>
<div class="modal-body col-md-12">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style>
.modal.show {
display:block;
}
.modal.show {
opacity: 1;
}
.modal-backdrop {
background-color: rgba(0,0,0,0.6) !important;
}
.modal-backdrop.show {
opacity: 1 !important;
}
</style>
<script setup>
const props = defineProps({
title: String,
visible: Boolean,
closeOnBackdrop: Boolean,
})
const emit = defineEmits(['close'])
function closeBackdrop() {
if(props.closeOnBackdrop) {
console.log("CLOSING BD")
emit('close')
}
}
function closeModal() {
console.log("CLOSING")
emit('close')
}
</script>

View File

@@ -0,0 +1,431 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { validateCIDR, validateIP, validateDomain } from '@/helpers/validators';
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshPeer, freshInterface } from '@/helpers/models';
const { t } = useI18n()
const peers = peerStore()
const interfaces = interfaceStore()
const props = defineProps({
peerId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedPeer = computed(() => {
return peers.Find(props.peerId)
})
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
if (selectedPeer.value) {
return t("modals.peer-edit.headline-edit-peer") + " " + selectedPeer.value.Identifier
}
return t("modals.peer-edit.headline-new-peer")
} else {
if (selectedPeer.value) {
return t("modals.peer-edit.headline-edit-endpoint") + " " + selectedPeer.value.Identifier
}
return t("modals.peer-edit.headline-new-endpoint")
}
})
const formData = ref(freshPeer())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedInterface.value)
console.log(selectedPeer.value)
if (!selectedPeer.value) {
await peers.PreparePeer(selectedInterface.value.Identifier)
formData.value.Identifier = peers.Prepared.Identifier
formData.value.DisplayName = peers.Prepared.DisplayName
formData.value.UserIdentifier = peers.Prepared.UserIdentifier
formData.value.InterfaceIdentifier = peers.Prepared.InterfaceIdentifier
formData.value.Disabled = peers.Prepared.Disabled
formData.value.ExpiresAt = peers.Prepared.ExpiresAt
formData.value.Notes = peers.Prepared.Notes
formData.value.Endpoint = peers.Prepared.Endpoint
formData.value.EndpointPublicKey = peers.Prepared.EndpointPublicKey
formData.value.AllowedIPs = peers.Prepared.AllowedIPs
formData.value.ExtraAllowedIPs = peers.Prepared.ExtraAllowedIPs
formData.value.PresharedKey = peers.Prepared.PresharedKey
formData.value.PersistentKeepalive = peers.Prepared.PersistentKeepalive
formData.value.PrivateKey = peers.Prepared.PrivateKey
formData.value.PublicKey = peers.Prepared.PublicKey
formData.value.Mode = peers.Prepared.Mode
formData.value.Addresses = peers.Prepared.Addresses
formData.value.CheckAliveAddress = peers.Prepared.CheckAliveAddress
formData.value.Dns = peers.Prepared.Dns
formData.value.DnsSearch = peers.Prepared.DnsSearch
formData.value.Mtu = peers.Prepared.Mtu
formData.value.FirewallMark = peers.Prepared.FirewallMark
formData.value.RoutingTable = peers.Prepared.RoutingTable
formData.value.PreUp = peers.Prepared.PreUp
formData.value.PostUp = peers.Prepared.PostUp
formData.value.PreDown = peers.Prepared.PreDown
formData.value.PostDown = peers.Prepared.PostDown
} else { // fill existing data
formData.value.Identifier = selectedPeer.value.Identifier
formData.value.DisplayName = selectedPeer.value.DisplayName
formData.value.UserIdentifier = selectedPeer.value.UserIdentifier
formData.value.InterfaceIdentifier = selectedPeer.value.InterfaceIdentifier
formData.value.Disabled = selectedPeer.value.Disabled
formData.value.ExpiresAt = selectedPeer.value.ExpiresAt
formData.value.Notes = selectedPeer.value.Notes
formData.value.Endpoint = selectedPeer.value.Endpoint
formData.value.EndpointPublicKey = selectedPeer.value.EndpointPublicKey
formData.value.AllowedIPs = selectedPeer.value.AllowedIPs
formData.value.ExtraAllowedIPs = selectedPeer.value.ExtraAllowedIPs
formData.value.PresharedKey = selectedPeer.value.PresharedKey
formData.value.PersistentKeepalive = selectedPeer.value.PersistentKeepalive
formData.value.PrivateKey = selectedPeer.value.PrivateKey
formData.value.PublicKey = selectedPeer.value.PublicKey
formData.value.Mode = selectedPeer.value.Mode
formData.value.Addresses = selectedPeer.value.Addresses
formData.value.CheckAliveAddress = selectedPeer.value.CheckAliveAddress
formData.value.Dns = selectedPeer.value.Dns
formData.value.DnsSearch = selectedPeer.value.DnsSearch
formData.value.Mtu = selectedPeer.value.Mtu
formData.value.FirewallMark = selectedPeer.value.FirewallMark
formData.value.RoutingTable = selectedPeer.value.RoutingTable
formData.value.PreUp = selectedPeer.value.PreUp
formData.value.PostUp = selectedPeer.value.PostUp
formData.value.PreDown = selectedPeer.value.PreDown
formData.value.PostDown = selectedPeer.value.PostDown
if (!formData.value.Endpoint.Overridable ||
!formData.value.EndpointPublicKey.Overridable ||
!formData.value.AllowedIPs.Overridable ||
!formData.value.PersistentKeepalive.Overridable ||
!formData.value.Dns.Overridable ||
!formData.value.DnsSearch.Overridable ||
!formData.value.Mtu.Overridable ||
!formData.value.FirewallMark.Overridable ||
!formData.value.RoutingTable.Overridable ||
!formData.value.PreUp.Overridable ||
!formData.value.PostUp.Overridable ||
!formData.value.PreDown.Overridable ||
!formData.value.PostDown.Overridable) {
formData.value.IgnoreGlobalSettings = true
}
}
}
}
)
watch(() => formData.value.IgnoreGlobalSettings, async (newValue, oldValue) => {
formData.value.Endpoint.Overridable = !newValue
formData.value.EndpointPublicKey.Overridable = !newValue
formData.value.AllowedIPs.Overridable = !newValue
formData.value.PersistentKeepalive.Overridable = !newValue
formData.value.Dns.Overridable = !newValue
formData.value.DnsSearch.Overridable = !newValue
formData.value.Mtu.Overridable = !newValue
formData.value.FirewallMark.Overridable = !newValue
formData.value.RoutingTable.Overridable = !newValue
formData.value.PreUp.Overridable = !newValue
formData.value.PostUp.Overridable = !newValue
formData.value.PreDown.Overridable = !newValue
formData.value.PostDown.Overridable = !newValue
}
)
watch(() => formData.value.Disabled, async (newValue, oldValue) => {
if (oldValue && !newValue && formData.value.ExpiresAt) {
formData.value.ExpiresAt = "" // reset expiry date
}
}
)
function close() {
formData.value = freshPeer()
emit('close')
}
function handleChangeAddresses(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Addresses = tags
}
}
function handleChangeAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.AllowedIPs.Value = tags
}
}
function handleChangeExtraAllowedIPs(tags) {
let validInput = true
tags.forEach(tag => {
if(isCidr(tag) === 0) {
validInput = false
notify({
title: "Invalid CIDR",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.ExtraAllowedIPs = tags
}
}
function handleChangeDns(tags) {
let validInput = true
tags.forEach(tag => {
if(!isIP(tag)) {
validInput = false
notify({
title: "Invalid IP",
text: tag + " is not a valid IP address",
type: 'error',
})
}
})
if(validInput) {
formData.value.Dns = tags
}
}
function handleChangeDnsSearch(tags) {
formData.value.DnsSearch = tags
}
async function save() {
try {
if (props.peerId!=='#NEW#') {
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
} else {
await peers.CreatePeer(selectedInterface.value.Identifier, formData.value)
}
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await peers.DeletePeer(selectedPeer.value.Identifier)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-general') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.display-name.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.display-name.placeholder')" v-model="formData.DisplayName">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.linked-user.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.linked-user.placeholder')" v-model="formData.UserIdentifier">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
<div class="form-group" v-if="selectedInterface.Mode==='server'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required v-model="formData.PrivateKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required v-model="formData.PublicKey">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')" v-model="formData.PresharedKey">
</div>
<div class="form-group" v-if="formData.Mode==='client'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.endpoint-public-key.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint-public-key.placeholder')" v-model="formData.EndpointPublicKey.Value">
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-network') }}</legend>
<div class="form-group" v-if="selectedInterface.Mode==='client'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.endpoint.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" v-model="formData.Endpoint.Value">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Addresses"
:placeholder="$t('modals.peer-edit.ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAddresses"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.AllowedIPs.Value"
:placeholder="$t('modals.peer-edit.allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeAllowedIPs"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.extra-allowed-ip.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.ExtraAllowedIPs"
:placeholder="$t('modals.peer-edit.extra-allowed-ip.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateCIDR"
@on-tags-changed="handleChangeExtraAllowedIPs"/>
<small class="form-text text-muted">{{ $t('modals.peer-edit.extra-allowed-ip.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Dns.Value"
:placeholder="$t('modals.peer-edit.dns.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateIP"
@on-tags-changed="handleChangeDns"/>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.dns-search.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.DnsSearch.Value"
:placeholder="$t('modals.peer-edit.dns-search.label')"
:add-tag-on-keys="[13, 188, 32, 9]"
:validate="validateDomain"
@on-tags-changed="handleChangeDnsSearch"/>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.keep-alive.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.keep-alive.label')" v-model="formData.PersistentKeepalive.Value">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.peer-edit.mtu.label') }}</label>
<input type="number" class="form-control" :placeholder="$t('modals.peer-edit.mtu.label')" v-model="formData.Mtu.Value">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-hooks') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-up.label') }}</label>
<textarea v-model="formData.PreUp.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.pre-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-up.label') }}</label>
<textarea v-model="formData.PostUp.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.post-up.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.pre-down.label') }}</label>
<textarea v-model="formData.PreDown.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.pre-down.placeholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.post-down.label') }}</label>
<textarea v-model="formData.PostDown.Value" class="form-control" rows="2" :placeholder="$t('modals.peer-edit.post-down.placeholder')"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.peer-edit.header-state') }}</legend>
<div class="row">
<div class="form-group col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label" >{{ $t('modals.peer-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.IgnoreGlobalSettings">
<label class="form-check-label">{{ $t('modals.peer-edit.ignore-global.label') }}</label>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label">{{ $t('modals.peer-edit.expires-at.label') }}</label>
<input type="date" pattern="\d{4}-\d{2}-\d{2}" class="form-control" min="2023-01-01" v-model="formData.ExpiresAt">
</div>
</div>
</fieldset>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.peerId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
</style>

View File

@@ -0,0 +1,110 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
import { freshInterface } from '@/helpers/models';
const { t } = useI18n()
const peers = peerStore()
const interfaces = interfaceStore()
const props = defineProps({
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
function freshForm() {
return {
Identifiers: [],
Suffix: "",
}
}
const formData = ref(freshForm())
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
return t("modals.peer-multi-create.headline-peer")
} else {
return t("modals.peer-multi-create.headline-endpoint")
}
})
function close() {
formData.value = freshForm()
emit('close')
}
function handleChangeUserIdentifiers(tags) {
formData.value.Identifiers = tags
}
async function save() {
if (formData.value.Identifiers.length === 0) {
notify({
title: "Missing Identifiers",
text: "At least one identifier is required to create a new peer.",
type: 'error',
})
return
}
try {
await peers.CreateMultiplePeers(selectedInterface.value.Identifier, formData.value)
close()
} catch (e) {
console.log(e)
notify({
title: "Failed to create peers!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.identifiers.label') }}</label>
<vue3-tags-input class="form-control" :tags="formData.Identifiers"
:placeholder="$t('modals.peer-multi-create.identifiers.placeholder')"
:add-tag-on-keys="[13, 188, 32, 9]"
@on-tags-changed="handleChangeUserIdentifiers"/>
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.identifiers.description') }}</small>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.prefix.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Suffix">
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.prefix.description') }}</small>
</div>
</fieldset>
</template>
<template #footer>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import {freshInterface, freshPeer, freshStats} from '@/helpers/models';
import Prism from "vue-prism-component";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n()
const settings = settingsStore()
const peers = peerStore()
const interfaces = interfaceStore()
const props = defineProps({
peerId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
function close() {
emit('close')
}
const configString = ref("")
const selectedPeer = computed(() => {
let p = peers.Find(props.peerId)
if (!p) {
p = freshPeer() // dummy peer to avoid 'undefined' exceptions
}
return p
})
const selectedStats = computed(() => {
let s = peers.Statistics(props.peerId)
if (!s) {
s = freshStats() // dummy peer to avoid 'undefined' exceptions
}
return s
})
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
if (!i) {
i = freshInterface() // dummy interface to avoid 'undefined' exceptions
}
return i
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedInterface.value.Mode === "server") {
return t("modals.peer-view.headline-peer") + " " + selectedPeer.value.DisplayName
} else {
return t("modals.peer-view.headline-endpoint") + " " + selectedPeer.value.DisplayName
}
})
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
await peers.LoadPeerConfig(selectedPeer.value.Identifier)
configString.value = peers.configuration
}
}
)
function download() {
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = 'WireGuard-Tunnel.conf'
if (selectedPeer.value.DisplayName) {
filename = selectedPeer.value.DisplayName
.replace(/ /g,"_")
.replace(/[^a-zA-Z0-9-_]/g,"")
.substring(0, 16)
+ ".conf"
}
let text = configString.value
let element = document.createElement('a')
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.style.display = 'none'
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
function email() {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
notify({
title: "Failed to send mail with peer configuration!",
text: e.toString(),
type: 'error',
})
})
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<div class="accordion" id="peerInformation">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDetails" aria-expanded="true" aria-controls="collapseDetails">
{{ $t('modals.peer-view.section-info') }}
</button>
</h2>
<div id="collapseDetails" class="accordion-collapse collapse show" aria-labelledby="headingDetails" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-8">
<ul>
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{ selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{ selectedPeer.DisabledReason }}</li>
</ul>
</div>
<div class="col-md-4">
<img class="config-qr-img" :src="peers.ConfigQrUrl(props.peerId)" loading="lazy" alt="Configuration QR Code">
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingStatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseStatus" aria-expanded="false" aria-controls="collapseStatus">
{{ $t('modals.peer-view.section-status') }}
</button>
</h2>
<div id="collapseStatus" class="accordion-collapse collapse" aria-labelledby="headingStatus" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-12">
<h4>{{ $t('modals.peer-view.traffic') }}</h4>
<p><i class="fas fa-long-arrow-alt-down" :title="$t('modals.peer-view.download')"></i> {{ selectedStats.BytesReceived }} Bytes / <i class="fas fa-long-arrow-alt-up" :title="$t('modals.peer-view.upload')"></i> {{ selectedStats.BytesTransmitted }} Bytes</p>
<h4>{{ $t('modals.peer-view.connection-status') }}</h4>
<ul>
<li>{{ $t('modals.peer-view.pingable') }}: {{ selectedStats.IsPingable }}</li>
<li>{{ $t('modals.peer-view.handshake') }}: {{ selectedStats.LastHandshake }}</li>
<li>{{ $t('modals.peer-view.connected-since') }}: {{ selectedStats.LastSessionStart }}</li>
<li>{{ $t('modals.peer-view.endpoint') }}: {{ selectedStats.EndpointAddress }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-if="selectedInterface.Mode==='server'" class="accordion-item">
<h2 class="accordion-header" id="headingConfig">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
{{ $t('modals.peer-view.section-config') }}
</button>
</h2>
<div id="collapseConfig" class="accordion-collapse collapse" aria-labelledby="headingConfig" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<Prism language="ini" :code="configString"></Prism>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex-fill text-start">
<button @click.prevent="download" type="button" class="btn btn-primary me-1">{{ $t('modals.peer-view.button-download') }}</button>
<button @click.prevent="email" type="button" class="btn btn-primary me-1">{{ $t('modals.peer-view.button-email') }}</button>
</div>
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>
<style>
.config-qr-img {
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup>
import Modal from "./Modal.vue";
import {userStore} from "@/stores/users";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models";
const { t } = useI18n()
const users = userStore()
const props = defineProps({
userId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedUser = computed(() => {
return users.Find(props.userId)
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
if (selectedUser.value) {
return t("modals.user-edit.headline-edit") + " " + selectedUser.value.Identifier
}
return t("modals.user-edit.headline-new")
})
const formData = ref(freshUser())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
if (!selectedUser.value) {
formData.value = freshUser()
} else { // fill existing userdata
formData.value.Identifier = selectedUser.value.Identifier
formData.value.Email = selectedUser.value.Email
formData.value.Source = selectedUser.value.Source
formData.value.IsAdmin = selectedUser.value.IsAdmin
formData.value.Firstname = selectedUser.value.Firstname
formData.value.Lastname = selectedUser.value.Lastname
formData.value.Phone = selectedUser.value.Phone
formData.value.Department = selectedUser.value.Department
formData.value.Notes = selectedUser.value.Notes
formData.value.Password = ""
formData.value.Disabled = selectedUser.value.Disabled
}
}
}
)
function close() {
formData.value = freshUser()
emit('close')
}
async function save() {
try {
if (props.userId!=='#NEW#') {
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
} else {
await users.CreateUser(formData.value)
}
close()
} catch (e) {
notify({
title: "Failed to save user!",
text: e.toString(),
type: 'error',
})
}
}
async function del() {
try {
await users.DeleteUser(selectedUser.value.Identifier)
close()
} catch (e) {
notify({
title: "Failed to delete user!",
text: e.toString(),
type: 'error',
})
}
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
<div v-if="props.userId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.user-edit.identifier.placeholder')" type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
</div>
<div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text">
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div>
</fieldset>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
<input v-model="formData.Email" class="form-control" :placeholder="$t('modals.user-edit.email.placeholder')" type="email">
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.firstname.label') }}</label>
<input v-model="formData.Firstname" class="form-control" :placeholder="$t('modals.user-edit.firstname.placeholder')" type="text">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.lastname.label') }}</label>
<input v-model="formData.Lastname" class="form-control" :placeholder="$t('modals.user-edit.lastname.placeholder')" type="text">
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.phone.label') }}</label>
<input v-model="formData.Phone" class="form-control" :placeholder="$t('modals.user-edit.phone.placeholder')" type="text">
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.user-edit.department.label') }}</label>
<input v-model="formData.Department" class="form-control" :placeholder="$t('modals.user-edit.department.placeholder')" type="text">
</div>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.user-edit.header-notes') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.notes.label') }}</label>
<textarea v-model="formData.Notes" class="form-control" rows="2"></textarea>
</div>
</fieldset>
<fieldset>
<legend class="mt-4">{{ $t('modals.user-edit.header-state') }}</legend>
<div class="form-check form-switch">
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch">
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Source==='db'">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
</div>
</fieldset>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'&&formData.Source==='db'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,143 @@
<script setup>
import Modal from "./Modal.vue";
import {userStore} from "../stores/users";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n()
const users = userStore()
const props = defineProps({
userId: String,
visible: Boolean,
})
const emit = defineEmits(['close'])
const selectedUser = computed(() => {
let user = users.Find(props.userId)
if (user) {
return user
}
return {} // return empty object to avoid "undefined" access problems
})
const title = computed(() => {
if (!props.visible) {
return "" // otherwise interfaces.GetSelected will die...
}
return t("modals.user-view.headline") + " " + selectedUser.value.Identifier
})
const userPeers = computed(() => {
return users.Peers
})
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
await users.LoadUserPeers(selectedUser.value.Identifier)
}
}
)
function close() {
emit('close')
}
</script>
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#user">{{ $t('modals.user-view.tab-user') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#peers">{{ $t('modals.user-view.tab-peers') }}</a>
</li>
</ul>
<div id="interfaceTabs" class="tab-content">
<div id="user" class="tab-pane fade active show">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<h4>{{ $t('modals.user-view.headline-info') }}</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('modals.user-view.email') }}:</td>
<td>{{selectedUser.Email}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.firstname') }}:</td>
<td>{{selectedUser.Firstname}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.lastname') }}:</td>
<td>{{selectedUser.Lastname}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.phone') }}:</td>
<td>{{selectedUser.Phone}}</td>
</tr>
<tr>
<td>{{ $t('modals.user-view.department') }}:</td>
<td>{{selectedUser.Department}}</td>
</tr>
<tr v-if="selectedUser.Disabled">
<td>{{ $t('modals.user-view.disabled') }}:</td>
<td>{{selectedUser.DisabledReason}}</td>
</tr>
<tr v-if="selectedUser.Locked">
<td>{{ $t('modals.user-view.locked') }}:</td>
<td>{{selectedUser.LockedReason}}</td>
</tr>
</tbody>
</table>
</li>
<li class="list-group-item" v-if="selectedUser.Notes">
<h4>{{ $t('modals.user-view.headline-notes') }}</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr><td>{{selectedUser.Notes}}</td></tr>
</tbody>
</table>
</li>
</ul>
</div>
<div id="peers" class="tab-pane fade">
<ul v-if="userPeers.length===0" class="list-group list-group-flush">
<li class="list-group-item">{{ $t('modals.user-view.no-peers') }}</li>
</ul>
<table v-if="userPeers.length!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('modals.user-view.peers.name') }}</th>
<th scope="col">{{ $t('modals.user-view.peers.interface') }}</th>
<th scope="col">{{ $t('modals.user-view.peers.ip') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in userPeers" :key="peer.Identifier">
<td>{{peer.DisplayName}}</td>
<td>{{peer.InterfaceIdentifier}}</td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge pill bg-light">{{ ip }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template #footer>
<button class="btn btn-primary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
export function base64_url_encode(input) {
let output = btoa(input)
output = output.replace('+', '.')
output = output.replace('/', '_')
output = output.replace('=', '-')
return output
}

View File

@@ -0,0 +1,95 @@
import { authStore } from '@/stores/auth';
import { securityStore } from '@/stores/security';
export const fetchWrapper = {
url: apiUrl(),
get: request('GET'),
post: request('POST'),
put: request('PUT'),
delete: request('DELETE')
};
export const apiWrapper = {
url: apiUrl(),
get: apiRequest('GET'),
post: apiRequest('POST'),
put: apiRequest('PUT'),
delete: apiRequest('DELETE')
};
// request can be used to query arbitrary URLs
function request(method) {
return (url, body = undefined) => {
const requestOptions = {
method,
headers: getHeaders(url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// apiRequest uses WGPORTAL_BACKEND_BASE_URL as base URL
function apiRequest(method) {
return (path, body = undefined) => {
const url = WGPORTAL_BACKEND_BASE_URL + path
const requestOptions = {
method,
headers: getHeaders(method, url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// apiUrl uses WGPORTAL_BACKEND_BASE_URL as base URL
function apiUrl() {
return (path) => {
return WGPORTAL_BACKEND_BASE_URL + path
}
}
// helper functions
function getHeaders(method, url) {
// return auth header with jwt if user is logged in and request is to the api url
const auth = authStore();
const sec = securityStore();
const isApiUrl = url.startsWith(WGPORTAL_BACKEND_BASE_URL);
let headers = {};
if (isApiUrl && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers["X-CSRF-TOKEN"] = sec.CsrfToken;
}
if (isApiUrl && auth.IsAuthenticated) {
headers["X-FRONTEND-UID"] = auth.UserIdentifier;
}
return headers;
}
function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
const auth = authStore();
if ([401, 403].includes(response.status) && auth.IsAuthenticated) {
console.log("automatic logout initiated...");
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
auth.Logout();
}
const error = (data && data.Message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}

View File

@@ -0,0 +1,164 @@
export function freshInterface() {
return {
Disabled: false,
DisplayName: "",
Identifier: "",
Mode: "server",
PublicKey: "",
PrivateKey: "",
ListenPort: 51820,
Addresses: [],
DnsStr: [],
DnsSearch: [],
Mtu: 0,
FirewallMark: 0,
RoutingTable: "",
PreUp: "",
PostUp: "",
PreDown: "",
PostDown: "",
SaveConfig: false,
// Peer defaults
PeerDefNetwork: [],
PeerDefDns: [],
PeerDefDnsSearch: [],
PeerDefEndpoint: "",
PeerDefAllowedIPs: [],
PeerDefMtu: 0,
PeerDefPersistentKeepalive: 0,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
TotalPeers: 0,
EnabledPeers: 0
}
}
export function freshPeer() {
return {
Identifier: "",
DisplayName: "",
UserIdentifier: "",
InterfaceIdentifier: "",
Disabled: false,
ExpiresAt: null,
Notes: "",
Endpoint: {
Value: "",
Overridable: true,
},
EndpointPublicKey: {
Value: "",
Overridable: true,
},
AllowedIPs: {
Value: [],
Overridable: true,
},
ExtraAllowedIPs: [],
PresharedKey: "",
PersistentKeepalive: {
Value: 0,
Overridable: true,
},
PrivateKey: "",
PublicKey: "",
Mode: "client",
Addresses: [],
CheckAliveAddress: "",
Dns: {
Value: [],
Overridable: true,
},
DnsSearch: {
Value: [],
Overridable: true,
},
Mtu: {
Value: 0,
Overridable: true,
},
FirewallMark: {
Value: 0,
Overridable: true,
},
RoutingTable: {
Value: "",
Overridable: true,
},
PreUp: {
Value: "",
Overridable: true,
},
PostUp: {
Value: "",
Overridable: true,
},
PreDown: {
Value: "",
Overridable: true,
},
PostDown: {
Value: "",
Overridable: true,
},
// Internal value
IgnoreGlobalSettings: false
}
}
export function freshUser() {
return {
Identifier: "",
Email: "",
Source: "db",
IsAdmin: false,
Firstname: "",
Lastname: "",
Phone: "",
Department: "",
Notes: "",
Password: "",
Disabled: false,
DisabledReason: "",
Locked: false,
LockedReason: "",
PeerCount: 0
}
}
export function freshStats() {
return {
IsConnected: false,
IsPingable: false,
LastHandshake: null,
LastPing: null,
LastSessionStart: null,
BytesTransmitted: 0,
BytesReceived: 0,
EndpointAddress: ""
}
}

View File

@@ -0,0 +1,14 @@
import isCidr from "is-cidr";
import {isIP} from 'is-ip';
export function validateCIDR(value) {
return isCidr(value) !== 0
}
export function validateIP(value) {
return isIP(value)
}
export function validateDomain(value) {
return true
}

View File

@@ -0,0 +1,27 @@
// src/lang/index.js
import de from './translations/de.json';
import en from './translations/en.json';
import {createI18n} from "vue-i18n";
function getStoredLanguage() {
let initialLang = localStorage.getItem('wgLang');
if (!initialLang) {
initialLang = "en"
}
return initialLang
}
// Create i18n instance with options
const i18n = createI18n({
legacy: false,
globalInjection: true,
allowComposition: true,
locale: getStoredLanguage(), // set locale
fallbackLocale: "en", // set fallback locale
messages: {
"de": de,
"en": en
}
});
export default i18n

View File

@@ -0,0 +1,489 @@
{
"general": {
"pagination": {
"size": "Anzahl an Elementen",
"all": "Alle (langsam)"
},
"search": {
"placeholder": "Suche...",
"button": "Suchen"
},
"select-all": "Alle auswählen",
"yes": "Ja",
"no": "Nein",
"cancel": "Abbrechen",
"close": "Schließen",
"save": "Speichern",
"delete": "Löschen"
},
"login": {
"headline": "Bitte melden Sie sich an",
"username": {
"label": "Benutzername",
"placeholder": "Bitte geben Sie Ihren Benutzernamen ein"
},
"password": {
"label": "Kennwort",
"placeholder": "Bitte geben Sie Ihr Passwort ein"
},
"button": "Anmelden"
},
"menu": {
"home": "Home",
"interfaces": "Schnittstellen",
"users": "Benutzer",
"lang": "Sprache ändern",
"profile": "Mein Profil",
"login": "Anmelden",
"logout": "Abmelden"
},
"home": {
"headline": "WireGuard® VPN Portal",
"info-headline": "Mehr Informationen",
"abstract": "WireGuard® ist ein extrem einfaches, aber dennoch schnelles und modernes VPN, das modernste Kryptographie nutzt. Es zielt darauf ab, schneller, einfacher, schlanker und nützlicher als IPsec zu sein, während es die massiven Kopfschmerzen vermeidet. Es soll wesentlich leistungsfähiger sein als OpenVPN.",
"installation": {
"box-header": "WireGuard Installation",
"headline": "Installation",
"content": "Die Installationsanweisungen für die Client-Software finden Sie auf der offiziellen WireGuard-Website.",
"btn": "Anleitung öffnen"
},
"about-wg": {
"box-header": "Über WireGuard",
"headline": "Über",
"content": "WireGuard® ist ein extrem einfaches, aber schnelles und modernes VPN, das modernste Kryptographie verwendet.",
"button": "Details"
},
"about-portal": {
"box-header": "Über WireGuard Portal",
"headline": "WireGuard Portal",
"content": "WireGuard Portal ist ein einfaches, webbasiertes Konfigurationsportal für WireGuard.",
"button": "Details"
},
"profiles": {
"headline": "VPN Profile",
"abstract": "Über Ihr Benutzerprofil können Sie auf Ihre persönlichen VPN-Konfigurationen zugreifen und diese herunterladen.",
"content": "Um alle Ihre konfigurierten Profile zu finden, klicken Sie auf die Schaltfläche unten.",
"button": "Mein Profil öffnen"
},
"admin": {
"headline": "Verwaltungsbereich",
"abstract": "Im Administrationsbereich können Sie VPN-Zugänge und die Serverschnittstelle sowie die Benutzer, die sich am VPN-Portal anmelden dürfen, verwalten.",
"content": "",
"button-admin": "Schnittstellenverwaltung",
"button-user": "Benutzerverwaltung"
}
},
"interfaces": {
"headline": "Schnittstellenverwaltung",
"headline-peers": "Current VPN Peers",
"headline-endpoints": "Current Endpoints",
"no-interface": {
"default-selection": "No Interface available",
"headline": "No interfaces found...",
"abstract": "Click the plus button above to create a new WireGuard interface."
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
},
"table-heading": {
"name": "Name",
"user": "User",
"ip": "IP's",
"endpoint": "Endpoint",
"status": "Status"
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
"peers": "Enabled Peers",
"total-peers": "Total Peers",
"endpoints": "Enabled Endpoints",
"total-endpoints": "Total Endpoints",
"ip": "IP Address",
"default-allowed-ip": "Default allowed IPs",
"dns": "DNS Servers",
"mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval",
"button-show-config": "Show configuration",
"button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick",
"button-edit": "Edit interface"
},
"button-add-interface": "Add Interface",
"button-add-peer": "Add Peer",
"button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer",
"peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at",
"peer-connected": "Connected",
"peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:"
},
"users": {
"headline": "Benutzerverwaltung",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"source": "Source",
"peers": "Peers",
"admin": "Admin"
},
"no-user": {
"headline": "No users available",
"abstract": "Currently, there are no users registered with WireGuard Portal."
},
"button-add-user": "Add User",
"button-show-user": "Show User",
"button-edit-user": "Edit User",
"user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges",
"no-admin": "User has no administrator privileges"
},
"profile": {
"headline": "Meine VPN-Konfigurationen",
"table-heading": {
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers associated with your user profile."
},
"peer-connected": "Connected",
"button-add-peer": "Add Peer",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"modals": {
"user-view": {
"headline": "User Account:",
"tab-user": "Information",
"tab-peers": "Peers",
"headline-info": "User Information:",
"headline-notes": "Notes:",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"department": "Department",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",
"peers": {
"name": "Name",
"interface": "Interface",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Edit user:",
"headline-new": "New user",
"header-general": "General",
"header-personal": "User Information",
"header-notes": "Notes",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique user identifier"
},
"source": {
"label": "Source",
"placeholder": "The user source"
},
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
},
"email": {
"label": "Email",
"placeholder": "The email address"
},
"phone": {
"label": "Phone",
"placeholder": "The phone number"
},
"department": {
"label": "Department",
"placeholder": "The department"
},
"firstname": {
"label": "Firstname",
"placeholder": "Firstname"
},
"lastname": {
"label": "Lastname",
"placeholder": "Lastname"
},
"notes": {
"label": "Notes",
"placeholder": ""
},
"disabled": {
"label": "Disabled (no WireGuard connection and no login possible)"
},
"locked": {
"label": "Locked (no login possible, WireGuard connections still work)"
},
"admin": {
"label": "Is Admin"
}
},
"interface-view": {
"headline": "Config for Interface:"
},
"interface-edit": {
"headline-edit": "Edit Interface:",
"headline-new": "New Interface",
"tab-interface": "Interface",
"tab-peerdef": "Peer Defaults",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Interface Hooks",
"header-peer-hooks": "Hooks",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique interface identifier"
},
"mode": {
"label": "Interface Mode",
"server": "Server Mode",
"client": "Client Mode",
"any": "Unknown Mode"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"listen-port": {
"label": "Listen Port",
"placeholder": "The listening port"
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"mtu": {
"label": "MTU",
"placeholder": "The interface MTU (0 = keep default)"
},
"firewall-mark": {
"label": "Firewall Mark",
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
},
"routing-table": {
"label": "Routing Table",
"placeholder": "The routing table ID",
"description": "Special cases: off = do not manage routes, 0 = automatic"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Interface Disabled"
},
"save-config": {
"label": "Automatically save wg-quick config"
},
"defaults": {
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
},
"networks": {
"label": "IP Networks",
"placeholder": "Network Addresses",
"description": "Peers will get IP addresses from those subnets."
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Default Allowed IP Addresses"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Peer Information",
"section-status": "Current Status",
"section-config": "Configuration",
"identifier": "Identifier",
"ip": "IP Addresses",
"user": "Associated User",
"notes": "Notes",
"expiry-status": "Expires At",
"disabled-status": "Disabled At",
"traffic": "Traffic",
"connection-status": "Connection Stats",
"upload": "Uploaded Bytes (from Server to Peer)",
"download": "Downloaded Bytes (from Peer to Server)",
"pingable": "Is Pingable",
"handshake": "Last Handshake",
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",
"headline-edit-endpoint": "Edit endpoint:",
"headline-new-peer": "Create peer",
"headline-new-endpoint": "Create endpoint",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Hooks (Executed on Peer)",
"header-state": "State",
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the peer"
},
"linked-user": {
"label": "Linked User",
"placeholder": "The user account which owns this peer"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "Optional pre-shared key"
},
"endpoint-public-key": {
"label": "Endpoint public Key",
"placeholder": "The public key of the remote endpoint"
},
"endpoint": {
"label": "Endpoint Address",
"placeholder": "The address of the remote endpoint"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Allowed IP Addresses (CIDR format)"
},
"extra-allowed-ip": {
"label": "Extra allowed IP Addresses",
"placeholder": "Extra allowed IP's (Server Sided)",
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Peer Disabled"
},
"ignore-global": {
"label": "Ignore global settings"
},
"expires-at": {
"label": "Expiry date"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
}
}
}
}

View File

@@ -0,0 +1,489 @@
{
"general": {
"pagination": {
"size": "Number of Elements",
"all": "All (slow)"
},
"search": {
"placeholder": "Search...",
"button": "Search"
},
"select-all": "Select all",
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete"
},
"login": {
"headline": "Please sign in",
"username": {
"label": "Username",
"placeholder": "Please enter your username"
},
"password": {
"label": "Password",
"placeholder": "Please enter your password"
},
"button": "Sign in"
},
"menu": {
"home": "Home",
"interfaces": "Interfaces",
"users": "Users",
"lang": "Toggle Language",
"profile": "My Profile",
"login": "Login",
"logout": "Logout"
},
"home": {
"headline": "WireGuard® VPN Portal",
"info-headline": "More Information",
"abstract": "WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography. It aims to be faster, simpler, leaner, and more useful than IPsec, while avoiding the massive headache. It intends to be considerably more performant than OpenVPN.",
"installation": {
"box-header": "WireGuard Installation",
"headline": "Installation",
"content": "Installation instructions for client software can be found on the official WireGuard website.",
"btn": "Open Instructions"
},
"about-wg": {
"box-header": "About WireGuard",
"headline": "About",
"content": "WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography.",
"button": "More"
},
"about-portal": {
"box-header": "About WireGuard Portal",
"headline": "WireGuard Portal",
"content": "WireGuard Portal is a simple, web based configuration portal for WireGuard.",
"button": "More"
},
"profiles": {
"headline": "VPN Profiles",
"abstract": "You can access and download your personal VPN configurations via your Userprofile.",
"content": "To find all your configured profiles click on the button below.",
"button": "Open my profile"
},
"admin": {
"headline": "Administration Area",
"abstract": "In the administration area you can manage WireGuard peers and the server interface as well as users that are allowed to log in to the WireGuard Portal.",
"content": "",
"button-admin": "Open Server Administration",
"button-user": "Open User Administration"
}
},
"interfaces": {
"headline": "Interface Administration",
"headline-peers": "Current VPN Peers",
"headline-endpoints": "Current Endpoints",
"no-interface": {
"default-selection": "No Interface available",
"headline": "No interfaces found...",
"abstract": "Click the plus button above to create a new WireGuard interface."
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
},
"table-heading": {
"name": "Name",
"user": "User",
"ip": "IP's",
"endpoint": "Endpoint",
"status": "Status"
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
"peers": "Enabled Peers",
"total-peers": "Total Peers",
"endpoints": "Enabled Endpoints",
"total-endpoints": "Total Endpoints",
"ip": "IP Address",
"default-allowed-ip": "Default allowed IPs",
"dns": "DNS Servers",
"mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval",
"button-show-config": "Show configuration",
"button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick",
"button-edit": "Edit interface"
},
"button-add-interface": "Add Interface",
"button-add-peer": "Add Peer",
"button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer",
"peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at",
"peer-connected": "Connected",
"peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:"
},
"users": {
"headline": "User Administration",
"table-heading": {
"id": "ID",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"source": "Source",
"peers": "Peers",
"admin": "Admin"
},
"no-user": {
"headline": "No users available",
"abstract": "Currently, there are no users registered with WireGuard Portal."
},
"button-add-user": "Add User",
"button-show-user": "Show User",
"button-edit-user": "Edit User",
"user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges",
"no-admin": "User has no administrator privileges"
},
"profile": {
"headline": "My VPN Peers",
"table-heading": {
"name": "Name",
"ip": "IP's",
"stats": "Status",
"interface": "Server Interface"
},
"no-peer": {
"headline": "No peers available",
"abstract": "Currently, there are no peers associated with your user profile."
},
"peer-connected": "Connected",
"button-add-peer": "Add Peer",
"button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer"
},
"modals": {
"user-view": {
"headline": "User Account:",
"tab-user": "Information",
"tab-peers": "Peers",
"headline-info": "User Information:",
"headline-notes": "Notes:",
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"phone": "Phone number",
"department": "Department",
"disabled": "Account Disabled",
"locked": "Account Locked",
"no-peers": "User has no associated peers.",
"peers": {
"name": "Name",
"interface": "Interface",
"ip": "IP's"
}
},
"user-edit": {
"headline-edit": "Edit user:",
"headline-new": "New user",
"header-general": "General",
"header-personal": "User Information",
"header-notes": "Notes",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique user identifier"
},
"source": {
"label": "Source",
"placeholder": "The user source"
},
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
},
"email": {
"label": "Email",
"placeholder": "The email address"
},
"phone": {
"label": "Phone",
"placeholder": "The phone number"
},
"department": {
"label": "Department",
"placeholder": "The department"
},
"firstname": {
"label": "Firstname",
"placeholder": "Firstname"
},
"lastname": {
"label": "Lastname",
"placeholder": "Lastname"
},
"notes": {
"label": "Notes",
"placeholder": ""
},
"disabled": {
"label": "Disabled (no WireGuard connection and no login possible)"
},
"locked": {
"label": "Locked (no login possible, WireGuard connections still work)"
},
"admin": {
"label": "Is Admin"
}
},
"interface-view": {
"headline": "Config for Interface:"
},
"interface-edit": {
"headline-edit": "Edit Interface:",
"headline-new": "New Interface",
"tab-interface": "Interface",
"tab-peerdef": "Peer Defaults",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Interface Hooks",
"header-peer-hooks": "Hooks",
"header-state": "State",
"identifier": {
"label": "Identifier",
"placeholder": "The unique interface identifier"
},
"mode": {
"label": "Interface Mode",
"server": "Server Mode",
"client": "Client Mode",
"any": "Unknown Mode"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"listen-port": {
"label": "Listen Port",
"placeholder": "The listening port"
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"mtu": {
"label": "MTU",
"placeholder": "The interface MTU (0 = keep default)"
},
"firewall-mark": {
"label": "Firewall Mark",
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
},
"routing-table": {
"label": "Routing Table",
"placeholder": "The routing table ID",
"description": "Special cases: off = do not manage routes, 0 = automatic"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Interface Disabled"
},
"save-config": {
"label": "Automatically save wg-quick config"
},
"defaults": {
"endpoint": {
"label": "Endpoint Address",
"placeholder": "Endpoint Address",
"description": "The endpoint address that peers will connect to."
},
"networks": {
"label": "IP Networks",
"placeholder": "Network Addresses",
"description": "Peers will get IP addresses from those subnets."
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Default Allowed IP Addresses"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Peer Information",
"section-status": "Current Status",
"section-config": "Configuration",
"identifier": "Identifier",
"ip": "IP Addresses",
"user": "Associated User",
"notes": "Notes",
"expiry-status": "Expires At",
"disabled-status": "Disabled At",
"traffic": "Traffic",
"connection-status": "Connection Stats",
"upload": "Uploaded Bytes (from Server to Peer)",
"download": "Downloaded Bytes (from Peer to Server)",
"pingable": "Is Pingable",
"handshake": "Last Handshake",
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",
"headline-edit-endpoint": "Edit endpoint:",
"headline-new-peer": "Create peer",
"headline-new-endpoint": "Create endpoint",
"header-general": "General",
"header-network": "Network",
"header-crypto": "Cryptography",
"header-hooks": "Hooks (Executed on Peer)",
"header-state": "State",
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the peer"
},
"linked-user": {
"label": "Linked User",
"placeholder": "The user account which owns this peer"
},
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "Optional pre-shared key"
},
"endpoint-public-key": {
"label": "Endpoint public Key",
"placeholder": "The public key of the remote endpoint"
},
"endpoint": {
"label": "Endpoint Address",
"placeholder": "The address of the remote endpoint"
},
"ip": {
"label": "IP Addresses",
"placeholder": "IP Addresses (CIDR format)"
},
"allowed-ip": {
"label": "Allowed IP Addresses",
"placeholder": "Allowed IP Addresses (CIDR format)"
},
"extra-allowed-ip": {
"label": "Extra allowed IP Addresses",
"placeholder": "Extra allowed IP's (Server Sided)",
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
},
"dns": {
"label": "DNS Server",
"placeholder": "The DNS servers that should be used"
},
"dns-search": {
"label": "DNS Search Domains",
"placeholder": "DNS search prefixes"
},
"keep-alive": {
"label": "Keep Alive Interval",
"placeholder": "Persistent Keepalive (0 = default)"
},
"mtu": {
"label": "MTU",
"placeholder": "The client MTU (0 = keep default)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "One or multiple bash commands separated by ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "One or multiple bash commands separated by ;"
},
"disabled": {
"label": "Peer Disabled"
},
"ignore-global": {
"label": "Ignore global settings"
},
"expires-at": {
"label": "Expiry date"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",
"headline-endpoint": "Create multiple endpoints",
"identifiers": {
"label": "User Identifiers",
"placeholder": "User Identifiers",
"description": "A user identifier (the username) for which a peer should be created."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Display Name Prefix",
"placeholder": "The prefix",
"description": "A prefix that is added to the peers display name."
}
}
}
}

45
frontend/src/main.js Normal file
View File

@@ -0,0 +1,45 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import i18n from "./lang";
import Notifications from '@kyvg/vue3-notification'
// Bootstrap (and theme)
//import "bootstrap/dist/css/bootstrap.min.css"
import "bootswatch/dist/lux/bootstrap.min.css";
import "bootstrap";
import "./assets/base.css";
// Fontawesome
import "@fortawesome/fontawesome-free/js/all.js"
// Flags
import "flag-icons/css/flag-icons.min.css"
// Syntax Highlighting
import 'prismjs'
import 'prismjs/themes/prism-okaidia.css'
const app = createApp(App);
app.use(i18n)
app.use(createPinia());
app.use(router);
app.use(Notifications);
app.config.globalProperties.$filters = {
truncate(value, maxLength, suffix) {
suffix = suffix || '...'
if (value.length > maxLength) {
return value.substring(0, maxLength) + suffix;
} else {
return value;
}
}
}
app.mount("#app");

View File

@@ -0,0 +1,109 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth'
import {notify} from "@kyvg/vue3-notification";
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{
path: '/interfaces',
name: 'interfaces',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/InterfaceView.vue')
},
{
path: '/users',
name: 'users',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/UserView.vue')
},
{
path: '/profile',
name: 'profile',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/ProfileView.vue')
}
],
linkActiveClass: "active",
linkExactActiveClass: "exact-active",
})
router.beforeEach(async (to) => {
const auth = authStore()
// check if the request was a successful oauth login
if ('wgLoginState' in to.query && !auth.IsAuthenticated) {
const state = to.query['wgLoginState']
const returnUrl = auth.ReturnUrl
console.log("Oauth login callback:", state)
if (state === "success") {
try {
const uid = await auth.LoadSession()
console.log("Oauth login completed for UID:", uid)
console.log("Continuing to:", returnUrl)
notify({
title: "Logged in",
text: "Authentication suceeded!",
type: 'success',
})
auth.ResetReturnUrl()
return returnUrl
} catch (e) {
notify({
title: "Login failed!",
text: "Oauth session is invalid!",
type: 'error',
})
return '/login'
}
} else {
notify({
title: "Login failed!",
text: "Authentication via Oauth failed!",
type: 'error',
})
return '/login'
}
}
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
return '/login'
}
})
export default router

125
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import router from '../router'
export const authStore = defineStore({
id: 'auth',
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
providers: [],
returnUrl: localStorage.getItem('returnUrl')
}),
getters: {
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
User: (state) => state.user,
LoginProviders: (state) => state.providers,
IsAuthenticated: (state) => state.user != null,
IsAdmin: (state) => state.user?.IsAdmin || false,
ReturnUrl: (state) => state.returnUrl || '/',
},
actions: {
SetReturnUrl(link) {
this.returnUrl = link
localStorage.setItem('returnUrl', link)
},
ResetReturnUrl() {
this.returnUrl = null
localStorage.removeItem('returnUrl')
},
// LoadProviders always returns a fulfilled promise, even if the request failed.
async LoadProviders() {
apiWrapper.get(`/auth/providers`)
.then(providers => this.providers = providers)
.catch(error => {
this.providers = []
console.log("Failed to load auth providers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load external authentication providers!",
})
})
},
// LoadSession returns promise that might have been rejected if the session was not authenticated.
async LoadSession() {
return apiWrapper.get(`/auth/session`)
.then(session => {
if (session.LoggedIn === true) {
this.ResetReturnUrl()
this.setUserInfo(session)
return session.UserIdentifier
} else {
this.setUserInfo(null)
return Promise.reject(new Error('session not authenticated'))
}
})
.catch(err => {
this.setUserInfo(null)
return Promise.reject(err)
})
},
// Login returns promise that might have been rejected if the login attempt was not successful.
async Login(username, password) {
return apiWrapper.post(`/auth/login`, { username, password })
.then(user => {
this.ResetReturnUrl()
this.setUserInfo(user)
return user.Identifier
})
.catch(err => {
console.log("Login failed:", err)
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
})
},
async Logout() {
this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^
try {
await apiWrapper.post(`/auth/logout`)
} catch (e) {
console.log("Logout request failed:", e)
}
notify({
title: "Logged Out",
text: "Logout successful!",
type: "warn",
})
await router.push('/login')
},
// -- internal setters
setUserInfo(userInfo) {
// store user details and jwt in local storage to keep user logged in between page refreshes
if (userInfo) {
if ('UserIdentifier' in userInfo) { // session object
this.user = {
Identifier: userInfo['UserIdentifier'],
Firstname: userInfo['UserFirstname'],
Lastname: userInfo['UserLastname'],
Email: userInfo['UserEmail'],
IsAdmin: userInfo['IsAdmin']
}
} else { // user object
this.user = {
Identifier: userInfo['Identifier'],
Firstname: userInfo['Firstname'],
Lastname: userInfo['Lastname'],
Email: userInfo['Email'],
IsAdmin: userInfo['IsAdmin']
}
}
localStorage.setItem('user', JSON.stringify(this.user))
} else {
this.user = null
localStorage.removeItem('user')
}
},
}
});

View File

@@ -0,0 +1,152 @@
import { defineStore } from 'pinia'
import {apiWrapper} from '@/helpers/fetch-wrapper'
import {notify} from "@kyvg/vue3-notification";
import { freshInterface } from '@/helpers/models';
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/interface`
export const interfaceStore = defineStore({
id: 'interfaces',
state: () => ({
interfaces: [],
prepared: freshInterface(),
configuration: "",
selected: "",
fetching: false,
}),
getters: {
Count: (state) => state.interfaces.length,
Prepared: (state) => {console.log("STATE:", state.prepared); return state.prepared},
All: (state) => state.interfaces,
Find: (state) => {
return (id) => state.interfaces.find((p) => p.Identifier === id)
},
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching,
},
actions: {
setInterfaces(interfaces) {
this.interfaces = interfaces
if (this.interfaces.length > 0) {
this.selected = this.interfaces[0].Identifier
} else {
this.selected = ""
}
this.fetching = false
},
async LoadInterfaces() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/all`)
.then(this.setInterfaces)
.catch(error => {
this.setInterfaces([])
console.log("Failed to load interfaces: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load interfaces!",
})
})
},
setPreparedInterface(iface) {
this.prepared = iface;
},
setInterfaceConfig(ifaceConfig) {
this.configuration = ifaceConfig;
},
async PrepareInterface() {
return apiWrapper.get(`${baseUrl}/prepare`)
.then(this.setPreparedInterface)
.catch(error => {
this.prepared = freshInterface()
console.log("Failed to load prepared interface: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load prepared interface!",
})
})
},
async LoadInterfaceConfig(id) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
.then(this.setInterfaceConfig)
.catch(error => {
this.configuration = ""
console.log("Failed to load interface configuration: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load interface configuration!",
})
})
},
async DeleteInterface(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.interfaces = this.interfaces.filter(i => i.Identifier !== id)
if (this.interfaces.length > 0) {
this.selected = this.interfaces[0].Identifier
} else {
this.selected = ""
}
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdateInterface(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(iface => {
let idx = this.interfaces.findIndex((i) => i.Identifier === id)
this.interfaces[idx] = iface
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateInterface(formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/new`, formData)
.then(iface => {
this.interfaces.push(iface)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async ApplyPeerDefaults(id, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/apply-peer-defaults`, formData)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
}
}
})

View File

@@ -0,0 +1,258 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {interfaceStore} from "./interfaces";
import {freshPeer, freshStats} from '@/helpers/models';
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/peer`
export const peerStore = defineStore({
id: 'peers',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
peer: freshPeer(),
prepared: freshPeer(),
configuration: "",
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Find: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id)
},
Count: (state) => state.peers.length,
Prepared: (state) => {console.log("STATE:", state.prepared); return state.prepared},
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.peers,
Filtered: (state) => {
if (!state.filter) {
return state.peers
}
return state.peers.filter((p) => {
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
ConfigQrUrl: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id) ? apiWrapper.url(`${baseUrl}/config-qr/${base64_url_encode(id)}`) : ''
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setPeers(peers) {
this.peers = peers
this.calculatePages()
this.fetching = false
},
setPeer(peer) {
this.peer = peer
this.fetching = false
},
setPreparedPeer(peer) {
this.prepared = peer;
},
setPeerConfig(config) {
this.configuration = config;
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async PreparePeer(interfaceId) {
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
.then(this.setPreparedPeer)
.catch(error => {
this.prepared = freshPeer()
console.log("Failed to load prepared peer: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load prepared peer!",
})
})
},
async MailPeerConfig(linkOnly, ids) {
return apiWrapper.post(`${baseUrl}/config-mail`, {
Identifiers: ids,
LinkOnly: linkOnly
})
.then(() => {
notify({
title: "Peer Configuration sent",
text: "Email sent to linked user!",
})
})
.catch(error => {
console.log("Failed to send peer configuration: ", error)
throw new Error(error)
})
},
async LoadPeerConfig(id) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
.then(this.setPeerConfig)
.catch(error => {
this.configuration = ""
console.log("Failed to load peer configuration: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer configuration!",
})
})
},
async LoadPeer(id) {
this.fetching = true
return apiWrapper.get(`${baseUrl}/${base64_url_encode(id)}`)
.then(this.setPeer)
.catch(error => {
this.setPeers([])
console.log("Failed to load peer: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer!",
})
})
},
async LoadStats(interfaceId) {
// if no interfaceId is given, use the currently selected interface
if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
}
this.fetching = true
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async DeletePeer(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.peers = this.peers.filter(p => p.Identifier !== id)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdatePeer(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(peer => {
let idx = this.peers.findIndex((p) => p.Identifier === id)
this.peers[idx] = peer
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreatePeer(interfaceId, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/new`, formData)
.then(peer => {
this.peers.push(peer)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateMultiplePeers(interfaceId, formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/multiplenew`, formData)
.then(peers => {
this.peers.push(...peers)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async LoadPeers(interfaceId) {
// if no interfaceId is given, use the currently selected interface
if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
}
this.fetching = true
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/all`)
.then(this.setPeers)
.catch(error => {
this.setPeers([])
console.log("Failed to load peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peers!",
})
})
}
}
})

View File

@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {authStore} from "@/stores/auth";
import { base64_url_encode } from '@/helpers/encoding';
import {freshStats} from "@/helpers/models";
const baseUrl = `/user`
export const profileStore = defineStore({
id: 'profile',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
user: {},
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
FindPeers: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id)
},
CountPeers: (state) => state.peers.length,
FilteredPeerCount: (state) => state.FilteredPeers.length,
Peers: (state) => state.peers,
FilteredPeers: (state) => {
if (!state.filter) {
return state.peers
}
return state.peers.filter((p) => {
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
})
},
FilteredAndPagedPeers: (state) => {
return state.FilteredPeers.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredPeerCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setPeers(peers) {
this.peers = peers
this.fetching = false
},
setUser(user) {
this.user = user
this.fetching = false
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async LoadPeers() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/peers`)
.then(this.setPeers)
.catch(error => {
this.setPeers([])
console.log("Failed to load user peers for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user peers!",
})
})
},
async LoadStats() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async LoadUser() {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.get(`${baseUrl}/${base64_url_encode(currentUser)}`)
.then(this.setUser)
.catch(error => {
this.setUser({})
console.log("Failed to load user for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user!",
})
})
},
}
})

View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
export const securityStore = defineStore({
id: 'security',
state: () => ({
csrfToken: "",
}),
getters: {
CsrfToken: (state) => state.csrfToken,
},
actions: {
SetCsrfToken(token) {
this.csrfToken = token
},
// LoadSecurityProperties always returns a fulfilled promise, even if the request failed.
async LoadSecurityProperties() {
await apiWrapper.get(`/csrf`)
.then(token => this.SetCsrfToken(token))
.catch(error => {
this.SetCsrfToken("");
console.log("Failed to load csrf token: ", error);
notify({
title: "Backend Connection Failure",
text: "Failed to load csrf token!",
});
})
}
}
});

View File

@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
const baseUrl = `/config`
export const settingsStore = defineStore({
id: 'settings',
state: () => ({
settings: {},
}),
getters: {
Setting: (state) => {
return (key) => (key in state.settings) ? state.settings[key] : undefined
}
},
actions: {
setSettings(settings) {
this.settings = settings
},
// LoadSecurityProperties always returns a fulfilled promise, even if the request failed.
async LoadSettings() {
await apiWrapper.get(`${baseUrl}/settings`)
.then(data => this.setSettings(data))
.catch(error => {
this.setSettings({});
console.log("Failed to load settings: ", error);
notify({
title: "Backend Connection Failure",
text: "Failed to load settings!",
});
})
}
}
});

View File

@@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/user`
export const userStore = defineStore({
id: 'users',
state: () => ({
userPeers: [],
users: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Find: (state) => {
return (id) => state.users.find((p) => p.Identifier === id)
},
Count: (state) => state.users.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.users,
Peers: (state) => state.userPeers,
Filtered: (state) => {
if (!state.filter) {
return state.users
}
return state.users.filter((u) => {
return u.Firstname.includes(state.filter) || u.Lastname.includes(state.filter) || u.Email.includes(state.filter) || u.Identifier.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setUsers(users) {
this.users = users
this.calculatePages()
this.fetching = false
},
setUserPeers(peers) {
this.userPeers = peers
this.fetching = false
},
async LoadUsers() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/all`)
.then(this.setUsers)
.catch(error => {
this.setUsers([])
console.log("Failed to load users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load users!",
})
})
},
async DeleteUser(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)
.then(() => {
this.users = this.users.filter(u => u.Identifier !== id)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async UpdateUser(id, formData) {
this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
.then(user => {
let idx = this.users.findIndex((u) => u.Identifier === id)
this.users[idx] = user
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async CreateUser(formData) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/new`, formData)
.then(user => {
this.users.push(user)
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async LoadUserPeers(id) {
this.fetching = true
return apiWrapper.get(`${baseUrl}/${base64_url_encode(id)}/peers`)
.then(this.setUserPeers)
.catch(error => {
this.setUserPeers([])
console.log("Failed to load user peers for ",id ,": ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load user peers!",
})
})
},
}
})

View File

@@ -0,0 +1,73 @@
<script setup>
import {authStore} from "@/stores/auth";
import {RouterLink} from "vue-router";
const auth = authStore()
</script>
<template>
<div class="page-header">
<h1>{{ $t('home.headline') }}</h1>
</div>
<p class="lead">{{ $t('home.abstract') }}</p>
<div class="bg-light p-5" v-if="auth.IsAuthenticated">
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.profiles.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
</p>
</div>
<div class="bg-light p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
<p class="lead">{{ $t('home.admin.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.admin.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}</RouterLink>
<RouterLink :to="{ name: 'users' }" class="btn btn-primary btn-lg">{{ $t('home.admin.button-user') }}</RouterLink>
</p>
</div>
<h3 class="mt-5">{{ $t('home.info-headline') }}</h3>
<div class="row">
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.installation.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.installation.headline') }}</h4>
<p class="card-text">{{ $t('home.installation.content') }}</p>
<a href="https://www.wireguard.com/install/" title="WireGuard Installation" target="_blank"
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.installation.button') }}</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.about-wg.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.about-wg.headline') }}</h4>
<p class="card-text">{{ $t('home.about-wg.content') }}</p>
<a href="https://www.wireguard.com/" title="WireGuard" target="_blank" rel="noopener noreferrer"
class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-wg.button') }}</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">{{ $t('home.about-portal.box-header') }}</div>
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank"
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,390 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import PeerEditModal from "../components/PeerEditModal.vue";
import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import {onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const interfaces = interfaceStore()
const peers = peerStore()
const viewedPeerId = ref("")
const editPeerId = ref("")
const multiCreatePeerId = ref("")
const editInterfaceId = ref("")
const viewedInterfaceId = ref("")
function calculateInterfaceName(id, name) {
let result = id
if (name) {
result += ' (' + name + ')'
}
return result
}
async function download() {
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
// credit: https://www.bitdegree.org/learn/javascript-download
let filename = interfaces.GetSelected.Identifier + ".conf"
let text = interfaces.configuration
let element = document.createElement('a')
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.style.display = 'none'
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
async function saveConfig() {
try {
await interfaces.SaveConfiguration(interfaces.GetSelected.Identifier)
notify({
title: "Interface configuration persisted to file",
text: "The interface configuration has been written to the wg-quick configuration file.",
type: 'success',
})
} catch (e) {
console.log(e)
notify({
title: "Failed to persist interface configuration file!",
text: e.toString(),
type: 'error',
})
}
}
onMounted(async () => {
await interfaces.LoadInterfaces()
await peers.LoadPeers(undefined) // use default interface
await peers.LoadStats(undefined) // use default interface
})
</script>
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId!==''" @close="viewedPeerId=''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId!==''" @close="editPeerId=''"></PeerEditModal>
<PeerMultiCreateModal :visible="multiCreatePeerId!==''" @close="multiCreatePeerId=''"></PeerMultiCreateModal>
<InterfaceEditModal :interfaceId="editInterfaceId" :visible="editInterfaceId!==''" @close="editInterfaceId=''"></InterfaceEditModal>
<InterfaceViewModal :interfaceId="viewedInterfaceId" :visible="viewedInterfaceId!==''" @close="viewedInterfaceId=''"></InterfaceViewModal>
<!-- Headline and interface selector -->
<div class="page-header row">
<div class="col-12 col-lg-8">
<h1>{{ $t('interfaces.headline') }}</h1>
</div>
<div class="col-12 col-lg-4 text-end">
<div class="form-group">
</div>
<div class="form-group">
<div class="input-group mb-3">
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
<i class="fa-solid fa-plus-circle"></i>
</button>
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="peers.LoadPeers()">
<option v-if="interfaces.Count===0" value="nothing">{{ $t('interfaces.no-interface.default-selection') }}</option>
<option v-for="iface in interfaces.All" :key="iface.Identifier" :value="iface.Identifier">{{ calculateInterfaceName(iface.Identifier,iface.DisplayName) }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- No interfaces information -->
<div v-if="interfaces.Count===0" class="row">
<div class="col-lg-12">
<div class="mt-5">
<h4>{{ $t('interfaces.no-interface.headline') }}</h4>
<p>{{ $t('interfaces.no-interface.abstract') }}</p>
</div>
</div>
</div>
<!-- Interface overview -->
<div v-if="interfaces.Count!==0" class="row">
<div class="col-lg-12">
<div class="card border-secondary mb-4" style="min-height: 15rem;">
<div class="card-header">
<div class="row">
<div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }})
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
<a class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-download-config')" @click.prevent="download"><i class="fas fa-download"></i></a>
<a v-if="settings.Setting('PersistentConfigSupported')" class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-store-config')" @click.prevent="saveConfig"><i class="fas fa-save"></i></a>
<a class="ms-5 btn-link" href="#" :title="$t('interfaces.interface.button-edit')" @click.prevent="editInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-cog"></i></a>
</div>
</div>
</div>
<div class="card-body d-flex flex-column">
<div v-if="interfaces.GetSelected.Mode==='server'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoint') }}:</td>
<td>{{interfaces.GetSelected.PeerDefEndpoint}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.port') }}:</td>
<td>{{interfaces.GetSelected.ListenPort}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.peers') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-peers') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-allowed-ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefAllowedIPs" :key="addr">{{addr}}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="interfaces.GetSelected.Mode==='client'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoints') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-endpoints') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="interfaces.GetSelected.Mode==='any'" class="row">
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.key') }}:</td>
<td>{{interfaces.GetSelected.PublicKey}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.endpoint') }}:</td>
<td>{{interfaces.GetSelected.PeerDefEndpoint}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.port') }}:</td>
<td>{{interfaces.GetSelected.ListenPort}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.peers') }}:</td>
<td>{{interfaces.GetSelected.EnabledPeers}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.total-peers') }}:</td>
<td>{{interfaces.GetSelected.TotalPeers}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6">
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
<td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-allowed-ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefAllowedIPs" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td>
</tr>
<tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Peer list -->
<div v-if="interfaces.Count!==0" class="mt-4 row">
<div class="col-12 col-lg-5">
<h2 v-if="interfaces.GetSelected.Mode==='server'" class="mt-2">{{ $t('interfaces.headline-peers') }}</h2>
<h2 v-else class="mt-2">{{ $t('interfaces.headline-endpoints') }}</h2>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peers')" @click.prevent="multiCreatePeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-users"></i></a>
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div>
</div>
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
<div v-if="peers.Count===0">
<h4>{{ $t('interfaces.no-peer.headline') }}</h4>
<p>{{ $t('interfaces.no-peer.abstract') }}</p>
</div>
<table v-if="peers.Count!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('interfaces.table-heading.name') }}</th>
<th scope="col">{{ $t('interfaces.table-heading.user') }}</th>
<th scope="col">{{ $t('interfaces.table-heading.ip') }}</th>
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.table-heading.endpoint') }}</th>
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.table-heading.status') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in peers.FilteredAndPaged" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger" :title="$t('interfaces.peer-disabled') + ' ' + peer.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
<td>{{peer.UserIdentifier}}</td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
</td>
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('interfaces.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:peers.pageOffset===0}" class="page-item">
<a class="page-link" @click="peers.previousPage">&laquo;</a>
</li>
<li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item">
<a class="page-link" @click="peers.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!peers.hasNextPage}" class="page-item">
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import {computed, ref} from "vue";
import {authStore} from "@/stores/auth";
import router from '../router/index.js'
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const auth = authStore()
const settings = settingsStore()
const loggingIn = ref(false)
const username = ref("")
const password = ref("")
const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
const login = async function () {
console.log("Performing login for user:", username.value);
loggingIn.value = true;
auth.Login(username.value, password.value)
.then(uid => {
notify({
title: "Logged in",
text: "Authentication succeeded!",
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // only logs errors, does not throw
router.push(auth.ReturnUrl);
})
.catch(error => {
notify({
title: "Login failed!",
text: "Authentication failed!",
type: 'error',
});
//loggingIn.value = false;
// delay the user from logging in for a short amount of time
setTimeout(() => loggingIn.value = false, 1000);
});
}
const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true;
console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath;
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri);
window.location.href = redirectUrl;
}
</script>
<template>
<div class="row">
<div class="col-lg-3"></div><!-- left spacer -->
<div class="col-lg-6">
<div class="card mt-5">
<div class="card-header">{{ $t('login.headline') }}<div class="float-end">
<RouterLink :to="{ name: 'home' }" class="nav-link" :title="$t('menu.home')"><i class="fas fa-times-circle"></i></RouterLink>
</div></div>
<div class="card-body">
<form method="post">
<fieldset>
<div class="form-group">
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
<div class="input-group mb-3">
<span class="input-group-text"><span class="far fa-user p-2"></span></span>
<input id="inputUsername" v-model="username" :class="{'is-invalid':usernameInvalid, 'is-valid':!usernameInvalid}" :placeholder="$t('login.username.placeholder')" aria-describedby="usernameHelp"
class="form-control"
name="username" type="text">
</div>
</div>
<div class="form-group">
<label class="form-label" for="inputPassword">{{ $t('login.password.label') }}</label>
<div class="input-group mb-3">
<span class="input-group-text"><span class="fas fa-lock p-2"></span></span>
<input id="inputPassword" v-model="password" :class="{'is-invalid':passwordInvalid, 'is-valid':!passwordInvalid}" :placeholder="$t('login.password.placeholder')" class="form-control"
name="password" type="password">
</div>
</div>
<div class="row mt-5 d-flex">
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
</div>
</div>
<div class="mt-3">
</div>
</fieldset>
</form>
</div>
</div>
</div>
<div class="col-lg-3"></div><!-- right spacer -->
</div>
</template>

View File

@@ -0,0 +1,126 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import {onMounted, ref} from "vue";
import {profileStore} from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const profile = profileStore()
const viewedPeerId = ref("")
const editPeerId = ref("")
onMounted(async () => {
await profile.LoadUser()
await profile.LoadPeers()
await profile.LoadStats()
})
</script>
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId!==''" @close="viewedPeerId=''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId!==''" @close="editPeerId=''"></PeerEditModal>
<!-- Peer list -->
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<h2 class="mt-2">{{ $t('profile.headline') }}</h2>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="profile.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a v-if="settings.Setting('SelfProvisioning')" class="btn btn-primary ms-2" href="#" :title="$t('general.search.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="profile.CountPeers===0">
<h4>{{ $t('profile.no-peer.headline') }}</h4>
<p>{{ $t('profile.no-peer.abstract') }}</p>
</div>
<table v-if="profile.CountPeers!==0" id="peerTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('profile.table-heading.name') }}</th>
<th scope="col">{{ $t('profile.table-heading.ip') }}</th>
<th v-if="profile.hasStatistics" scope="col">{{ $t('profile.table-heading.stats') }}</th>
<th scope="col">{{ $t('profile.table-heading.interface') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="peer in profile.FilteredAndPagedPeers" :key="peer.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="peer.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="peer.DisabledReason"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning"><i class="fas fa-hourglass-end" :title="peer.ExpiresAt"></i></span>
</td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{$filters.truncate(peer.Identifier, 10)}}</span></td>
<td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span>
</td>
<td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span> <span :title="peers.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td>{{peer.InterfaceIdentifier}}</td>
<td class="text-center">
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:profile.pageOffset===0}" class="page-item">
<a class="page-link" @click="profile.previousPage">&laquo;</a>
</li>
<li v-for="page in profile.pages" :key="page" :class="{active:profile.currentPage===page}" class="page-item">
<a class="page-link" @click="profile.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!profile.hasNextPage}" class="page-item">
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,126 @@
<script setup>
import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore()
const editUserId = ref("")
const viewedUserId = ref("")
onMounted(() => {
users.LoadUsers()
})
</script>
<template>
<UserEditModal :userId="editUserId" :visible="editUserId!==''" @close="editUserId=''"></UserEditModal>
<UserViewModal :userId="viewedUserId" :visible="viewedUserId!==''" @close="viewedUserId=''"></UserViewModal>
<!-- User list -->
<div class="mt-4 row">
<div class="col-12 col-lg-5">
<h1>{{ $t('users.headline') }}</h1>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a class="btn btn-primary ms-2" href="#" :title="$t('users.button-add-user')" @click.prevent="editUserId='#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
</a>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="users.Count===0">
<h4>{{ $t('users.no-user.headline') }}</h4>
<p>{{ $t('users.no-user.abstract') }}</p>
</div>
<table v-if="users.Count!==0" id="userTable" class="table table-sm">
<thead>
<tr>
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('users.table-heading.id') }}</th>
<th scope="col">{{ $t('users.table-heading.email') }}</th>
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
<tbody>
<tr v-for="user in users.FilteredAndPaged" :key="user.Identifier">
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="user.Disabled" class="text-danger" :title="$t('users.user-disabled') + ' ' + user.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
<span v-if="user.Locked" class="text-danger" :title="$t('users.user-locked') + ' ' + user.LockedReason"><i class="fas fa-lock"></i></span>
</td>
<td>{{user.Identifier}}</td>
<td>{{user.Email}}</td>
<td>{{user.Firstname}}</td>
<td>{{user.Lastname}}</td>
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
<td class="text-center">{{user.PeerCount}}</td>
<td class="text-center">
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>
<span v-else><i class="fa fa-circle-xmark" :title="$t('users.no-admin')"></i></span>
</td>
<td class="text-center">
<a href="#" :title="$t('users.button-show-user')" @click.prevent="viewedUserId=user.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('users.button-edit-user')" @click.prevent="editUserId=user.Identifier"><i class="fas fa-cog me-2"></i></a>
</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:users.pageOffset===0}" class="page-item">
<a class="page-link" @click="users.previousPage">&laquo;</a>
</li>
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

34
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,34 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: '../internal/app/api/core/frontend-dist',
emptyOutDir: true
},
// local dev api (proxy to avoid cors problems)
server: {
port: 5000,
proxy: {
"/api/v0": {
target: "http://localhost:8888",
changeOrigin: true,
secure: false,
withCredentials: true,
headers: {
"x-wg-dev": true,
},
rewrite: (path) => path,
},
},
},
})