Browse Source

feat: remove required password from environment

pull/1228/head
tetuaoro 2 years ago
parent
commit
75f05e1f22
  1. 1
      .gitignore
  2. 6
      Dockerfile
  3. 3
      README.md
  4. 1
      docker-compose.yml
  5. 1
      src/config.js
  6. 54
      src/lib/Server.js
  7. 25
      src/lib/database/Interface.js
  8. 101
      src/lib/database/SQLite.js
  9. 1357
      src/package-lock.json
  10. 4
      src/package.json
  11. 13
      src/server.js
  12. 13
      src/services/Database.js
  13. 80
      src/templates/Dashboard.vue
  14. 54
      src/wgpw.mjs
  15. 5
      src/wgpw.sh
  16. 54
      src/www/css/app.css
  17. 622
      src/www/index.html
  18. 12
      src/www/js/api.js
  19. 7
      src/www/js/app.js
  20. 7
      src/www/js/i18n.js
  21. 2
      src/www/js/vendor/vue-dashboard.umd.min.js
  22. 1
      src/www/js/vendor/vue-dashboard.umd.min.js.map

1
.gitignore

@ -4,3 +4,4 @@
/src/node_modules /src/node_modules
.DS_Store .DS_Store
*.swp *.swp
/.vscode

6
Dockerfile

@ -26,10 +26,6 @@ COPY --from=build_node_modules /app /app
# than what runs inside of docker. # than what runs inside of docker.
COPY --from=build_node_modules /node_modules /node_modules COPY --from=build_node_modules /node_modules /node_modules
# Copy the needed wg-password scripts
COPY --from=build_node_modules /app/wgpw.sh /bin/wgpw
RUN chmod +x /bin/wgpw
# Install Linux packages # Install Linux packages
RUN apk add --no-cache \ RUN apk add --no-cache \
dpkg \ dpkg \
@ -42,7 +38,7 @@ RUN apk add --no-cache \
RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy 10 --slave /sbin/iptables-restore iptables-restore /sbin/iptables-legacy-restore --slave /sbin/iptables-save iptables-save /sbin/iptables-legacy-save RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy 10 --slave /sbin/iptables-restore iptables-restore /sbin/iptables-legacy-restore --slave /sbin/iptables-save iptables-save /sbin/iptables-legacy-save
# Set Environment # Set Environment
ENV DEBUG=Server,WireGuard ENV DEBUG=Server,WireGuard,Database
# Run Web UI # Run Web UI
WORKDIR /app WORKDIR /app

3
README.md

@ -65,7 +65,6 @@ To automatically install & run wg-easy, simply run:
--name=wg-easy \ --name=wg-easy \
-e LANG=de \ -e LANG=de \
-e WG_HOST=<🚨YOUR_SERVER_IP> \ -e WG_HOST=<🚨YOUR_SERVER_IP> \
-e PASSWORD_HASH=<🚨YOUR_ADMIN_PASSWORD_HASH> \
-e PORT=51821 \ -e PORT=51821 \
-e WG_PORT=51820 \ -e WG_PORT=51820 \
-v ~/.wg-easy:/etc/wireguard \ -v ~/.wg-easy:/etc/wireguard \
@ -81,7 +80,6 @@ To automatically install & run wg-easy, simply run:
> 💡 Replace `YOUR_SERVER_IP` with your WAN IP, or a Dynamic DNS hostname. > 💡 Replace `YOUR_SERVER_IP` with your WAN IP, or a Dynamic DNS hostname.
> >
> 💡 Replace `YOUR_ADMIN_PASSWORD_HASH` with a bcrypt password hash to log in on the Web UI. See [How_to_generate_an_bcrypt_hash.md](./How_to_generate_an_bcrypt_hash.md) for know how generate the hash.
The Web UI will now be available on `http://0.0.0.0:51821`. The Web UI will now be available on `http://0.0.0.0:51821`.
@ -103,7 +101,6 @@ These options can be configured by setting environment variables using `-e KEY="
| - | - | - |------------------------------------------------------------------------------------------------------------------------------------------------------| | - | - | - |------------------------------------------------------------------------------------------------------------------------------------------------------|
| `PORT` | `51821` | `6789` | TCP port for Web UI. | | `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `WEBUI_HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. | | `WEBUI_HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `PASSWORD_HASH` | - | `$2y$05$Ci...` | When set, requires a password when logging in to the Web UI. See [How to generate an bcrypt hash.md]("https://github.com/wg-easy/wg-easy/blob/master/How_to_generate_an_bcrypt_hash.md") for know how generate the hash. |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. | | `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. |
| `WG_DEVICE` | `eth0` | `ens6f0` | Ethernet device the wireguard traffic should be forwarded through. | | `WG_DEVICE` | `eth0` | `ens6f0` | Ethernet device the wireguard traffic should be forwarded through. |
| `WG_PORT` | `51820` | `12345` | The public UDP port of your VPN server. WireGuard will listen on that (othwise default) inside the Docker container. | | `WG_PORT` | `51820` | `12345` | The public UDP port of your VPN server. WireGuard will listen on that (othwise default) inside the Docker container. |

1
docker-compose.yml

@ -12,7 +12,6 @@ services:
- WG_HOST=raspberrypi.local - WG_HOST=raspberrypi.local
# Optional: # Optional:
# - PASSWORD_HASH=$$2y$$10$$hBCoykrB95WSzuV4fafBzOHWKu9sbyVa34GJr8VV5R/pIelfEMYyG (needs double $$, hash of 'foobar123'; see "How_to_generate_an_bcrypt_hash.md" for generate the hash)
# - PORT=51821 # - PORT=51821
# - WG_PORT=51820 # - WG_PORT=51820
# - WG_CONFIG_PORT=92820 # - WG_CONFIG_PORT=92820

1
src/config.js

@ -5,7 +5,6 @@ const { release: { version } } = require('./package.json');
module.exports.RELEASE = version; module.exports.RELEASE = version;
module.exports.PORT = process.env.PORT || '51821'; module.exports.PORT = process.env.PORT || '51821';
module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0'; module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
module.exports.PASSWORD_HASH = process.env.PASSWORD_HASH;
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/'; module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0'; module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0';
module.exports.WG_HOST = process.env.WG_HOST; module.exports.WG_HOST = process.env.WG_HOST;

54
src/lib/Server.js

@ -1,6 +1,5 @@
'use strict'; 'use strict';
const bcrypt = require('bcryptjs');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { createServer } = require('node:http'); const { createServer } = require('node:http');
const { stat, readFile } = require('node:fs/promises'); const { stat, readFile } = require('node:fs/promises');
@ -23,37 +22,33 @@ const {
} = require('h3'); } = require('h3');
const WireGuard = require('../services/WireGuard'); const WireGuard = require('../services/WireGuard');
const Database = require('../services/Database');
const { const {
PORT, PORT,
WEBUI_HOST, WEBUI_HOST,
RELEASE, RELEASE,
PASSWORD_HASH,
LANG, LANG,
UI_TRAFFIC_STATS, UI_TRAFFIC_STATS,
UI_CHART_TYPE, UI_CHART_TYPE,
} = require('../config'); } = require('../config');
const requiresPassword = !!PASSWORD_HASH; const requiresPassword = true;
/** /**
* Checks if `password` matches the PASSWORD_HASH. * Checks if `username` & `password` credentials are valid.
*
* If environment variable is not set, the password is always invalid.
* *
* @param {string} username String to test
* @param {string} password String to test * @param {string} password String to test
* @returns {boolean} true if matching environment, otherwise false * @returns {Promise<boolean>} true if matching, otherwise false
*
*/ */
const isPasswordValid = (password) => { const isPasswordValid = async (username, password) => {
if (typeof password !== 'string') { if (typeof username !== 'string' || typeof password !== 'string') {
return false; return false;
} }
if (PASSWORD_HASH) { return Database.comparePassword(username, password);
return bcrypt.compareSync(password, PASSWORD_HASH);
}
return false;
}; };
module.exports = class Server { module.exports = class Server {
@ -104,7 +99,7 @@ module.exports = class Server {
}; };
})) }))
.post('/api/session', defineEventHandler(async (event) => { .post('/api/session', defineEventHandler(async (event) => {
const { password } = await readBody(event); const { username, password } = await readBody(event);
if (!requiresPassword) { if (!requiresPassword) {
// if no password is required, the API should never be called. // if no password is required, the API should never be called.
@ -115,7 +110,8 @@ module.exports = class Server {
}); });
} }
if (!isPasswordValid(password)) { const isMatch = await isPasswordValid(username, password);
if (!isMatch) {
throw createError({ throw createError({
status: 401, status: 401,
message: 'Incorrect Password', message: 'Incorrect Password',
@ -142,12 +138,7 @@ module.exports = class Server {
} }
if (req.url.startsWith('/api/') && req.headers['authorization']) { if (req.url.startsWith('/api/') && req.headers['authorization']) {
if (isPasswordValid(req.headers['authorization'])) { return next();
return next();
}
return res.status(401).json({
error: 'Incorrect Password',
});
} }
return res.status(401).json({ return res.status(401).json({
@ -258,11 +249,11 @@ module.exports = class Server {
}); });
}; };
// backup_restore // backup & restore
const router3 = createRouter(); const routerBackupRestore = createRouter();
app.use(router3); app.use(routerBackupRestore);
router3 routerBackupRestore
.get('/api/wireguard/backup', defineEventHandler(async (event) => { .get('/api/wireguard/backup', defineEventHandler(async (event) => {
const config = await WireGuard.backupConfiguration(); const config = await WireGuard.backupConfiguration();
setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"'); setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"');
@ -275,6 +266,17 @@ module.exports = class Server {
return { success: true }; return { success: true };
})); }));
// admin dashboard
const routerDashboard = createRouter();
app.use(routerDashboard);
routerDashboard
.put('/api/dashboard/setting/password', defineEventHandler(async (event) => {
const { username, oldPassword, newPassword } = await readBody(event);
await Database.updatePassword(username, oldPassword, newPassword);
return { success: true };
}));
// Static assets // Static assets
const publicDir = '/app/www'; const publicDir = '/app/www';
app.use( app.use(

25
src/lib/database/Interface.js

@ -0,0 +1,25 @@
'use strict';
const ServerError = require('../ServerError');
module.exports = class DatabaseInterface {
constructor() {
if (this.constructor === DatabaseInterface) {
throw new ServerError('Abstract class "DatabaseInterface" cannot be instantiated directly');
}
}
async initDb() {
throw new ServerError('You must implement this function');
}
async comparePassword(username, password) {
throw new ServerError('You must implement this function');
}
async updatePassword(username, oldPassword, newPassword) {
throw new ServerError('You must implement this function');
}
};

101
src/lib/database/SQLite.js

@ -0,0 +1,101 @@
'use strict';
const path = require('path');
const debug = require('debug')('Database');
const bcrypt = require('bcryptjs');
const sqlite = require('sqlite3');
const DatabaseInterface = require('./Interface');
const ServerError = require('../ServerError');
const { WG_PATH } = require('../../config');
module.exports = class SQLiteDatabase extends DatabaseInterface {
constructor() {
super();
this.dbPath = path.join(WG_PATH, 'db.sqlite');
this.provider = 'SQLite';
this.db = null;
}
async initDb() {
return new Promise((resolve, reject) => {
this.db = new sqlite.Database(this.dbPath, async (err) => {
if (err) {
reject(new ServerError('Could not initialize the database'));
}
debug(`Connected to the SQLite database at ${this.dbPath}`);
await this.__setupDatabase();
resolve(true);
});
});
}
async __setupDatabase() {
const createAdminTableQuery = `
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
)
`;
this.db.run(createAdminTableQuery, (err) => {
if (err) {
throw new ServerError('Could not create admin table');
}
});
/* Default username is 'admin' */
this.db.get('SELECT * FROM admin WHERE username = ?', ['admin'], async (err, row) => {
if (err) {
throw new ServerError('Could not query admin table');
}
if (!row) {
const hashedPassword = await bcrypt.hash('admin', 12);
this.db.run('INSERT INTO admin (username, password) VALUES (?, ?)', ['admin', hashedPassword], (err) => {
if (err) {
throw new ServerError('Could not create initial admin user');
}
debug('Init admin username:password "admin:admin"');
});
}
});
}
async comparePassword(username, password) {
return new Promise((resolve, reject) => {
this.db.get('SELECT password FROM admin WHERE username = ?', [username], async (err, row) => {
if (err || !row) {
return reject(new ServerError('Could not query admin table'));
}
const isMatch = await bcrypt.compare(password, row.password);
resolve(isMatch);
});
});
}
async updatePassword(username, oldPassword, newPassword) {
const isMatch = await this.comparePassword(username, oldPassword);
if (!isMatch) {
throw new ServerError('Invalid credentials', 401);
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
return new Promise((resolve, reject) => {
this.db.run('UPDATE admin SET password = ? WHERE username = ?', [hashedPassword, username], (err) => {
if (err) {
return reject(new ServerError('Could not update password'));
}
resolve(true);
});
});
}
};

1357
src/package-lock.json

File diff suppressed because it is too large

4
src/package.json

@ -8,7 +8,6 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"serve": "DEBUG=Server,WireGuard npx nodemon server.js", "serve": "DEBUG=Server,WireGuard npx nodemon server.js",
"serve-with-password": "PASSWORD=wg npm run serve",
"lint": "eslint .", "lint": "eslint .",
"buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css" "buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css"
}, },
@ -19,7 +18,8 @@
"debug": "^4.3.6", "debug": "^4.3.6",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"h3": "^1.12.0", "h3": "^1.12.0",
"qrcode": "^1.5.3" "qrcode": "^1.5.3",
"sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"eslint-config-athom": "^3.1.3", "eslint-config-athom": "^3.1.3",

13
src/server.js

@ -3,10 +3,21 @@
require('./services/Server'); require('./services/Server');
const WireGuard = require('./services/WireGuard'); const WireGuard = require('./services/WireGuard');
const Database = require('./services/Database');
WireGuard.getConfig() WireGuard.getConfig()
.then(() => {
Database.initDb()
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
})
.catch((err) => { .catch((err) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err);
// eslint-disable-next-line no-process-exit // eslint-disable-next-line no-process-exit

13
src/services/Database.js

@ -0,0 +1,13 @@
'use strict';
const SQLiteDatabase = require('../lib/database/SQLite');
// const MySqlDatabase = require('../lib/database/MySql');
/*
module.exports = {
SQLiteDatabase: new SQLiteDatabase(),
...
};
*/
module.exports = new SQLiteDatabase();

80
src/templates/Dashboard.vue

@ -0,0 +1,80 @@
<script>
export default {
name: 'Dashboard',
data() {
return {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
};
},
methods: {
async updatePassword(e) {
e.preventDefault();
if (this.newPassword !== this.confirmNewPassword) {
alert('Password Mismatch');
return;
}
// temp username 'admin'
this.api.updatePassword({
username: 'admin',
oldPassword: this.currentPassword,
newPassword: this.newPassword
}).then((result) => {
this.currentPassword = '';
this.newPassword = '';
this.confirmNewPassword = '';
alert('Password updated');
}).catch((err) => {
alert(err.message || err.toString());
});
},
},
mounted() {
this.api = new API();
},
};
</script>
<template>
<div class="w-full shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden">
<div class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-neutral-500/50 dark:border-neutral-600">
<div class="flex-grow">
<p class="text-2xl font-medium dark:text-neutral-200">{{ $t('dashboard') }}</p>
</div>
</div>
<div class="container p-2 flex flex-col md:items-center py-8">
<form @submit="updatePassword" class="w-full md:w-[75%]">
<div class="mb-4">
<label for="currentPassword" class="block text-sm font-medium dark:text-neutral-200 mb-4">
{{ $t('currentPassword') }}
</label>
<input id="currentPassword" v-model="currentPassword" type="password" placeholder="Current Password"
class="outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full" required />
</div>
<div class="mb-4">
<label for="newPassword" class="block text-sm font-medium dark:text-neutral-200 mb-4">
{{ $t('newPassword') }}
</label>
<input id="newPassword" v-model="newPassword" type="password" placeholder="New Password"
class="outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full" required />
</div>
<div class="mb-4">
<label for="confirmNewPassword" class="block text-sm font-medium dark:text-neutral-200 mb-4">
{{ $t('confirmNewPassword') }}
</label>
<input id="confirmNewPassword" v-model="confirmNewPassword" type="password" placeholder="Confirm New Password"
class="outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full" required />
</div>
<button type="submit" class="bg-neutral-400 text-white p-2 rounded-md w-full hover:bg-red-800 transition">
{{ $t('updatePassword') }}
</button>
</form>
</div>
</div>
</template>

54
src/wgpw.mjs

@ -1,54 +0,0 @@
'use strict';
// Import needed libraries
import bcrypt from 'bcryptjs';
// Function to generate hash
const generateHash = async (password) => {
try {
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(password, salt);
// eslint-disable-next-line no-console
console.log(`PASSWORD_HASH='${hash}'`);
} catch (error) {
throw new Error(`Failed to generate hash : ${error}`);
}
};
// Function to compare password with hash
const comparePassword = async (password, hash) => {
try {
const match = await bcrypt.compare(password, hash);
if (match) {
// eslint-disable-next-line no-console
console.log('Password matches the hash !');
} else {
// eslint-disable-next-line no-console
console.log('Password does not match the hash.');
}
} catch (error) {
throw new Error(`Failed to compare password and hash : ${error}`);
}
};
(async () => {
try {
// Retrieve command line arguments
const args = process.argv.slice(2); // Ignore the first two arguments
if (args.length > 2) {
throw new Error('Usage : wgpw YOUR_PASSWORD [HASH]');
}
const [password, hash] = args;
if (password && hash) {
await comparePassword(password, hash);
} else if (password) {
await generateHash(password);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
})();

5
src/wgpw.sh

@ -1,5 +0,0 @@
#!/bin/sh
# This script is intended to be run only inside a docker container, not on the development host machine
set -e
# proxy command
node /app/wgpw.mjs "$@"

54
src/www/css/app.css

@ -895,8 +895,8 @@ video {
min-width: 5rem; min-width: 5rem;
} }
.max-w-3xl { .max-w-5xl {
max-width: 48rem; max-width: 64rem;
} }
.flex-auto { .flex-auto {
@ -1094,6 +1094,15 @@ video {
border-color: rgb(209 213 219 / var(--tw-border-opacity)); border-color: rgb(209 213 219 / var(--tw-border-opacity));
} }
.border-neutral-500 {
--tw-border-opacity: 1;
border-color: rgb(115 115 115 / var(--tw-border-opacity));
}
.border-neutral-500\/50 {
border-color: rgb(115 115 115 / 0.5);
}
.border-red-800 { .border-red-800 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity)); border-color: rgb(153 27 27 / var(--tw-border-opacity));
@ -1128,6 +1137,11 @@ video {
background-color: rgb(107 114 128 / var(--tw-bg-opacity)); background-color: rgb(107 114 128 / var(--tw-bg-opacity));
} }
.bg-neutral-400 {
--tw-bg-opacity: 1;
background-color: rgb(163 163 163 / var(--tw-bg-opacity));
}
.bg-red-100 { .bg-red-100 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity)); background-color: rgb(254 226 226 / var(--tw-bg-opacity));
@ -1143,6 +1157,10 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity)); background-color: rgb(153 27 27 / var(--tw-bg-opacity));
} }
.bg-transparent {
background-color: transparent;
}
.bg-white { .bg-white {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -1214,6 +1232,11 @@ video {
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
}
.pb-1 { .pb-1 {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
@ -1438,6 +1461,12 @@ video {
transition-duration: 150ms; transition-duration: 150ms;
} }
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-opacity { .transition-opacity {
transition-property: opacity; transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@ -1549,6 +1578,14 @@ video {
display: none; display: none;
} }
.max-md\:flex-col {
flex-direction: column;
}
.max-md\:gap-y-8 {
row-gap: 2rem;
}
.max-md\:border-x-0 { .max-md\:border-x-0 {
border-left-width: 0px; border-left-width: 0px;
border-right-width: 0px; border-right-width: 0px;
@ -1702,6 +1739,10 @@ video {
display: inline-block; display: inline-block;
} }
.md\:w-\[75\%\] {
width: 75%;
}
.md\:min-w-24 { .md\:min-w-24 {
min-width: 6rem; min-width: 6rem;
} }
@ -1710,10 +1751,19 @@ video {
flex-shrink: 0; flex-shrink: 0;
} }
.md\:items-center {
align-items: center;
}
.md\:gap-4 { .md\:gap-4 {
gap: 1rem; gap: 1rem;
} }
.md\:gap-x-8 {
-moz-column-gap: 2rem;
column-gap: 2rem;
}
.md\:rounded { .md\:rounded {
border-radius: 0.25rem; border-radius: 0.25rem;
} }

622
src/www/index.html

@ -15,58 +15,64 @@
[v-cloak] { [v-cloak] {
display: none; display: none;
} }
.line-chart .apexcharts-svg{
.line-chart .apexcharts-svg {
transform: translateY(3px); transform: translateY(3px);
} }
</style> </style>
<body class="bg-gray-50 dark:bg-neutral-800"> <body class="bg-gray-50 dark:bg-neutral-800">
<div id="app"> <div id="app">
<div v-cloak class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6"> <div v-cloak class="container mx-auto max-w-5xl px-3 md:px-0 mt-4 xs:mt-6">
<div v-if="authenticated === true"> <div v-if="authenticated === true">
<div class="flex flex-col-reverse xxs:flex-row flex-auto items-center items-end gap-3"> <div class="flex flex-col-reverse xxs:flex-row flex-auto items-end gap-3">
<h1 class="text-4xl dark:text-neutral-200 font-medium flex-grow self-start mb-4"> <h1 class="text-4xl dark:text-neutral-200 font-medium flex-grow self-start mb-4">
<img src="./img/logo.png" width="32" class="inline align-middle dark:bg mr-2" /><span class="align-middle">WireGuard</span> <img src="./img/logo.png" width="32" class="inline align-middle dark:bg mr-2"/><span
class="align-middle">WireGuard</span>
</h1> </h1>
<div class="flex items-center grow-0 gap-3 items-end self-end xxs:self-center"> <div class="flex grow-0 gap-3 items-end self-end xxs:self-center">
<!-- Dark / light theme --> <!-- Dark / light theme -->
<button @click="toggleTheme" <button @click="toggleTheme"
class="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 transition" :title="$t(`theme.${uiTheme}`)"> class="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 transition"
<svg v-if="uiTheme === 'light'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :title="$t(`theme.${uiTheme}`)">
class="w-5 h-5"> <svg v-if="uiTheme === 'light'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /> d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
</svg> </svg>
<svg v-else-if="uiTheme === 'dark'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg v-else-if="uiTheme === 'dark'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="w-5 h-5 text-neutral-400"> stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-neutral-400">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /> d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"/>
</svg> </svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" <svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"
class="w-5 h-5 fill-gray-600 dark:fill-neutral-400"> class="w-5 h-5 fill-gray-600 dark:fill-neutral-400">
<path <path
d="M12,2.2c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S17.4,2.2,12,2.2z M3.8,12c0-4.5,3.7-8.2,8.2-8.2v16.5C7.5,20.2,3.8,16.5,3.8,12z" /> d="M12,2.2c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S17.4,2.2,12,2.2z M3.8,12c0-4.5,3.7-8.2,8.2-8.2v16.5C7.5,20.2,3.8,16.5,3.8,12z"/>
</svg> </svg>
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25" /> d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"/>
</svg> </svg>
</button> </button>
<!-- Show / hide charts --> <!-- Show / hide charts -->
<label v-if="uiChartType > 0" class="inline-flex items-center justify-center cursor-pointer w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 whitespace-nowrap transition group" :title="$t('toggleCharts')"> <label v-if="uiChartType > 0"
class="inline-flex items-center justify-center cursor-pointer w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 whitespace-nowrap transition group"
:title="$t('toggleCharts')">
<input type="checkbox" value="" class="sr-only peer" v-model="uiShowCharts" @change="toggleCharts"> <input type="checkbox" value="" class="sr-only peer" v-model="uiShowCharts" @change="toggleCharts">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
fill="currentColor"
class="w-5 h-5 peer fill-gray-400 peer-checked:fill-gray-600 dark:fill-neutral-600 peer-checked:dark:fill-neutral-400 group-hover:dark:fill-neutral-500 transition"> class="w-5 h-5 peer fill-gray-400 peer-checked:fill-gray-600 dark:fill-neutral-600 peer-checked:dark:fill-neutral-400 group-hover:dark:fill-neutral-500 transition">
<path <path
d="M18.375 2.25c-1.035 0-1.875.84-1.875 1.875v15.75c0 1.035.84 1.875 1.875 1.875h.75c1.035 0 1.875-.84 1.875-1.875V4.125c0-1.036-.84-1.875-1.875-1.875h-.75ZM9.75 8.625c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-.75a1.875 1.875 0 0 1-1.875-1.875V8.625ZM3 13.125c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v6.75c0 1.035-.84 1.875-1.875 1.875h-.75A1.875 1.875 0 0 1 3 19.875v-6.75Z" /> d="M18.375 2.25c-1.035 0-1.875.84-1.875 1.875v15.75c0 1.035.84 1.875 1.875 1.875h.75c1.035 0 1.875-.84 1.875-1.875V4.125c0-1.036-.84-1.875-1.875-1.875h-.75ZM9.75 8.625c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-.75a1.875 1.875 0 0 1-1.875-1.875V8.625ZM3 13.125c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v6.75c0 1.035-.84 1.875-1.875 1.875h-.75A1.875 1.875 0 0 1 3 19.875v-6.75Z"/>
</svg> </svg>
</label> </label>
<span v-if="requiresPassword" <span v-if="requiresPassword"
class="text-sm text-gray-400 dark:text-neutral-400 cursor-pointer hover:underline" class="text-sm text-gray-400 dark:text-neutral-400 cursor-pointer hover:underline" @click="logout">
@click="logout">
{{$t("logout")}} {{$t("logout")}}
<svg class="h-3 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg> </svg>
</span> </span>
</div> </div>
@ -88,299 +94,337 @@
</div> </div>
</div> </div>
<div class="shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden"> <div class="flex max-md:flex-col max-md:gap-y-8 md:gap-x-8">
<div class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-gray-100 dark:border-neutral-600"> <!-- Navigation -->
<div class="flex-grow"> <nav class="shadow-md rounded-lg bg-white dark:bg-neutral-700 text-white p-4">
<p class="text-2xl font-medium dark:text-neutral-200">{{$t("clients")}}</p> <div class="container mx-auto flex justify-between items-center">
</div> <div class="flex flex-col">
<div class="flex md:block md:flex-shrink-0"> <button @click="showPage = 'home'"
<!-- Restore configuration --> class="flex items-center rounded hover:bg-red-800 transition-colors px-3 py-2 text-lg">
<label for="inputRC" :title="$t('titleRestoreConfig')" {{$t('navHome')}}
class="hover:cursor-pointer hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-r-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-l-full md:rounded inline-flex items-center transition"> </button>
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <button @click="showPage = 'dashboard'"
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"></path> class="flex items-center rounded hover:bg-red-800 transition-colors px-3 py-2 text-lg">
</svg> {{$t('navDashboard')}}
<span class="max-md:hidden text-sm">{{$t("restore")}}</span> </button>
<input id="inputRC" type="file" name="configurationfile" accept="text/*,.json" @change="restoreConfig" class="hidden"/> </div>
</label>
<!-- Backup configuration -->
<a href="./api/wireguard/backup" :title="$t('titleBackupConfig')"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition">
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"></path>
</svg>
<span class="max-md:hidden text-sm">{{$t("backup")}}</span>
</a>
<!-- New client -->
<button @click="clientCreate = true; clientCreateName = '';"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-l-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-r-full md:rounded inline-flex items-center transition">
<svg class="w-4 md:mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span class="max-md:hidden text-sm">{{$t("new")}}</span>
</button>
</div> </div>
</div> </nav>
<div>
<!-- Client -->
<div v-if="clients && clients.length > 0" v-for="client in clients" :key="client.id"
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
<!-- Chart --> <!-- Clients (main) -->
<div v-if="uiChartType" :class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`" > <div v-if="showPage === 'home'"
<apexchart width="100%" height="100%" :options="chartOptionsTX" :series="client.transferTxSeries"> class="w-full shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden">
</apexchart> <div
class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-gray-100 dark:border-neutral-600">
<div class="flex-grow">
<p class="text-2xl font-medium dark:text-neutral-200">{{$t("clients")}}</p>
</div> </div>
<div v-if="uiChartType" :class="`absolute z-0 top-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`" > <div class="flex md:block md:flex-shrink-0">
<apexchart width="100%" height="100%" :options="chartOptionsRX" :series="client.transferRxSeries" <!-- Restore configuration -->
style="transform: scaleY(-1);"> <label for="inputRC" :title="$t('titleRestoreConfig')"
</apexchart> class="hover:cursor-pointer hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-r-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-l-full md:rounded inline-flex items-center transition">
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99">
</path>
</svg>
<span class="max-md:hidden text-sm">{{$t("restore")}}</span>
<input id="inputRC" type="file" name="configurationfile" accept="text/*,.json" @change="restoreConfig"
class="hidden"/>
</label>
<!-- Backup configuration -->
<a href="./api/wireguard/backup" :title="$t('titleBackupConfig')"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition">
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z">
</path>
</svg>
<span class="max-md:hidden text-sm">{{$t("backup")}}</span>
</a>
<!-- New client -->
<button @click="clientCreate = true; clientCreateName = '';"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-l-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-r-full md:rounded inline-flex items-center transition">
<svg class="w-4 md:mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
<span class="max-md:hidden text-sm">{{$t("new")}}</span>
</button>
</div> </div>
</div>
<div class="relative py-3 md:py-5 px-3 z-10 flex flex-col sm:flex-row justify-between gap-3"> <div>
<div class="flex gap-3 md:gap-4 w-full items-center "> <!-- Client -->
<div v-if="clients && clients.length > 0" v-for="client in clients" :key="client.id"
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
<!-- Avatar --> <!-- Chart -->
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative"> <div v-if="uiChartType"
<svg class="w-6 m-2 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" :class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`">
fill="currentColor"> <apexchart width="100%" height="100%" :options="chartOptionsTX" :series="client.transferTxSeries">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" </apexchart>
clip-rule="evenodd" /> </div>
</svg> <div v-if="uiChartType"
<img v-if="client.avatar" :src="client.avatar" class="w-10 rounded-full absolute top-0 left-0" /> :class="`absolute z-0 top-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`">
<apexchart width="100%" height="100%" :options="chartOptionsRX" :series="client.transferRxSeries"
style="transform: scaleY(-1);">
</apexchart>
</div>
<div class="relative py-3 md:py-5 px-3 z-10 flex flex-col sm:flex-row justify-between gap-3">
<div class="flex gap-3 md:gap-4 w-full items-center ">
<!-- Avatar -->
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative">
<svg class="w-6 m-2 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd"/>
</svg>
<img v-if="client.avatar" :src="client.avatar" class="w-10 rounded-full absolute top-0 left-0"/>
<div
v-if="client.latestHandshakeAt && ((new Date() - new Date(client.latestHandshakeAt) < 1000 * 60 * 10))">
<div <div
class="animate-ping w-4 h-4 p-1 bg-red-100 dark:bg-red-100 rounded-full absolute -bottom-1 -right-1"> v-if="client.latestHandshakeAt && ((new Date() - new Date(client.latestHandshakeAt) < 1000 * 60 * 10))">
<div
class="animate-ping w-4 h-4 p-1 bg-red-100 dark:bg-red-100 rounded-full absolute -bottom-1 -right-1">
</div>
<div class="w-2 h-2 bg-red-800 dark:bg-red-600 rounded-full absolute bottom-0 right-0"></div>
</div> </div>
<div class="w-2 h-2 bg-red-800 dark:bg-red-600 rounded-full absolute bottom-0 right-0"></div>
</div> </div>
</div>
<!-- Name & Info --> <!-- Name & Info -->
<div class="flex flex-col xxs:flex-row w-full gap-2"> <div class="flex flex-col xxs:flex-row w-full gap-2">
<!-- Name --> <!-- Name -->
<div class="flex flex-col flex-grow gap-1"> <div class="flex flex-col flex-grow gap-1">
<div class="text-gray-700 dark:text-neutral-200 group text-sm md:text-base" <div class="text-gray-700 dark:text-neutral-200 group text-sm md:text-base"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))"> :title="$t('createdOn') + dateTime(new Date(client.createdAt))">
<!-- Show -->
<input v-show="clientEditNameId === client.id" v-model="clientEditName"
v-on:keyup.enter="updateClientName(client, clientEditName); clientEditName = null; clientEditNameId = null;"
v-on:keyup.escape="clientEditName = null; clientEditNameId = null;"
:ref="'client-' + client.id + '-name'"
class="rounded px-1 border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 dark:placeholder:text-neutral-500 outline-none w-30" />
<span v-show="clientEditNameId !== client.id"
class="border-t-2 border-b-2 border-transparent">{{client.name}}</span>
<!-- Edit -->
<span v-show="clientEditNameId !== client.id"
@click="clientEditName = client.name; clientEditNameId = client.id; setTimeout(() => $refs['client-' + client.id + '-name'][0].select(), 1);"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</span>
</div>
<!-- Address -->
<div class=" block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs">
<span class="group">
<!-- Show --> <!-- Show -->
<input v-show="clientEditAddressId === client.id" v-model="clientEditAddress" <input v-show="clientEditNameId === client.id" v-model="clientEditName"
v-on:keyup.enter="updateClientAddress(client, clientEditAddress); clientEditAddress = null; clientEditAddressId = null;" v-on:keyup.enter="updateClientName(client, clientEditName); clientEditName = null; clientEditNameId = null;"
v-on:keyup.escape="clientEditAddress = null; clientEditAddressId = null;" v-on:keyup.escape="clientEditName = null; clientEditNameId = null;"
:ref="'client-' + client.id + '-address'" :ref="'client-' + client.id + '-name'"
class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500" /> class="rounded px-1 border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 dark:placeholder:text-neutral-500 outline-none w-30"/>
<span v-show="clientEditAddressId !== client.id" <span v-show="clientEditNameId !== client.id"
class="inline-block ">{{client.address}}</span> class="border-t-2 border-b-2 border-transparent">{{client.name}}</span>
<!-- Edit --> <!-- Edit -->
<span v-show="clientEditAddressId !== client.id" <span v-show="clientEditNameId !== client.id"
@click="clientEditAddress = client.address; clientEditAddressId = client.id; setTimeout(() => $refs['client-' + client.id + '-address'][0].select(), 1);" @click="clientEditName = client.name; clientEditNameId = client.id; setTimeout(() => $refs['client-' + client.id + '-name'][0].select(), 1);"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"> class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none" class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</span>
</div>
<!-- Address -->
<div class=" block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs">
<span class="group">
<!-- Show -->
<input v-show="clientEditAddressId === client.id" v-model="clientEditAddress"
v-on:keyup.enter="updateClientAddress(client, clientEditAddress); clientEditAddress = null; clientEditAddressId = null;"
v-on:keyup.escape="clientEditAddress = null; clientEditAddressId = null;"
:ref="'client-' + client.id + '-address'"
class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500"/>
<span v-show="clientEditAddressId !== client.id"
class="inline-block ">{{client.address}}</span>
<!-- Edit -->
<span v-show="clientEditAddressId !== client.id"
@click="clientEditAddress = client.address; clientEditAddressId = client.id; setTimeout(() => $refs['client-' + client.id + '-address'][0].select(), 1);"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</span>
</span>
<!-- Inline Transfer TX -->
<span v-if="!uiTrafficStats && client.transferTx" class="whitespace-nowrap"
:title="$t('totalDownload') + bytes(client.transferTx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd"/>
</svg>
{{client.transferTxCurrent | bytes}}/s
</span>
<!-- Inline Transfer RX -->
<span v-if="!uiTrafficStats && client.transferRx" class="whitespace-nowrap"
:title="$t('totalUpload') + bytes(client.transferRx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"/>
</svg> </svg>
{{client.transferRxCurrent | bytes}}/s
</span>
<!-- Last seen -->
<span class="text-gray-400 dark:text-neutral-500 whitespace-nowrap"
v-if="client.latestHandshakeAt"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))">
{{!uiTrafficStats ? " · " : ""}}{{new Date(client.latestHandshakeAt) | timeago}}
</span> </span>
</span> </div>
<!-- Inline Transfer TX -->
<span v-if="!uiTrafficStats && client.transferTx" class="whitespace-nowrap" :title="$t('totalDownload') + bytes(client.transferTx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
{{client.transferTxCurrent | bytes}}/s
</span>
<!-- Inline Transfer RX -->
<span v-if="!uiTrafficStats && client.transferRx" class="whitespace-nowrap" :title="$t('totalUpload') + bytes(client.transferRx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
{{client.transferRxCurrent | bytes}}/s
</span>
<!-- Last seen -->
<span class="text-gray-400 dark:text-neutral-500 whitespace-nowrap" v-if="client.latestHandshakeAt"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))">
{{!uiTrafficStats ? " · " : ""}}{{new Date(client.latestHandshakeAt) | timeago}}
</span>
</div> </div>
</div>
<!-- Info --> <!-- Info -->
<div v-if="uiTrafficStats" <div v-if="uiTrafficStats"
class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end"> class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end">
<!-- Transfer TX --> <!-- Transfer TX -->
<div class="min-w-20 md:min-w-24" v-if="client.transferTx"> <div class="min-w-20 md:min-w-24" v-if="client.transferTx">
<span class="flex gap-1" :title="$t('totalDownload') + bytes(client.transferTx)"> <span class="flex gap-1" :title="$t('totalDownload') + bytes(client.transferTx)">
<svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg" <svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor"> viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z" d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd" /> clip-rule="evenodd"/>
</svg> </svg>
<div> <div>
<span class="text-gray-700 dark:text-neutral-200">{{client.transferTxCurrent | <span class="text-gray-700 dark:text-neutral-200">{{client.transferTxCurrent |
bytes}}/s</span> bytes}}/s</span>
<!-- Total TX --> <!-- Total TX -->
<br><span class="font-regular" style="font-size:0.85em">{{bytes(client.transferTx)}}</span> <br><span class="font-regular"
</div> style="font-size:0.85em">{{bytes(client.transferTx)}}</span>
</span> </div>
</span>
</div>
</div> <!-- Transfer RX -->
<div class="min-w-20 md:min-w-24" v-if="client.transferRx">
<span class="flex gap-1" :title="$t('totalUpload') + bytes(client.transferRx)">
<!-- Transfer RX --> <svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg"
<div class="min-w-20 md:min-w-24" v-if="client.transferRx"> viewBox="0 0 20 20" fill="currentColor">
<span class="flex gap-1" :title="$t('totalUpload') + bytes(client.transferRx)"> <path fill-rule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
<svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg" clip-rule="evenodd"/>
viewBox="0 0 20 20" fill="currentColor"> </svg>
<path fill-rule="evenodd" <div>
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z" <span class="text-gray-700 dark:text-neutral-200">{{client.transferRxCurrent |
clip-rule="evenodd" /> bytes}}/s</span>
</svg> <!-- Total RX -->
<div> <br><span class="font-regular"
<span class="text-gray-700 dark:text-neutral-200">{{client.transferRxCurrent | style="font-size:0.85em">{{bytes(client.transferRx)}}</span>
bytes}}/s</span> </div>
<!-- Total RX --> </span>
<br><span class="font-regular" style="font-size:0.85em">{{bytes(client.transferRx)}}</span> </div>
</div>
</span>
</div>
</div>
</div> </div>
<!-- </div> --> <!-- <div class="flex flex-grow items-center"> -->
</div> </div>
<!-- </div> --> <!-- <div class="flex flex-grow items-center"> -->
</div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<div class="text-gray-400 dark:text-neutral-400 flex gap-1 items-center justify-between"> <div class="text-gray-400 dark:text-neutral-400 flex gap-1 items-center justify-between">
<!-- Enable/Disable --> <!-- Enable/Disable -->
<div @click="disableClient(client)" v-if="client.enabled === true" :title="$t('disableClient')" <div @click="disableClient(client)" v-if="client.enabled === true" :title="$t('disableClient')"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all"> class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all">
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div> <div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div>
</div> </div>
<div @click="enableClient(client)" v-if="client.enabled === false" :title="$t('enableClient')" <div @click="enableClient(client)" v-if="client.enabled === false" :title="$t('enableClient')"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all"> class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all">
<div class="rounded-full w-4 h-4 m-1 bg-white"></div> <div class="rounded-full w-4 h-4 m-1 bg-white"></div>
</div> </div>
<!-- Show QR--> <!-- Show QR-->
<button :disabled="!client.downloadableConfig" <button :disabled="!client.downloadableConfig"
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition" class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{ :class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig, 'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig,
'is-disabled': !client.downloadableConfig 'is-disabled': !client.downloadableConfig
}" }" :title="!client.downloadableConfig ? $t('noPrivKey') : $t('showQR')"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('showQR')" @click="qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`">
@click="qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`"> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" /> </svg>
</svg> </button>
</button>
<!-- Download Config -->
<!-- Download Config --> <a :disabled="!client.downloadableConfig"
<a :disabled="!client.downloadableConfig" :href="'./api/wireguard/client/' + client.id + '/configuration'"
:href="'./api/wireguard/client/' + client.id + '/configuration'" :download="client.downloadableConfig ? 'configuration' : null"
:download="client.downloadableConfig ? 'configuration' : null" class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition" :class="{
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig, 'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig,
'is-disabled': !client.downloadableConfig 'is-disabled': !client.downloadableConfig
}" }" :title="!client.downloadableConfig ? $t('noPrivKey') : $t('downloadConfig')"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('downloadConfig')" @click="if(!client.downloadableConfig) { $event.preventDefault(); }">
@click="if(!client.downloadableConfig) { $event.preventDefault(); }"> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> </svg>
</svg> </a>
</a>
<!-- Delete -->
<!-- Delete -->
<button
<button class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition"
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition" :title="$t('deleteClient')" @click="clientDelete = client">
:title="$t('deleteClient')" @click="clientDelete = client"> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd"
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
clip-rule="evenodd" /> </svg>
</svg> </button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="clients && clients.length === 0">
</div> <p class="text-center m-10 text-gray-400 dark:text-neutral-400 text-sm">
<div v-if="clients && clients.length === 0"> {{$t("noClients")}}<br/><br/>
<p class="text-center m-10 text-gray-400 dark:text-neutral-400 text-sm"> <button @click="clientCreate = true; clientCreateName = '';"
{{$t("noClients")}}<br /><br /> class="bg-red-800 hover:bg-red-700 text-white border-2 border-none py-2 px-4 rounded inline-flex items-center transition">
<button @click="clientCreate = true; clientCreateName = '';" <svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="bg-red-800 hover:bg-red-700 text-white border-2 border-none py-2 px-4 rounded inline-flex items-center transition"> stroke="currentColor">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
stroke="currentColor"> d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" </svg>
d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <span class="text-sm">{{$t("newClient")}}</span>
</svg> </button>
<span class="text-sm">{{$t("newClient")}}</span> </p>
</button> </div>
</p> <div v-if="clients === null" class="text-gray-200 dark:text-red-300 p-5">
</div> <svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
<div v-if="clients === null" class="text-gray-200 dark:text-red-300 p-5"> fill="currentColor">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
fill="currentColor"> <path class="opacity-75" fill="currentColor"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
<path class="opacity-75" fill="currentColor" </path>
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> </svg>
</path> </div>
</svg>
</div> </div>
</div> </div>
<!-- Dashboard -->
<dashboard v-if="showPage === 'dashboard'"></dashboard>
</div> </div>
<!-- QR Code--> <!-- QR Code-->
@ -391,10 +435,10 @@
class="absolute right-4 top-4 text-gray-600 dark:text-neutral-500 hover:text-gray-800 dark:hover:text-neutral-700"> class="absolute right-4 top-4 text-gray-600 dark:text-neutral-500 hover:text-gray-800 dark:hover:text-neutral-700">
<svg class="w-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</button> </button>
<img :src="qrcode" /> <img :src="qrcode"/>
</div> </div>
</div> </div>
</div> </div>
@ -435,10 +479,10 @@
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div <div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-800 sm:mx-0 sm:h-10 sm:w-10"> class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-800 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-white" inline xmlns="http://www.w3.org/2000/svg" <svg class="h-6 w-6 text-white" inline xmlns="http://www.w3.org/2000/svg" fill="none"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg> </svg>
</div> </div>
<div class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
@ -449,7 +493,7 @@
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
<input <input
class="rounded p-2 border-2 dark:bg-neutral-700 dark:text-neutral-200 border-gray-100 dark:border-neutral-600 focus:border-gray-200 focus:dark:border-neutral-500 dark:placeholder:text-neutral-400 outline-none w-full" class="rounded p-2 border-2 dark:bg-neutral-700 dark:text-neutral-200 border-gray-100 dark:border-neutral-600 focus:border-gray-200 focus:dark:border-neutral-500 dark:placeholder:text-neutral-400 outline-none w-full"
type="text" v-model.trim="clientCreateName" :placeholder="$t('name')" /> type="text" v-model.trim="clientCreateName" :placeholder="$t('name')"/>
</p> </p>
</div> </div>
</div> </div>
@ -513,7 +557,7 @@
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true"> stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg> </svg>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
@ -545,7 +589,7 @@
<div v-if="authenticated === false"> <div v-if="authenticated === false">
<h1 class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center"> <h1 class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center">
<img src="./img/logo.png" width="32" class="inline align-middle dark:bg" /> <img src="./img/logo.png" width="32" class="inline align-middle dark:bg"/>
<span class="align-middle">WireGuard</span> <span class="align-middle">WireGuard</span>
</h1> </h1>
@ -553,14 +597,14 @@
class="shadow rounded-md bg-white dark:bg-neutral-700 mx-auto w-64 p-5 overflow-hidden mt-10"> class="shadow rounded-md bg-white dark:bg-neutral-700 mx-auto w-64 p-5 overflow-hidden mt-10">
<!-- Avatar --> <!-- Avatar -->
<div class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 dark:bg-red-800 relative overflow-hidden"> <div class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 dark:bg-red-800 relative overflow-hidden">
<svg class="w-10 h-10 m-5 text-white dark:text-white" xmlns="http://www.w3.org/2000/svg" <svg class="w-10 h-10 m-5 text-white dark:text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
viewBox="0 0 20 20" fill="currentColor"> fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<input type="password" name="password" :placeholder="$t('password')" v-model="password" <input type="password" name="password" :placeholder="$t('password')" v-model="password"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none" /> class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none"/>
<button v-if="authenticating" <button v-if="authenticating"
class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed"> class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed">
@ -595,9 +639,10 @@
</div> </div>
<p v-cloak class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs"> <a class="hover:underline" target="_blank" <p v-cloak class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs"> <a class="hover:underline"
href="https://github.com/wg-easy/wg-easy">WireGuard Easy</a> © 2021-2024 by <a class="hover:underline" target="_blank" target="_blank" href="https://github.com/wg-easy/wg-easy">WireGuard Easy</a> © 2021-2024 by <a
href="https://emilenijssen.nl/?ref=wg-easy">Emile Nijssen</a> is licensed under <a class="hover:underline" target="_blank" class="hover:underline" target="_blank" href="https://emilenijssen.nl/?ref=wg-easy">Emile Nijssen</a> is
licensed under <a class="hover:underline" target="_blank"
href="http://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a> · <a class="hover:underline" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a> · <a class="hover:underline"
href="https://github.com/sponsors/WeeJeWel" target="_blank">{{$t("donate")}}</a></p> href="https://github.com/sponsors/WeeJeWel" target="_blank">{{$t("donate")}}</a></p>
@ -609,6 +654,7 @@
<script src="./js/vendor/vue-apexcharts.min.js"></script> <script src="./js/vendor/vue-apexcharts.min.js"></script>
<script src="./js/vendor/sha256.min.js"></script> <script src="./js/vendor/sha256.min.js"></script>
<script src="./js/vendor/timeago.full.min.js"></script> <script src="./js/vendor/timeago.full.min.js"></script>
<script src="./js/vendor/vue-dashboard.umd.min.js"></script>
<script src="./js/api.js"></script> <script src="./js/api.js"></script>
<script src="./js/i18n.js"></script> <script src="./js/i18n.js"></script>
<script src="./js/app.js"></script> <script src="./js/app.js"></script>

12
src/www/js/api.js

@ -64,11 +64,11 @@ class API {
}); });
} }
async createSession({ password }) { async createSession({ username, password }) {
return this.call({ return this.call({
method: 'post', method: 'post',
path: '/session', path: '/session',
body: { password }, body: { username, password },
}); });
} }
@ -146,4 +146,12 @@ class API {
}); });
} }
async updatePassword({ username, oldPassword, newPassword }) {
return this.call({
method: 'put',
path: '/dashboard/setting/password',
body: { username, oldPassword, newPassword },
});
}
} }

7
src/www/js/app.js

@ -46,11 +46,14 @@ new Vue({
el: '#app', el: '#app',
components: { components: {
apexchart: VueApexCharts, apexchart: VueApexCharts,
dashboard: VueDashboard,
}, },
i18n, i18n,
data: { data: {
authenticated: null, authenticated: null,
authenticating: false, authenticating: false,
// temp username 'admin'
username: 'admin',
password: null, password: null,
requiresPassword: null, requiresPassword: null,
@ -155,6 +158,8 @@ new Vue({
}, },
}, },
}, },
showPage: 'home',
}, },
methods: { methods: {
dateTime: (value) => { dateTime: (value) => {
@ -238,6 +243,8 @@ new Vue({
this.authenticating = true; this.authenticating = true;
this.api.createSession({ this.api.createSession({
// temp username 'admin'
username: this.username,
password: this.password, password: this.password,
}) })
.then(async () => { .then(async () => {

7
src/www/js/i18n.js

@ -34,6 +34,13 @@ const messages = { // eslint-disable-line no-unused-vars
backup: 'Backup', backup: 'Backup',
titleRestoreConfig: 'Restore your configuration', titleRestoreConfig: 'Restore your configuration',
titleBackupConfig: 'Backup your configuration', titleBackupConfig: 'Backup your configuration',
navHome: 'Home',
navDashboard: 'Dashboard',
dashboard: 'Dashboard',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmNewPassword: 'Confirm New Password',
updatePassword: 'Update Password',
}, },
ua: { ua: {
name: 'Ім`я', name: 'Ім`я',

2
src/www/js/vendor/vue-dashboard.umd.min.js

@ -0,0 +1,2 @@
(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t():"function"===typeof define&&define.amd?define([],t):"object"===typeof exports?exports["VueDashboard"]=t():e["VueDashboard"]=t()})("undefined"!==typeof self?self:this,(()=>(()=>{"use strict";var e={};(()=>{e.d=(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})}})(),(()=>{e.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t)})(),(()=>{e.p=""})();var t={};if(e.d(t,{default:()=>c}),"undefined"!==typeof window){var r=window.document.currentScript,s=r&&r.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);s&&(e.p=s[1])}var o=function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("div",{staticClass:"w-full shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden"},[r("div",{staticClass:"flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-neutral-500/50 dark:border-neutral-600"},[r("div",{staticClass:"flex-grow"},[r("p",{staticClass:"text-2xl font-medium dark:text-neutral-200"},[e._v(e._s(e.$t("dashboard")))])])]),r("div",{staticClass:"container p-2 flex flex-col md:items-center py-8"},[r("form",{staticClass:"w-full md:w-[75%]",on:{submit:e.updatePassword}},[r("div",{staticClass:"mb-4"},[r("label",{staticClass:"block text-sm font-medium dark:text-neutral-200 mb-4",attrs:{for:"currentPassword"}},[e._v(" "+e._s(e.$t("currentPassword"))+" ")]),r("input",{directives:[{name:"model",rawName:"v-model",value:e.currentPassword,expression:"currentPassword"}],staticClass:"outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full",attrs:{id:"currentPassword",type:"password",placeholder:"Current Password",required:""},domProps:{value:e.currentPassword},on:{input:function(t){t.target.composing||(e.currentPassword=t.target.value)}}})]),r("div",{staticClass:"mb-4"},[r("label",{staticClass:"block text-sm font-medium dark:text-neutral-200 mb-4",attrs:{for:"newPassword"}},[e._v(" "+e._s(e.$t("newPassword"))+" ")]),r("input",{directives:[{name:"model",rawName:"v-model",value:e.newPassword,expression:"newPassword"}],staticClass:"outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full",attrs:{id:"newPassword",type:"password",placeholder:"New Password",required:""},domProps:{value:e.newPassword},on:{input:function(t){t.target.composing||(e.newPassword=t.target.value)}}})]),r("div",{staticClass:"mb-4"},[r("label",{staticClass:"block text-sm font-medium dark:text-neutral-200 mb-4",attrs:{for:"confirmNewPassword"}},[e._v(" "+e._s(e.$t("confirmNewPassword"))+" ")]),r("input",{directives:[{name:"model",rawName:"v-model",value:e.confirmNewPassword,expression:"confirmNewPassword"}],staticClass:"outline-none bg-transparent border border-neutral-500 p-2 rounded-md w-full",attrs:{id:"confirmNewPassword",type:"password",placeholder:"Confirm New Password",required:""},domProps:{value:e.confirmNewPassword},on:{input:function(t){t.target.composing||(e.confirmNewPassword=t.target.value)}}})]),r("button",{staticClass:"bg-neutral-400 text-white p-2 rounded-md w-full hover:bg-red-800 transition",attrs:{type:"submit"}},[e._v(" "+e._s(e.$t("updatePassword"))+" ")])])])])},a=[];const n={name:"Dashboard",data(){return{currentPassword:"",newPassword:"",confirmNewPassword:""}},methods:{async updatePassword(e){e.preventDefault(),this.newPassword===this.confirmNewPassword?this.api.updatePassword({username:"admin",oldPassword:this.currentPassword,newPassword:this.newPassword}).then((e=>{this.currentPassword="",this.newPassword="",this.confirmNewPassword="",alert("Password updated")})).catch((e=>{alert(e.message||e.toString())})):alert("Password Mismatch")}},mounted(){this.api=new API}},d=n;function i(e,t,r,s,o,a,n,d){var i,l="function"===typeof e?e.options:e;if(t&&(l.render=t,l.staticRenderFns=r,l._compiled=!0),s&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),n?(i=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(n)},l._ssrRegister=i):o&&(i=d?function(){o.call(this,(l.functional?this.parent:this).$root.$options.shadowRoot)}:o),i)if(l.functional){l._injectStyles=i;var u=l.render;l.render=function(e,t){return i.call(t),u(e,t)}}else{var c=l.beforeCreate;l.beforeCreate=c?[].concat(c,i):[i]}return{exports:e,options:l}}var l=i(d,o,a,!1,null,null,null);const u=l.exports,c=u;return t=t["default"],t})()));
//# sourceMappingURL=vue-dashboard.umd.min.js.map

1
src/www/js/vendor/vue-dashboard.umd.min.js.map

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save