Browse Source

Add Nuxt, ESM, Typescript (#1244)

* wip: add nuxt

* basic implementation

* add changes from c9ff248

* update workflow, add eslint

* add types, fix wrong error message

* install correct bcrypt, move eslint to dev modules

* add docker dev script

* fix styling

* add wireguard routes

* typescript, vendors

* fix lint workflow

* lint fixes

* add prettier, format

* fix lint, add vscode settings

* better typescript

* use auto imports

* add prettier eslint config

* cache config

* fix styling issue, fix formatting

* fix tailwind problems

* fix logout not showing

* fix lint action

* Fix session middleware

* split files into correct methods

* use type safe api, fix typescript errors

* better return types

not tested

* change default working directory

* update workflows

* fix error

* correct session middleware, type safe session

* convert undefined to boolean

* correct key for api errors

* use zod to validate input

* add more jobs to check for good code

* add pinia

Co-authored-by: Sergei Birukov <[email protected]>
Co-authored-by: Bernd Storath <[email protected]>

* use color mode plugin

* !! use better storage key name

Breaking as if old key exists it breaks as "auto" is not compatible with new "system"

* better local dev while dev container is running

use `docker compose -f docker-compose.dev.yml up`
or after changing dockerfile
`docker compose -f docker-compose.dev.yml up --build`

* update translation to match new theme mode

* improve dx

new devs get extensions recommended to catch errors, etc directly in vscode

* reduce errors, improve typing

* Split components (#1)

* update: introduce pages & components

 fix lint

* update: starting split components

* use auto imports

* Improve workflows and docker

workflow fix step naming

simplify docker dev

simplify docker prod

revert to node 18

dockerfile naming scheme

* Split components (#2)

* update: starting split components

* upd: rebase & continue splitting components

- layouts: header & footer
- components: basic buttton
- pages: login page

* update: login page

* package.json: remove dev:pass script

* Split into Components, migrate to nuxt

fixup

shutdown wireguard properly

fix styling, fix store

split even more

clear interval

split even more

split even more

handle auth middleware on server

avoid flicker of login page

* fix: buttons spaces & move layouts to components (#3)

* update: icons into components

- fix: header login page

* fix: tailwind handle btn class

* Split into icons

fix avatar

move class to view not icon itself

fix icon

format

* invalidate cache to make restoreConfig work

* fix apexchart

* use different color mode module

other one resulted in hydration mismatch

* fix dialog

* fix bad i18n merge

* use nuxt 4

* fix typing, fix redirect, latest release on server

* start wireguard on start

* wait for shutdown

* improve zod errors, consistent server errors

* migrate to useFetch

this makes sure that there is no double fetching

* fix hydration issues, remove unnecessary state, rename function

* fetch globalstore globally

otherwise this will load on login to homepage

* migrate to useFetch

no javascript support

TODO: not properly tested

* update backend

* wip: frontend

* update frontend

* update pnpm lock

---------

Co-authored-by: Sergei Birukov <[email protected]>
Co-authored-by: Bernd Storath <[email protected]>
Co-authored-by: tetuaoro <[email protected]>
pull/1648/head
Bernd Storath 8 months ago
committed by Bernd Storath
parent
commit
153e944893
  1. 68
      .github/workflows/lint.yml
  2. 13
      .vscode/extensions.json
  3. 16
      .vscode/settings.json
  4. 37
      Dockerfile
  5. 28
      Dockerfile.dev
  6. 15
      docker-compose.dev.yml
  7. 11
      package-lock.json
  8. 5
      package.json
  9. 9
      pnpm-lock.yaml
  10. 11
      src/.eslintrc.json
  11. 24
      src/.gitignore
  12. 1
      src/.prettierignore
  13. 6
      src/.prettierrc.json
  14. 49
      src/app.vue
  15. 60
      src/components/Client/Address.vue
  16. 31
      src/components/Client/Avatar.vue
  17. 138
      src/components/Client/Charts.vue
  18. 58
      src/components/Client/Client.vue
  19. 27
      src/components/Client/Config.vue
  20. 17
      src/components/Client/Delete.vue
  21. 91
      src/components/Client/ExpireDate.vue
  22. 29
      src/components/Client/InlineTransfer.vue
  23. 20
      src/components/Client/LastSeen.vue
  24. 65
      src/components/Client/Name.vue
  25. 26
      src/components/Client/OneTimeLink.vue
  26. 47
      src/components/Client/OneTimeLinkBtn.vue
  27. 25
      src/components/Client/QRCode.vue
  28. 40
      src/components/Client/Switch.vue
  29. 45
      src/components/Client/Transfer.vue
  30. 10
      src/components/Clients/BackupConfig.vue
  31. 13
      src/components/Clients/Clients.vue
  32. 113
      src/components/Clients/CreateDialog.vue
  33. 99
      src/components/Clients/DeleteDialog.vue
  34. 20
      src/components/Clients/Empty.vue
  35. 16
      src/components/Clients/New.vue
  36. 21
      src/components/Clients/QRCodeDialog.vue
  37. 34
      src/components/Clients/RestoreConfig.vue
  38. 51
      src/components/Clients/Sort.vue
  39. 26
      src/components/base/Button.vue
  40. 0
      src/components/base/Container.vue
  41. 13
      src/components/icons/ArrowDown.vue
  42. 16
      src/components/icons/ArrowInf.vue
  43. 13
      src/components/icons/ArrowUp.vue
  44. 13
      src/components/icons/Avatar.vue
  45. 12
      src/components/icons/Chart.vue
  46. 15
      src/components/icons/Close.vue
  47. 13
      src/components/icons/Delete.vue
  48. 15
      src/components/icons/Download.vue
  49. 15
      src/components/icons/Edit.vue
  50. 11
      src/components/icons/HalfMoon.vue
  51. 21
      src/components/icons/Loading.vue
  52. 15
      src/components/icons/Logout.vue
  53. 15
      src/components/icons/Moon.vue
  54. 16
      src/components/icons/Plus.vue
  55. 15
      src/components/icons/QRCode.vue
  56. 16
      src/components/icons/Stack.vue
  57. 15
      src/components/icons/Sun.vue
  58. 17
      src/components/icons/Warning.vue
  59. 14
      src/components/ui/Chart.vue
  60. 0
      src/components/ui/Modal.vue
  61. 0
      src/components/ui/NavBar.vue
  62. 6
      src/composables/useColorMode.ts
  63. 47
      src/config.js
  64. 4
      src/eslint.config.mjs
  65. 679
      src/i18n.config.ts
  66. 35
      src/layouts/Footer.vue
  67. 129
      src/layouts/Header.vue
  68. 441
      src/lib/Server.js
  69. 10
      src/lib/ServerError.js
  70. 80
      src/lib/Util.js
  71. 20
      src/nuxt.config.ts
  72. 56
      src/package.json
  73. 73
      src/pages/index.vue
  74. 105
      src/pages/login.vue
  75. 5
      src/plugins/apexcharts.client.ts
  76. 9345
      src/pnpm-lock.yaml
  77. 0
      src/public/apple-touch-icon.png
  78. 0
      src/public/favicon.png
  79. 0
      src/public/logo.png
  80. 2
      src/public/manifest.json
  81. 29
      src/server.js
  82. 24
      src/server/api/cnf/:clientsOnteTimeLink.ts
  83. 4
      src/server/api/lang.get.ts
  84. 8
      src/server/api/release.get.ts
  85. 4
      src/server/api/remember-me.get.ts
  86. 16
      src/server/api/session.delete.ts
  87. 11
      src/server/api/session.get.ts
  88. 43
      src/server/api/session.post.ts
  89. 8
      src/server/api/ui-chart-type.get.ts
  90. 5
      src/server/api/ui-sort-clients.get.ts
  91. 6
      src/server/api/ui-traffic-stats.get.ts
  92. 5
      src/server/api/wg-enable-expire-time.get.ts
  93. 5
      src/server/api/wg-enable-one-time-links.get.ts
  94. 6
      src/server/api/wireguard/backup.get.ts
  95. 9
      src/server/api/wireguard/client/[clientId]/address.put.ts
  96. 20
      src/server/api/wireguard/client/[clientId]/configuration.get.ts
  97. 8
      src/server/api/wireguard/client/[clientId]/disable.post.ts
  98. 8
      src/server/api/wireguard/client/[clientId]/enable.post.ts
  99. 12
      src/server/api/wireguard/client/[clientId]/expireDate.put.ts
  100. 14
      src/server/api/wireguard/client/[clientId]/generateOneTimeLink.post.ts

68
.github/workflows/lint.yml

@ -15,15 +15,73 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
check-latest: true
cache: "pnpm"
- name: pnpm lint
run: |
cd src
pnpm install
pnpm lint
typecheck:
name: Typecheck
runs-on: ubuntu-latest
if: github.repository_owner == 'wg-easy'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
check-latest: true
cache: "pnpm"
- name: pnpm typecheck
run: |
cd src
pnpm install
pnpm typecheck
formatcheck:
name: Check format
runs-on: ubuntu-latest
if: github.repository_owner == 'wg-easy'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
node-version: "20"
check-latest: true
cache: 'npm'
cache: "pnpm"
- name: npm run lint
- name: pnpm format:check
run: |
cd src
npm ci
npm run lint
pnpm install
pnpm format:check

13
.vscode/extensions.json

@ -0,0 +1,13 @@
{
"recommendations": [
"aaron-bond.better-comments",
"dbaeumer.vscode-eslint",
"antfu.goto-alias",
"visualstudioexptteam.vscodeintellicode",
"Nuxtr.nuxtr-vscode",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"bradlc.vscode-tailwindcss",
"vue.volar"
]
}

16
.vscode/settings.json

@ -0,0 +1,16 @@
{
"editor.tabSize": 2,
"editor.useTabStops": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"nuxtr.vueFiles.style.addStyleTag": false,
"nuxtr.piniaFiles.defaultTemplate": "setup",
"nuxtr.monorepoMode.DirectoryName": "src",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "./src/node_modules/typescript/lib"
}

37
Dockerfile

@ -1,30 +1,29 @@
# As a workaround we have to build on nodejs 18
# nodejs 20 hangs on build with armv6/armv7
FROM docker.io/library/node:18-alpine AS build_node_modules
# nodejs 20 hangs on build with armv6/armv7 (https://github.com/nodejs/docker-node/issues/2077)
FROM docker.io/library/node:18-alpine AS build
WORKDIR /app
# Install pnpm
RUN corepack enable pnpm
# Copy Web UI
COPY src /app
WORKDIR /app
RUN npm ci --omit=dev &&\
mv node_modules /node_modules
COPY src ./
RUN pnpm install
# Build UI
RUN pnpm build
# Copy build result to a new image.
# This saves a lot of disk space.
FROM docker.io/library/node:lts-alpine
WORKDIR /app
HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q interface || exit 1" --interval=1m --timeout=5s --retries=3
COPY --from=build_node_modules /app /app
# Move node_modules one directory up, so during development
# we don't have to mount it in a volume.
# This results in much faster reloading!
#
# Also, some node_modules might be native, and
# the architecture & OS of your development machine might differ
# than what runs inside of docker.
COPY --from=build_node_modules /node_modules /node_modules
# Copy build
COPY --from=build /app/.output /app
# Copy the needed wg-password scripts
COPY --from=build_node_modules /app/wgpw.sh /bin/wgpw
COPY --from=build /app/wgpw.sh /bin/wgpw
RUN chmod +x /bin/wgpw
# Install Linux packages
@ -40,7 +39,7 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
# Set Environment
ENV DEBUG=Server,WireGuard
ENV PORT=51821
# Run Web UI
WORKDIR /app
CMD ["/usr/bin/dumb-init", "node", "server.js"]
CMD ["/usr/bin/dumb-init", "node", "server/index.mjs"]

28
Dockerfile.dev

@ -0,0 +1,28 @@
# As a workaround we have to build on nodejs 18
# nodejs 20 hangs on build with armv6/armv7
FROM docker.io/library/node:20-alpine
WORKDIR /app
# Install pnpm
RUN corepack enable pnpm
# Copy Web UI
COPY src ./
RUN pnpm install
HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q interface || exit 1" --interval=1m --timeout=5s --retries=3
# Install Linux packages
RUN apk add --no-cache \
dpkg \
dumb-init \
iptables \
iptables-legacy \
wireguard-tools
# Use iptables-legacy
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
ENV DEBUG=Server,WireGuard
ENV PORT=51821

15
docker-compose.dev.yml

@ -1,10 +1,12 @@
services:
wg-easy:
build:
dockerfile: ./Dockerfile
command: npm run serve
dockerfile: ./Dockerfile.dev
command: pnpm run dev
volumes:
- ./src/:/app/
- temp:/app/.nuxt/
- temp1:/app/node_modules/
# - ./data/:/etc/wireguard
ports:
- "51820:51820/udp"
@ -13,5 +15,12 @@ services:
- NET_ADMIN
- SYS_MODULE
environment:
# - PASSWORD_HASH=p
- PASSWORD_HASH=$$2y$$10$$Vhi2tF1i2c/ReW3LdLOru.z7LDITqBgb2wrSVw6sa.KEtbpYgSAf2 # foobar123
- WG_HOST=192.168.1.233
# folders should be generated inside container
volumes:
temp:
driver: local
temp1:
driver: local

11
package-lock.json

@ -1,11 +0,0 @@
{
"name": "wg-easy",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"version": "1.0.1"
}
}
}

5
package.json

@ -6,5 +6,6 @@
"serve": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
"sudostart": "sudo docker run --env WG_HOST=0.0.0.0 --name wg-easy --cap-add=NET_ADMIN --cap-add=SYS_MODULE --sysctl=\"net.ipv4.conf.all.src_valid_mark=1\" --mount type=bind,source=\"$(pwd)\"/config,target=/etc/wireguard -p 51820:51820/udp -p 51821:51821/tcp wg-easy",
"start": "docker run --env WG_HOST=0.0.0.0 --name wg-easy --cap-add=NET_ADMIN --cap-add=SYS_MODULE --sysctl=\"net.ipv4.conf.all.src_valid_mark=1\" --mount type=bind,source=\"$(pwd)\"/config,target=/etc/wireguard -p 51820:51820/udp -p 51821:51821/tcp wg-easy"
}
}
},
"packageManager": "[email protected]"
}

9
pnpm-lock.yaml

@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

11
src/.eslintrc.json

@ -1,11 +0,0 @@
{
"extends": "athom",
"ignorePatterns": [
"**/vendor/*.js"
],
"rules": {
"consistent-return": "off",
"no-shadow": "off",
"max-len": "off"
}
}

24
src/.gitignore

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

1
src/.prettierignore

@ -0,0 +1 @@
pnpm-lock.yaml

6
src/.prettierrc.json

@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

49
src/app.vue

@ -0,0 +1,49 @@
<template>
<NuxtLayout>
<NuxtLayout name="header" />
<NuxtPage />
<NuxtLayout name="footer" />
</NuxtLayout>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
globalStore.fetchTrafficStats();
globalStore.fetchChartType();
globalStore.fetchRelease();
globalStore.fetchOneTimeLinks();
globalStore.fetchSortClients();
globalStore.fetchExpireTime();
globalStore.fetchRememberMe();
useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',
},
link: [
{
rel: 'manifest',
href: '/manifest.json',
},
{
rel: 'icon',
type: 'image/png',
href: '/favicon.png',
},
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon.png',
},
],
meta: [
{
name: 'apple-mobile-web-app-capable',
content: 'yes',
},
{
name: 'apple-mobile-web-app-status-bar-style',
content: 'black-translucent',
},
],
title: 'WireGuard',
});
</script>

60
src/components/Client/Address.vue

@ -0,0 +1,60 @@
<template>
<span class="group">
<!-- Show -->
<input
v-show="clientEditAddressId === client.id"
ref="clientAddressInput"
v-model="clientEditAddress"
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"
@keyup.enter="
updateClientAddress(client, clientEditAddress);
clientEditAddress = null;
clientEditAddressId = null;
"
@keyup.escape="
clientEditAddress = null;
clientEditAddressId = null;
"
/>
<span v-show="clientEditAddressId !== client.id" class="inline-block">{{
client.address
}}</span>
<!-- Edit -->
<span
v-show="clientEditAddressId !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditAddress = client.address;
clientEditAddressId = client.id;
nextTick(() => clientAddressInput?.select());
"
>
<IconsEdit
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100"
/>
</span>
</span>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const clientsStore = useClientsStore();
const clientAddressInput = ref<HTMLInputElement | null>(null);
const clientEditAddress = ref<null | string>(null);
const clientEditAddressId = ref<null | string>(null);
function updateClientAddress(client: WGClient, address: string | null) {
if (address === null) {
return;
}
api
.updateClientAddress({ clientId: client.id, address })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

31
src/components/Client/Avatar.vue

@ -0,0 +1,31 @@
<template>
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative">
<IconsAvatar class="w-6 m-2 text-gray-300" />
<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().getTime() - new Date(client.latestHandshakeAt).getTime() <
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
class="w-2 h-2 bg-red-800 dark:bg-red-600 rounded-full absolute bottom-0 right-0"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

138
src/components/Client/Charts.vue

@ -0,0 +1,138 @@
<template>
<div
v-if="globalStore.uiChartType"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`"
>
<UiChart :options="chartOptionsTX" :series="client.transferTxSeries" />
</div>
<div
v-if="globalStore.uiChartType"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`"
>
<UiChart
:options="chartOptionsRX"
:series="client.transferRxSeries"
style="transform: scaleY(-1)"
/>
</div>
</template>
<script setup lang="ts">
import type { ApexOptions } from 'apexcharts';
defineProps<{
client: LocalClient;
}>();
const globalStore = useGlobalStore();
const theme = useTheme();
const chartOptionsTX = computed(() => {
const opts = {
...chartOptions,
colors: [CHART_COLORS.tx[theme.value]],
};
opts.chart.type = UI_CHART_TYPES[globalStore.uiChartType]?.type || undefined;
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType]?.strokeWidth ?? 0;
return opts;
});
const chartOptionsRX = computed(() => {
const opts = {
...chartOptions,
colors: [CHART_COLORS.rx[theme.value]],
};
opts.chart.type = UI_CHART_TYPES[globalStore.uiChartType]?.type || undefined;
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType]?.strokeWidth ?? 0;
return opts;
});
const chartOptions = {
chart: {
type: undefined as ApexChart['type'],
background: 'transparent',
stacked: false,
toolbar: {
show: false,
},
animations: {
enabled: false,
},
parentHeightOffset: 0,
sparkline: {
enabled: true,
},
},
colors: [],
stroke: {
curve: 'smooth',
width: 0,
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: CHART_COLORS.gradient[theme.value],
inverseColors: false,
opacityTo: 0,
stops: [0, 100],
},
},
dataLabels: {
enabled: false,
},
plotOptions: {
bar: {
horizontal: false,
},
},
xaxis: {
labels: {
show: false,
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
yaxis: {
labels: {
show: false,
},
min: 0,
},
tooltip: {
enabled: false,
},
legend: {
show: false,
},
grid: {
show: false,
padding: {
left: -10,
right: 0,
bottom: -15,
top: -15,
},
column: {
opacity: 0,
},
xaxis: {
lines: {
show: false,
},
},
},
} satisfies ApexOptions;
</script>
<style scoped lang="css">
.line-chart .apexcharts-svg {
transform: translateY(3px);
}
</style>

58
src/components/Client/Client.vue

@ -0,0 +1,58 @@
<template>
<ClientCharts :client="client" />
<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">
<ClientAvatar :client="client" />
<!-- Name & Info -->
<div class="flex flex-col xxs:flex-row w-full gap-2">
<div class="flex flex-col flex-grow gap-1">
<ClientName :client="client" />
<div
class="block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs"
>
<ClientAddress :client="client" />
<ClientInlineTransfer
v-if="!globalStore.uiTrafficStats"
:client="client"
/>
<ClientLastSeen :client="client" />
</div>
<ClientOneTimeLink :client="client" />
<ClientExpireDate :client="client" />
</div>
<!-- Info -->
<div
v-if="globalStore.uiTrafficStats"
class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end"
>
<ClientTransfer :client="client" />
</div>
</div>
<!-- </div> -->
<!-- <div class="flex flex-grow items-center"> -->
</div>
<div class="flex items-center justify-end">
<div
class="text-gray-400 dark:text-neutral-400 flex gap-1 items-center justify-between"
>
<ClientSwitch :client="client" />
<ClientQRCode :client="client" />
<ClientConfig :client="client" />
<ClientOneTimeLinkBtn :client="client" />
<ClientDelete :client="client" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const globalStore = useGlobalStore();
</script>

27
src/components/Client/Config.vue

@ -0,0 +1,27 @@
<template>
<a
:disabled="!client.downloadableConfig"
:href="'./api/wireguard/client/' + client.id + '/configuration'"
: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="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white':
client.downloadableConfig,
'is-disabled': !client.downloadableConfig,
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('downloadConfig')"
@click="
if (!client.downloadableConfig) {
$event.preventDefault();
}
"
>
<IconsDownload class="w-5" />
</a>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

17
src/components/Client/Delete.vue

@ -0,0 +1,17 @@
<template>
<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"
:title="$t('deleteClient')"
@click="modalStore.clientDelete = client"
>
<IconsDelete class="w-5" />
</button>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const modalStore = useModalStore();
</script>

91
src/components/Client/ExpireDate.vue

@ -0,0 +1,91 @@
<template>
<div
v-show="globalStore.enableExpireTime"
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="clientEditExpireDateId === client.id"
ref="clientExpireDateInput"
v-model="clientEditExpireDate"
type="text"
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-70 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500 text-xs p-0"
@keyup.enter="
updateClientExpireDate(client, clientEditExpireDate);
clientEditExpireDate = null;
clientEditExpireDateId = null;
"
@keyup.escape="
clientEditExpireDate = null;
clientEditExpireDateId = null;
"
/>
<span
v-show="clientEditExpireDateId !== client.id"
class="inline-block"
>{{ expiredDateFormat(client.expireAt) }}</span
>
<!-- Edit -->
<span
v-show="clientEditExpireDateId !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditExpireDate = client.expireAt
? client.expireAt.slice(0, 10)
: 'yyyy-mm-dd';
clientEditExpireDateId = client.id;
nextTick(() => clientExpireDateInput?.select());
"
>
<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>
</div>
</template>
<script setup lang="ts">
defineProps<{ client: LocalClient }>();
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
const clientEditExpireDate = ref<string | null>(null);
const clientEditExpireDateId = ref<string | null>(null);
const { t, locale } = useI18n();
const clientExpireDateInput = ref<HTMLInputElement | null>(null);
function updateClientExpireDate(
client: LocalClient,
expireDate: string | null
) {
api
.updateClientExpireDate({ clientId: client.id, expireDate })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
function expiredDateFormat(value: string | null) {
if (value === null) return t('Permanent');
const dateTime = new Date(value);
return dateTime.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>

29
src/components/Client/InlineTransfer.vue

@ -0,0 +1,29 @@
<template>
<!-- Inline Transfer TX -->
<span
v-if="client.transferTx"
class="whitespace-nowrap"
:title="$t('totalDownload') + bytes(client.transferTx)"
>
·
<IconsArrowDown class="align-middle h-3 inline" />
{{ bytes(client.transferTxCurrent) }}/s
</span>
<!-- Inline Transfer RX -->
<span
v-if="client.transferRx"
class="whitespace-nowrap"
:title="$t('totalUpload') + bytes(client.transferRx)"
>
·
<IconsArrowUp class="align-middle h-3 inline" />
{{ bytes(client.transferRxCurrent) }}/s
</span>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

20
src/components/Client/LastSeen.vue

@ -0,0 +1,20 @@
<template>
<span
v-if="client.latestHandshakeAt"
class="text-gray-400 dark:text-neutral-500 whitespace-nowrap"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"
>
{{ !globalStore.uiTrafficStats ? ' · ' : ''
}}{{ timeago(new Date(client.latestHandshakeAt)) }}
</span>
</template>
<script setup lang="ts">
import { format as timeago } from 'timeago.js';
defineProps<{
client: LocalClient;
}>();
const globalStore = useGlobalStore();
</script>

65
src/components/Client/Name.vue

@ -0,0 +1,65 @@
<template>
<div
class="text-gray-700 dark:text-neutral-200 group text-sm md:text-base"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))"
>
<!-- Show -->
<input
v-show="clientEditNameId === client.id"
ref="clientNameInput"
v-model="clientEditName"
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"
@keyup.enter="
updateClientName(client, clientEditName);
clientEditName = null;
clientEditNameId = null;
"
@keyup.escape="
clientEditName = null;
clientEditNameId = null;
"
/>
<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"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditName = client.name;
clientEditNameId = client.id;
nextTick(() => clientNameInput?.select());
"
>
<IconsEdit
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100"
/>
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const clientsStore = useClientsStore();
const clientNameInput = ref<HTMLInputElement | null>(null);
const clientEditName = ref<null | string>(null);
const clientEditNameId = ref<null | string>(null);
function updateClientName(client: LocalClient, name: string | null) {
if (name === null) {
return;
}
api
.updateClientName({ clientId: client.id, name })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

26
src/components/Client/OneTimeLink.vue

@ -0,0 +1,26 @@
<template>
<div
v-if="
globalStore.enableOneTimeLinks &&
client.oneTimeLink !== null &&
client.oneTimeLink !== ''
"
:ref="'client-' + client.id + '-link'"
class="text-gray-400 text-xs"
>
<a :href="'./cnf/' + client.oneTimeLink + ''">{{ path }}</a>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ client: LocalClient }>();
const globalStore = useGlobalStore();
const path = computed(() => {
if (import.meta.client) {
return `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink}`;
}
return '';
});
</script>

47
src/components/Client/OneTimeLinkBtn.vue

@ -0,0 +1,47 @@
<template>
<button
v-if="globalStore.enableOneTimeLinks"
:disabled="!client.downloadableConfig"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white':
client.downloadableConfig,
'is-disabled': !client.downloadableConfig,
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('OneTimeLink')"
@click="
if (client.downloadableConfig) {
showOneTimeLink(client);
}
"
>
<svg
class="w-5"
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="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"
/>
</svg>
</button>
</template>
<script setup lang="ts">
defineProps<{ client: LocalClient }>();
const clientsStore = useClientsStore();
const globalStore = useGlobalStore();
function showOneTimeLink(client: LocalClient) {
api
.showOneTimeLink({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

25
src/components/Client/QRCode.vue

@ -0,0 +1,25 @@
<template>
<button
:disabled="!client.downloadableConfig"
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white':
client.downloadableConfig,
'is-disabled': !client.downloadableConfig,
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('showQR')"
@click="
modalStore.qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`
"
>
<IconsQRCode class="w-5" />
</button>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const modalStore = useModalStore();
</script>

40
src/components/Client/Switch.vue

@ -0,0 +1,40 @@
<template>
<div
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"
@click="disableClient(client)"
>
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white" />
</div>
<div
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"
@click="enableClient(client)"
>
<div class="rounded-full w-4 h-4 m-1 bg-white" />
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const clientsStore = useClientsStore();
function enableClient(client: WGClient) {
api
.enableClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
function disableClient(client: WGClient) {
api
.disableClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

45
src/components/Client/Transfer.vue

@ -0,0 +1,45 @@
<template>
<!-- Transfer TX -->
<div v-if="client.transferTx" class="min-w-20 md:min-w-24">
<span
class="flex gap-1"
:title="$t('totalDownload') + bytes(client.transferTx)"
>
<IconsArrowDown class="align-middle h-3 inline mt-0.5" />
<div>
<span class="text-gray-700 dark:text-neutral-200"
>{{ bytes(client.transferTxCurrent) }}/s</span
>
<!-- Total TX -->
<br /><span class="font-regular" style="font-size: 0.85em">{{
bytes(client.transferTx)
}}</span>
</div>
</span>
</div>
<!-- Transfer RX -->
<div v-if="client.transferRx" class="min-w-20 md:min-w-24">
<span
class="flex gap-1"
:title="$t('totalUpload') + bytes(client.transferRx)"
>
<IconsArrowUp class="align-middle h-3 inline mt-0.5" />
<div>
<span class="text-gray-700 dark:text-neutral-200"
>{{ bytes(client.transferRxCurrent) }}/s</span
>
<!-- Total RX -->
<br /><span class="font-regular" style="font-size: 0.85em">{{
bytes(client.transferRx)
}}</span>
</div>
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

10
src/components/Clients/BackupConfig.vue

@ -0,0 +1,10 @@
<template>
<BaseButton
as="a"
href="./api/wireguard/backup"
:title="$t('titleBackupConfig')"
>
<IconsStack class="w-4 md:mr-2" />
<span class="max-md:hidden text-sm">{{ $t('backup') }}</span>
</BaseButton>
</template>

13
src/components/Clients/Clients.vue

@ -0,0 +1,13 @@
<template>
<div
v-for="client in clientsStore.clients"
:key="client.id"
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid"
>
<Client :client="client" />
</div>
</template>
<script setup lang="ts">
const clientsStore = useClientsStore();
</script>

113
src/components/Clients/CreateDialog.vue

@ -0,0 +1,113 @@
<template>
<!-- Create Dialog -->
<div
v-if="modalStore.clientCreate"
class="fixed z-10 inset-0 overflow-y-auto"
>
<div
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div
class="absolute inset-0 bg-gray-500 dark:bg-black opacity-75 dark:opacity-50"
/>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
To: "opacity-100 tranneutral-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 tranneutral-y-0 sm:scale-100"
To: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white dark:bg-neutral-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
>
<div class="bg-white dark:bg-neutral-700 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<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"
>
<IconsPlus class="h-6 w-6 text-white" />
</div>
<div
class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"
>
<h3
id="modal-headline"
class="text-lg leading-6 font-medium text-gray-900 dark:text-neutral-200"
>
{{ $t('newClient') }}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<input
v-model.trim="modalStore.clientCreateName"
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"
:placeholder="$t('name')"
/>
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 dark:bg-neutral-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
v-if="modalStore.clientCreateName.length"
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-800 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
@click="
modalStore.createClient();
modalStore.clientCreate = null;
"
>
{{ $t('create') }}
</button>
<button
v-else
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-200 dark:bg-neutral-400 text-base font-medium text-white dark:text-neutral-300 sm:ml-3 sm:w-auto sm:text-sm cursor-not-allowed"
>
{{ $t('create') }}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-neutral-500 shadow-sm px-4 py-2 bg-white dark:bg-neutral-500 text-base font-medium text-gray-700 dark:text-neutral-50 hover:bg-gray-50 dark:hover:bg-neutral-600 dark:hover:border-neutral-600 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
@click="modalStore.clientCreate = null"
>
{{ $t('cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

99
src/components/Clients/DeleteDialog.vue

@ -0,0 +1,99 @@
<template>
<div
v-if="modalStore.clientDelete"
class="fixed z-10 inset-0 overflow-y-auto"
>
<div
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div
class="absolute inset-0 bg-gray-500 dark:bg-black opacity-75 dark:opacity-50"
/>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
To: "opacity-100 tranneutral-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 tranneutral-y-0 sm:scale-100"
To: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white dark:bg-neutral-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
>
<div class="bg-white dark:bg-neutral-700 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<IconsWarning class="h-6 w-6 text-red-600" />
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3
id="modal-headline"
class="text-lg leading-6 font-medium text-gray-900 dark:text-neutral-200"
>
{{ $t('deleteClient') }}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-neutral-300">
{{ $t('deleteDialog1') }}
<strong>{{ modalStore.clientDelete.name }}</strong
>? {{ $t('deleteDialog2') }}
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 dark:bg-neutral-600 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 dark:bg-red-600 text-base font-medium text-white dark:text-white hover:bg-red-700 dark:hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
@click="
modalStore.deleteClient(modalStore.clientDelete);
modalStore.clientDelete = null;
"
>
{{ $t('deleteClient') }}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-neutral-500 shadow-sm px-4 py-2 bg-white dark:bg-neutral-500 text-base font-medium text-gray-700 dark:text-neutral-50 hover:bg-gray-50 dark:hover:bg-neutral-600 dark:hover:border-neutral-600 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
@click="modalStore.clientDelete = null"
>
{{ $t('cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

20
src/components/Clients/Empty.vue

@ -0,0 +1,20 @@
<template>
<p class="text-center m-10 text-gray-400 dark:text-neutral-400 text-sm">
{{ $t('noClients') }}<br /><br />
<button
class="bg-red-800 hover:bg-red-700 text-white border-2 border-none py-2 px-4 rounded inline-flex items-center transition"
@click="
modalStore.clientCreate = true;
modalStore.clientCreateName = '';
modalStore.clientExpireDate = '';
"
>
<IconsPlus class="w-4 mr-2" />
<span class="text-sm">{{ $t('newClient') }}</span>
</button>
</p>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

16
src/components/Clients/New.vue

@ -0,0 +1,16 @@
<template>
<BaseButton
@click="
modalStore.clientCreate = true;
modalStore.clientCreateName = '';
modalStore.clientExpireDate = '';
"
>
<IconsPlus class="w-4 md:mr-2" />
<span class="max-md:hidden text-sm">{{ $t('new') }}</span>
</BaseButton>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

21
src/components/Clients/QRCodeDialog.vue

@ -0,0 +1,21 @@
<template>
<div v-if="modalStore.qrcode">
<div
class="bg-black bg-opacity-50 fixed top-0 right-0 left-0 bottom-0 flex items-center justify-center z-20"
>
<div class="bg-white rounded-md shadow-lg relative p-8">
<button
class="absolute right-4 top-4 text-gray-600 dark:text-neutral-500 hover:text-gray-800 dark:hover:text-neutral-700"
@click="modalStore.qrcode = null"
>
<IconsClose class="w-8" />
</button>
<img :src="modalStore.qrcode" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

34
src/components/Clients/RestoreConfig.vue

@ -0,0 +1,34 @@
<template>
<BaseButton as="label" for="inputRC" :title="$t('titleRestoreConfig')">
<IconsArrowInf class="w-4 md:mr-2" />
<span class="max-md:hidden text-sm">{{ $t('restore') }}</span>
<input
id="inputRC"
type="file"
name="configurationfile"
accept="text/*,.json"
class="hidden"
@change="restoreConfig"
/>
</BaseButton>
</template>
<script setup lang="ts">
function restoreConfig(e: Event) {
e.preventDefault();
const file = (e.currentTarget as HTMLInputElement).files?.item(0);
if (file) {
file
.text()
.then((content) => {
api
.restoreConfiguration(content)
.then(() => alert('The configuration was updated.'))
.catch((err) => alert(err.message || err.toString()));
})
.catch((err) => alert(err.message || err.toString()));
} else {
alert('Failed to load your file!');
}
}
</script>

51
src/components/Clients/Sort.vue

@ -0,0 +1,51 @@
<template>
<button
v-if="globalStore.enableSortClient"
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"
@click="globalStore.sortClient = !globalStore.sortClient"
>
<svg
v-if="globalStore.sortClient === true"
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
d="M12 19.75C11.9015 19.7504 11.8038 19.7312 11.7128 19.6934C11.6218 19.6557 11.5392 19.6001 11.47 19.53L5.47 13.53C5.33752 13.3878 5.2654 13.1997 5.26882 13.0054C5.27225 12.8111 5.35096 12.6258 5.48838 12.4883C5.62579 12.3509 5.81118 12.2722 6.00548 12.2688C6.19978 12.2654 6.38782 12.3375 6.53 12.47L12 17.94L17.47 12.47C17.6122 12.3375 17.8002 12.2654 17.9945 12.2688C18.1888 12.2722 18.3742 12.3509 18.5116 12.4883C18.649 12.6258 18.7277 12.8111 18.7312 13.0054C18.7346 13.1997 18.6625 13.3878 18.53 13.53L12.53 19.53C12.4608 19.6001 12.3782 19.6557 12.2872 19.6934C12.1962 19.7312 12.0985 19.7504 12 19.75Z"
fill="#000000"
/>
<path
d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z"
fill="#000000"
/>
</svg>
<svg
v-if="globalStore.sortClient === false"
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
d="M18 11.75C17.9015 11.7505 17.8038 11.7313 17.7128 11.6935C17.6218 11.6557 17.5392 11.6001 17.47 11.53L12 6.06001L6.53 11.53C6.38782 11.6625 6.19978 11.7346 6.00548 11.7312C5.81118 11.7278 5.62579 11.649 5.48838 11.5116C5.35096 11.3742 5.27225 11.1888 5.26882 10.9945C5.2654 10.8002 5.33752 10.6122 5.47 10.47L11.47 4.47001C11.6106 4.32956 11.8012 4.25067 12 4.25067C12.1987 4.25067 12.3894 4.32956 12.53 4.47001L18.53 10.47C18.6705 10.6106 18.7493 10.8013 18.7493 11C18.7493 11.1988 18.6705 11.3894 18.53 11.53C18.4608 11.6001 18.3782 11.6557 18.2872 11.6935C18.1962 11.7313 18.0985 11.7505 18 11.75Z"
fill="#000000"
/>
<path
d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z"
fill="#000000"
/>
</svg>
<span class="max-md:hidden text-sm">{{ $t('sort') }}</span>
</button>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
</script>

26
src/components/base/Button.vue

@ -0,0 +1,26 @@
<template>
<component
:is="elementType"
role="button"
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 md:px-4 rounded max-md:rounded-full inline-flex items-center transition"
v-bind="attrs"
>
<slot />
</component>
</template>
<script setup lang="ts">
const props = defineProps({
as: {
type: String,
default: 'button',
},
});
const elementType = computed(() => props.as);
const attrs = computed(() => {
const { as, ...attrs } = props;
return attrs;
});
</script>

0
src/components/base/Container.vue

13
src/components/icons/ArrowDown.vue

@ -0,0 +1,13 @@
<template>
<svg
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>
</template>

16
src/components/icons/ArrowInf.vue

@ -0,0 +1,16 @@
<template>
<svg
inline
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"
/>
</svg>
</template>

13
src/components/icons/ArrowUp.vue

@ -0,0 +1,13 @@
<template>
<svg
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>
</template>

13
src/components/icons/Avatar.vue

@ -0,0 +1,13 @@
<template>
<svg
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>
</template>

12
src/components/icons/Chart.vue

@ -0,0 +1,12 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="currentColor"
>
<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"
/>
</svg>
</template>

15
src/components/icons/Close.vue

@ -0,0 +1,15 @@
<template>
<svg
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="M6 18L18 6M6 6l12 12"
/>
</svg>
</template>

13
src/components/icons/Delete.vue

@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<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"
clip-rule="evenodd"
/>
</svg>
</template>

15
src/components/icons/Download.vue

@ -0,0 +1,15 @@
<template>
<svg
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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</template>

15
src/components/icons/Edit.vue

@ -0,0 +1,15 @@
<template>
<svg
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="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>
</template>

11
src/components/icons/HalfMoon.vue

@ -0,0 +1,11 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<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"
/>
</svg>
</template>

21
src/components/icons/Loading.vue

@ -0,0 +1,21 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
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>
</template>

15
src/components/icons/Logout.vue

@ -0,0 +1,15 @@
<template>
<svg
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="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>
</template>

15
src/components/icons/Moon.vue

@ -0,0 +1,15 @@
<template>
<svg
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="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>
</template>

16
src/components/icons/Plus.vue

@ -0,0 +1,16 @@
<template>
<svg
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>
</template>

15
src/components/icons/QRCode.vue

@ -0,0 +1,15 @@
<template>
<svg
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 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>
</template>

16
src/components/icons/Stack.vue

@ -0,0 +1,16 @@
<template>
<svg
inline
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="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"
/>
</svg>
</template>

15
src/components/icons/Sun.vue

@ -0,0 +1,15 @@
<template>
<svg
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="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>
</template>

17
src/components/icons/Warning.vue

@ -0,0 +1,17 @@
<template>
<!-- Heroicon name: outline/exclamation -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<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"
/>
</svg>
</template>

14
src/components/ui/Chart.vue

@ -0,0 +1,14 @@
<template>
<ClientOnly>
<apexchart width="100%" height="100%" :options="options" :series="series" />
</ClientOnly>
</template>
<script setup lang="ts">
import type { VueApexChartsComponent } from 'vue3-apexcharts';
defineProps<{
options: VueApexChartsComponent['options'];
series: VueApexChartsComponent['series'];
}>();
</script>

0
src/components/ui/Modal.vue

0
src/components/ui/NavBar.vue

6
src/composables/useColorMode.ts

@ -0,0 +1,6 @@
export const useTheme = useColorMode as () => ThemeInstance;
type ThemeInstance = ReturnType<typeof useColorMode> & {
preference: 'system' | 'dark' | 'light';
value: 'dark' | 'light';
};

47
src/config.js

@ -1,47 +0,0 @@
'use strict';
const { release: { version } } = require('./package.json');
module.exports.RELEASE = version;
module.exports.PORT = process.env.PORT || '51821';
module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
/** This is only kept for migration purpose. DO NOT USE! */
module.exports.PASSWORD = process.env.PASSWORD;
module.exports.PASSWORD_HASH = process.env.PASSWORD_HASH;
module.exports.MAX_AGE = parseInt(process.env.MAX_AGE, 10) * 1000 * 60 || 0;
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0';
module.exports.WG_HOST = process.env.WG_HOST;
module.exports.WG_PORT = process.env.WG_PORT || '51820';
module.exports.WG_CONFIG_PORT = process.env.WG_CONFIG_PORT || process.env.WG_PORT || '51820';
module.exports.WG_MTU = process.env.WG_MTU || null;
module.exports.WG_PERSISTENT_KEEPALIVE = process.env.WG_PERSISTENT_KEEPALIVE || '0';
module.exports.WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.8.0.x';
module.exports.WG_DEFAULT_DNS = typeof process.env.WG_DEFAULT_DNS === 'string'
? process.env.WG_DEFAULT_DNS
: '1.1.1.1';
module.exports.WG_ALLOWED_IPS = process.env.WG_ALLOWED_IPS || '0.0.0.0/0, ::/0';
module.exports.WG_PRE_UP = process.env.WG_PRE_UP || '';
module.exports.WG_POST_UP = process.env.WG_POST_UP || `
iptables -t nat -A POSTROUTING -s ${module.exports.WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${module.exports.WG_DEVICE} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${module.exports.WG_PORT} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
`.split('\n').join(' ');
module.exports.WG_PRE_DOWN = process.env.WG_PRE_DOWN || '';
module.exports.WG_POST_DOWN = process.env.WG_POST_DOWN || `
iptables -t nat -D POSTROUTING -s ${module.exports.WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${module.exports.WG_DEVICE} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${module.exports.WG_PORT} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
`.split('\n').join(' ');
module.exports.LANG = process.env.LANG || 'en';
module.exports.UI_TRAFFIC_STATS = process.env.UI_TRAFFIC_STATS || 'false';
module.exports.UI_CHART_TYPE = process.env.UI_CHART_TYPE || 0;
module.exports.WG_ENABLE_ONE_TIME_LINKS = process.env.WG_ENABLE_ONE_TIME_LINKS || 'false';
module.exports.UI_ENABLE_SORT_CLIENTS = process.env.UI_ENABLE_SORT_CLIENTS || 'false';
module.exports.WG_ENABLE_EXPIRES_TIME = process.env.WG_ENABLE_EXPIRES_TIME || 'false';
module.exports.ENABLE_PROMETHEUS_METRICS = process.env.ENABLE_PROMETHEUS_METRICS || 'false';
module.exports.PROMETHEUS_METRICS_PASSWORD = process.env.PROMETHEUS_METRICS_PASSWORD;

4
src/eslint.config.mjs

@ -0,0 +1,4 @@
import { createConfigForNuxt } from '@nuxt/eslint-config/flat';
import eslintConfigPrettier from 'eslint-config-prettier';
export default createConfigForNuxt().append(eslintConfigPrettier);

679
src/i18n.config.ts

@ -0,0 +1,679 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: 'en',
messages: {
en: {
name: 'Name',
password: 'Password',
signIn: 'Sign In',
logout: 'Logout',
updateAvailable: 'There is an update available!',
update: 'Update',
clients: 'Clients',
new: 'New',
deleteClient: 'Delete Client',
deleteDialog1: 'Are you sure you want to delete',
deleteDialog2: 'This action cannot be undone.',
cancel: 'Cancel',
create: 'Create',
createdOn: 'Created on ',
lastSeen: 'Last seen on ',
totalDownload: 'Total Download: ',
totalUpload: 'Total Upload: ',
newClient: 'New Client',
disableClient: 'Disable Client',
enableClient: 'Enable Client',
noClients: 'There are no clients yet.',
noPrivKey:
'This client has no known private key. Cannot create Configuration.',
showQR: 'Show QR Code',
downloadConfig: 'Download Configuration',
madeBy: 'Made by',
donate: 'Donate',
toggleCharts: 'Show/hide Charts',
theme: {
dark: 'Dark theme',
light: 'Light theme',
system: 'System theme',
},
restore: 'Restore',
backup: 'Backup',
titleRestoreConfig: 'Restore your configuration',
titleBackupConfig: 'Backup your configuration',
rememberMe: 'Remember me',
titleRememberMe: 'Stay logged after closing the browser',
sort: 'Sort',
ExpireDate: 'Expire Date',
Permanent: 'Permanent',
OneTimeLink: 'Generate short one time link',
},
ua: {
name: 'Ім`я',
password: 'Пароль',
signIn: 'Увійти',
logout: 'Вихід',
updateAvailable: 'Доступне оновлення!',
update: 'Оновити',
clients: 'Клієнти',
new: 'Новий',
deleteClient: 'Видалити клієнта',
deleteDialog1: 'Ви впевнені, що бажаєте видалити',
deleteDialog2: 'Цю дію неможливо скасувати.',
cancel: 'Скасувати',
create: 'Створити',
createdOn: 'Створено ',
lastSeen: 'Останнє підключення в ',
totalDownload: 'Всього завантажено: ',
totalUpload: 'Всього відправлено: ',
newClient: 'Новий клієнт',
disableClient: 'Вимкнути клієнта',
enableClient: 'Увімкнути клієнта',
noClients: 'Ще немає клієнтів.',
noPrivKey:
'У цього клієнта немає відомого приватного ключа. Неможливо створити конфігурацію.',
showQR: 'Показати QR-код',
downloadConfig: 'Завантажити конфігурацію',
madeBy: 'Зроблено',
donate: 'Пожертвувати',
toggleCharts: 'Показати/сховати діаграми',
theme: {
dark: 'Темна тема',
light: 'Світла тема',
system: 'Автоматична тема',
},
restore: 'Відновити',
backup: 'Резервна копія',
titleRestoreConfig: 'Відновити конфігурацію',
titleBackupConfig: 'Створити резервну копію конфігурації',
},
ru: {
name: 'Имя',
password: 'Пароль',
signIn: 'Войти',
logout: 'Выйти',
updateAvailable: 'Доступно обновление!',
update: 'Обновить',
clients: 'Клиенты',
new: 'Создать',
deleteClient: 'Удалить клиента',
deleteDialog1: 'Вы уверены, что хотите удалить',
deleteDialog2: 'Это действие невозможно отменить.',
cancel: 'Закрыть',
create: 'Создать',
createdOn: 'Создано в ',
lastSeen: 'Последнее подключение в ',
totalDownload: 'Всего скачано: ',
totalUpload: 'Всего загружено: ',
newClient: 'Создать клиента',
disableClient: 'Выключить клиента',
enableClient: 'Включить клиента',
noClients: 'Пока нет клиентов.',
noPrivKey:
'Невозможно создать конфигурацию: у клиента нет известного приватного ключа.',
showQR: 'Показать QR-код',
downloadConfig: 'Скачать конфигурацию',
madeBy: 'Автор',
donate: 'Поблагодарить',
toggleCharts: 'Показать/скрыть графики',
theme: {
dark: 'Темная тема',
light: 'Светлая тема',
system: 'Как в системе',
},
restore: 'Восстановить',
backup: 'Резервная копия',
titleRestoreConfig: 'Восстановить конфигурацию',
titleBackupConfig: 'Создать резервную копию конфигурации',
rememberMe: 'Запомнить меня',
titleRememberMe: 'Оставаться в системе после закрытия браузера',
sort: 'Сортировка',
ExpireDate: 'Дата истечения срока',
Permanent: 'Бессрочно',
OneTimeLink: 'Создать короткую одноразовую ссылку',
},
tr: {
// Müslüm Barış Korkmazer @babico
name: 'İsim',
password: 'Şifre',
signIn: 'Giriş Yap',
logout: 'Çıkış Yap',
updateAvailable: 'Mevcut bir güncelleme var!',
update: 'Güncelle',
clients: 'Kullanıcılar',
new: 'Yeni',
deleteClient: 'Kullanıcı Sil',
deleteDialog1: 'Silmek istediğine emin misin',
deleteDialog2: 'Bu işlem geri alınamaz.',
cancel: 'İptal',
create: 'Oluştur',
createdOn: 'Şu saatte oluşturuldu: ',
lastSeen: 'Son görülme tarihi: ',
totalDownload: 'Toplam İndirme: ',
totalUpload: 'Toplam Yükleme: ',
newClient: 'Yeni Kullanıcı',
disableClient: 'Kullanıcıyı Devre Dışı Bırak',
enableClient: 'Kullanıcıyı Etkinleştir',
noClients: 'Henüz kullanıcı yok.',
noPrivKey:
'Bu istemcinin bilinen bir özel anahtarı yok. Yapılandırma oluşturulamıyor.',
showQR: 'QR Kodunu Göster',
downloadConfig: 'Yapılandırmayı İndir',
madeBy: 'Yapan Kişi: ',
donate: 'Bağış Yap',
toggleCharts: 'Grafiği göster/gizle',
theme: {
dark: 'Karanlık tema',
light: 'Açık tema',
system: 'Otomatik tema',
},
restore: 'Geri yükle',
backup: 'Yedekle',
titleRestoreConfig: 'Yapılandırmanızı geri yükleyin',
titleBackupConfig: 'Yapılandırmanızı yedekleyin',
},
no: {
// github.com/digvalley
name: 'Navn',
password: 'Passord',
signIn: 'Logg Inn',
logout: 'Logg Ut',
updateAvailable: 'En ny oppdatering er tilgjengelig!',
update: 'Oppdater',
clients: 'Klienter',
new: 'Ny',
deleteClient: 'Slett Klient',
deleteDialog1: 'Er du sikker på at du vil slette?',
deleteDialog2: 'Denne handlingen kan ikke angres',
cancel: 'Avbryt',
create: 'Opprett',
createdOn: 'Opprettet ',
lastSeen: 'Sist sett ',
totalDownload: 'Total Nedlasting: ',
totalUpload: 'Total Opplasting: ',
newClient: 'Ny Klient',
disableClient: 'Deaktiver Klient',
enableClient: 'Aktiver Klient',
noClients: 'Ingen klienter opprettet enda.',
showQR: 'Vis QR Kode',
downloadConfig: 'Last Ned Konfigurasjon',
madeBy: 'Laget av',
donate: 'Doner',
},
pl: {
// github.com/archont94
name: 'Nazwa',
password: 'Hasło',
signIn: 'Zaloguj się',
logout: 'Wyloguj się',
updateAvailable: 'Dostępna aktualizacja!',
update: 'Aktualizuj',
clients: 'Klienci',
new: 'Stwórz klienta',
deleteClient: 'Usuń klienta',
deleteDialog1: 'Jesteś pewny że chcesz usunąć',
deleteDialog2: 'Tej akcji nie da się cofnąć.',
cancel: 'Anuluj',
create: 'Stwórz',
createdOn: 'Utworzono ',
lastSeen: 'Ostatnio widziany ',
totalDownload: 'Całkowite pobieranie: ',
totalUpload: 'Całkowite wysyłanie: ',
newClient: 'Nowy klient',
disableClient: 'Wyłączenie klienta',
enableClient: 'Włączenie klienta',
noClients: 'Nie ma jeszcze klientów.',
showQR: 'Pokaż kod QR',
downloadConfig: 'Pobierz konfigurację',
madeBy: 'Stworzone przez',
donate: 'Wsparcie autora',
},
fr: {
// github.com/clem3109
name: 'Nom',
password: 'Mot de passe',
signIn: 'Se Connecter',
logout: 'Se déconnecter',
updateAvailable: 'Une mise à jour est disponible !',
update: 'Mise à jour',
clients: 'Clients',
new: 'Nouveau',
deleteClient: 'Supprimer ce client',
deleteDialog1: 'Êtes-vous que vous voulez supprimer',
deleteDialog2: 'Cette action ne peut pas être annulée.',
cancel: 'Annuler',
create: 'Créer',
createdOn: 'Créé le ',
lastSeen: 'Dernière connexion le ',
totalDownload: 'Téléchargement total : ',
totalUpload: 'Téléversement total : ',
newClient: 'Nouveau client',
disableClient: 'Désactiver ce client',
enableClient: 'Activer ce client',
noClients: 'Aucun client pour le moment.',
showQR: 'Afficher le code à réponse rapide (QR Code)',
downloadConfig: 'Télécharger la configuration',
madeBy: 'Développé par',
donate: 'Soutenir',
restore: 'Restaurer',
backup: 'Sauvegarder',
titleRestoreConfig: 'Restaurer votre configuration',
titleBackupConfig: 'Sauvegarder votre configuration',
},
de: {
// github.com/florian-asche
name: 'Name',
password: 'Passwort',
signIn: 'Anmelden',
logout: 'Abmelden',
updateAvailable: 'Eine Aktualisierung steht zur Verfügung!',
update: 'Aktualisieren',
clients: 'Clients',
new: 'Neu',
deleteClient: 'Client löschen',
deleteDialog1: 'Möchtest du wirklich löschen?',
deleteDialog2: 'Diese Aktion kann nicht rückgängig gemacht werden.',
cancel: 'Abbrechen',
create: 'Erstellen',
createdOn: 'Erstellt am ',
lastSeen: 'Zuletzt Online ',
totalDownload: 'Gesamt Download: ',
totalUpload: 'Gesamt Upload: ',
newClient: 'Neuer Client',
disableClient: 'Client deaktivieren',
enableClient: 'Client aktivieren',
noClients: 'Es wurden noch keine Clients konfiguriert.',
noPrivKey:
'Es ist kein Private Key für diesen Client bekannt. Eine Konfiguration kann nicht erstellt werden.',
showQR: 'Zeige den QR Code',
downloadConfig: 'Konfiguration herunterladen',
madeBy: 'Erstellt von',
donate: 'Spenden',
restore: 'Wiederherstellen',
backup: 'Sichern',
titleRestoreConfig: 'Stelle deine Konfiguration wieder her',
titleBackupConfig: 'Sichere deine Konfiguration',
},
ca: {
// github.com/guillembonet
name: 'Nom',
password: 'Contrasenya',
signIn: 'Iniciar sessió',
logout: 'Tanca sessió',
updateAvailable: 'Hi ha una actualització disponible!',
update: 'Actualitza',
clients: 'Clients',
new: 'Nou',
deleteClient: 'Esborra client',
deleteDialog1: 'Estàs segur que vols esborrar aquest client?',
deleteDialog2: 'Aquesta acció no es pot desfer.',
cancel: 'Cancel·la',
create: 'Crea',
createdOn: 'Creat el ',
lastSeen: 'Última connexió el ',
totalDownload: 'Baixada total: ',
totalUpload: 'Pujada total: ',
newClient: 'Nou client',
disableClient: 'Desactiva client',
enableClient: 'Activa client',
noClients: 'Encara no hi ha cap client.',
showQR: 'Mostra codi QR',
downloadConfig: 'Descarrega configuració',
madeBy: 'Fet per',
donate: 'Donatiu',
},
es: {
// github.com/amarqz
name: 'Nombre',
password: 'Contraseña',
signIn: 'Iniciar sesión',
logout: 'Cerrar sesión',
updateAvailable: '¡Hay una actualización disponible!',
update: 'Actualizar',
clients: 'Clientes',
new: 'Nuevo',
deleteClient: 'Eliminar cliente',
deleteDialog1: '¿Estás seguro de que quieres borrar este cliente?',
deleteDialog2: 'Esta acción no podrá ser revertida.',
cancel: 'Cancelar',
create: 'Crear',
createdOn: 'Creado el ',
lastSeen: 'Última conexión el ',
totalDownload: 'Total descargado: ',
totalUpload: 'Total subido: ',
newClient: 'Nuevo cliente',
disableClient: 'Desactivar cliente',
enableClient: 'Activar cliente',
noClients: 'Aún no hay ningún cliente.',
showQR: 'Mostrar código QR',
downloadConfig: 'Descargar configuración',
madeBy: 'Hecho por',
donate: 'Donar',
toggleCharts: 'Mostrar/Ocultar gráficos',
theme: {
dark: 'Modo oscuro',
light: 'Modo claro',
system: 'Modo automático',
},
restore: 'Restaurar',
backup: 'Realizar copia de seguridad',
titleRestoreConfig: 'Restaurar su configuración',
titleBackupConfig: 'Realizar copia de seguridad de su configuración',
},
ko: {
name: '이름',
password: '암호',
signIn: '로그인',
logout: '로그아웃',
updateAvailable: '업데이트가 있습니다!',
update: '업데이트',
clients: '클라이언트',
new: '추가',
deleteClient: '클라이언트 삭제',
deleteDialog1: '삭제 하시겠습니까?',
deleteDialog2: '이 작업은 취소할 수 없습니다.',
cancel: '취소',
create: '생성',
createdOn: '생성일: ',
lastSeen: '마지막 사용 날짜: ',
totalDownload: '총 다운로드: ',
totalUpload: '총 업로드: ',
newClient: '새로운 클라이언트',
disableClient: '클라이언트 비활성화',
enableClient: '클라이언트 활성화',
noClients: '아직 클라이언트가 없습니다.',
showQR: 'QR 코드 표시',
downloadConfig: '구성 다운로드',
madeBy: '만든 사람',
donate: '기부',
toggleCharts: '차트 표시/숨기기',
theme: { dark: '어두운 테마', light: '밝은 테마', auto: '자동 테마' },
restore: '복원',
backup: '백업',
titleRestoreConfig: '구성 파일 복원',
titleBackupConfig: '구성 파일 백업',
},
vi: {
// https://github.com/hoangneeee
name: 'Tên',
password: 'Mật khẩu',
signIn: 'Đăng nhập',
logout: 'Đăng xuất',
updateAvailable: 'Có bản cập nhật mới!',
update: 'Cập nhật',
clients: 'Danh sách người dùng',
new: 'Mới',
deleteClient: 'Xóa người dùng',
deleteDialog1: 'Bạn có chắc chắn muốn xóa',
deleteDialog2: 'Thao tác này không thể hoàn tác.',
cancel: 'Huỷ',
create: 'Tạo',
createdOn: 'Được tạo lúc ',
lastSeen: 'Lần xem cuối vào ',
totalDownload: 'Tổng dung lượng tải xuống: ',
totalUpload: 'Tổng dung lượng tải lên: ',
newClient: 'Người dùng mới',
disableClient: 'Vô hiệu hóa người dùng',
enableClient: 'Kích hoạt người dùng',
noClients: 'Hiện chưa có người dùng nào.',
showQR: 'Hiển thị mã QR',
downloadConfig: 'Tải xuống cấu hình',
madeBy: 'Được tạo bởi',
donate: 'Ủng hộ',
toggleCharts: 'Mở/Ẩn Biểu đồ',
theme: { dark: 'Dark theme', light: 'Light theme', auto: 'Auto theme' },
restore: 'Khôi phục',
backup: 'Sao lưu',
titleRestoreConfig: 'Khôi phục cấu hình của bạn',
titleBackupConfig: 'Sao lưu cấu hình của bạn',
sort: 'Sắp xếp',
},
nl: {
name: 'Naam',
password: 'Wachtwoord',
signIn: 'Inloggen',
logout: 'Uitloggen',
updateAvailable: 'Nieuw update beschikbaar!',
update: 'update',
clients: 'clients',
new: 'Nieuw',
deleteClient: 'client verwijderen',
deleteDialog1: 'Weet je zeker dat je wilt verwijderen',
deleteDialog2: 'Deze actie kan niet ongedaan worden gemaakt.',
cancel: 'Annuleren',
create: 'Creëren',
createdOn: 'Gemaakt op ',
lastSeen: 'Laatst gezien op ',
totalDownload: 'Totaal Gedownload: ',
totalUpload: 'Totaal Geupload: ',
newClient: 'Nieuwe client',
disableClient: 'client uitschakelen',
enableClient: 'client inschakelen',
noClients: 'Er zijn nog geen clients.',
showQR: 'QR-code weergeven',
downloadConfig: 'Configuratie downloaden',
madeBy: 'Gemaakt door',
donate: 'Doneren',
},
is: {
name: 'Nafn',
password: 'Lykilorð',
signIn: 'Skrá inn',
logout: 'Útskráning',
updateAvailable: 'Það er uppfærsla í boði!',
update: 'Uppfæra',
clients: 'Viðskiptavinir',
new: 'Nýtt',
deleteClient: 'Eyða viðskiptavin',
deleteDialog1: 'Ertu viss um að þú viljir eyða',
deleteDialog2: 'Þessi aðgerð getur ekki verið afturkallað.',
cancel: 'Hætta við',
create: 'Búa til',
createdOn: 'Búið til á ',
lastSeen: 'Síðast séð á ',
totalDownload: 'Samtals Niðurhlaða: ',
totalUpload: 'Samtals Upphlaða: ',
newClient: 'Nýr Viðskiptavinur',
disableClient: 'Gera viðskiptavin óvirkan',
enableClient: 'Gera viðskiptavin virkan',
noClients: 'Engir viðskiptavinir ennþá.',
showQR: 'Sýna QR-kóða',
downloadConfig: 'Niðurhal Stillingar',
madeBy: 'Gert af',
donate: 'Gefa',
},
pt: {
name: 'Nome',
password: 'Palavra Chave',
signIn: 'Entrar',
logout: 'Sair',
updateAvailable: 'Existe uma atualização disponível!',
update: 'Atualizar',
clients: 'Clientes',
new: 'Novo',
deleteClient: 'Apagar Clientes',
deleteDialog1: 'Tem certeza que pretende apagar',
deleteDialog2: 'Esta ação não pode ser revertida.',
cancel: 'Cancelar',
create: 'Criar',
createdOn: 'Criado em ',
lastSeen: 'Último acesso em ',
totalDownload: 'Total Download: ',
totalUpload: 'Total Upload: ',
newClient: 'Novo Cliente',
disableClient: 'Desativar Cliente',
enableClient: 'Ativar Cliente',
noClients: 'Não existem ainda clientes.',
showQR: 'Apresentar o código QR',
downloadConfig: 'Descarregar Configuração',
madeBy: 'Feito por',
donate: 'Doar',
},
chs: {
name: '名称',
password: '密码',
signIn: '登录',
logout: '退出',
updateAvailable: '有新版本可用!',
update: '更新',
clients: '客户端',
new: '新建',
deleteClient: '删除客户端',
deleteDialog1: '您确定要删除',
deleteDialog2: '此操作无法撤销。',
cancel: '取消',
create: '创建',
createdOn: '创建于 ',
lastSeen: '最后访问于 ',
totalDownload: '总下载: ',
totalUpload: '总上传: ',
newClient: '新建客户端',
disableClient: '禁用客户端',
enableClient: '启用客户端',
noClients: '目前没有客户端。',
noPrivKey: '此客户端没有已知的私钥。无法创建配置。',
showQR: '显示二维码',
downloadConfig: '下载配置',
madeBy: '由',
donate: '捐赠',
toggleCharts: '显示/隐藏图表',
theme: { dark: '暗黑主题', light: '明亮主题', auto: '自动主题' },
restore: '恢复',
backup: '备份',
titleRestoreConfig: '恢复您的配置',
titleBackupConfig: '备份您的配置',
rememberMe: '记住我',
titleRememberMe: '关闭浏览器后保持登录',
sort: '排序',
ExpireDate: '到期日期',
Permanent: '永久',
OneTimeLink: '生成一次性短链接',
},
cht: {
name: '名字',
password: '密碼',
signIn: '登入',
logout: '登出',
updateAvailable: '有新版本可以使用!',
update: '更新',
clients: '使用者',
new: '建立',
deleteClient: '刪除使用者',
deleteDialog1: '您確定要刪除',
deleteDialog2: '此作業無法復原。',
cancel: '取消',
create: '建立',
createdOn: '建立於 ',
lastSeen: '最後存取於 ',
totalDownload: '總下載: ',
totalUpload: '總上傳: ',
newClient: '新用戶',
disableClient: '停用使用者',
enableClient: '啟用使用者',
noClients: '目前沒有使用者。',
noPrivKey: '此使用者沒有已知的私鑰。無法創建配置。',
showQR: '顯示 QR Code',
downloadConfig: '下載 Config 檔',
madeBy: '由',
donate: '抖內',
toggleCharts: '顯示/隱藏圖表',
theme: { dark: '暗黑主題', light: '明亮主題', auto: '自動主題' },
restore: '恢復',
backup: '備份',
titleRestoreConfig: '恢復您的配置',
titleBackupConfig: '備份您的配置',
rememberMe: '記住我',
titleRememberMe: '關閉瀏覽器後保持登錄',
sort: '排序',
ExpireDate: '到期日期',
Permanent: '永久',
OneTimeLink: '生成一次性短鏈接',
},
it: {
name: 'Nome',
password: 'Password',
signIn: 'Accedi',
logout: 'Esci',
updateAvailable: 'È disponibile un aggiornamento!',
update: 'Aggiorna',
clients: 'Client',
new: 'Nuovo',
deleteClient: 'Elimina Client',
deleteDialog1: 'Sei sicuro di voler eliminare',
deleteDialog2: 'Questa azione non può essere annullata.',
cancel: 'Annulla',
create: 'Crea',
createdOn: 'Creato il ',
lastSeen: "Visto l'ultima volta il ",
totalDownload: 'Totale Download: ',
totalUpload: 'Totale Upload: ',
newClient: 'Nuovo Client',
disableClient: 'Disabilita Client',
enableClient: 'Abilita Client',
noClients: 'Non ci sono ancora client.',
showQR: 'Mostra codice QR',
downloadConfig: 'Scarica configurazione',
madeBy: 'Realizzato da',
donate: 'Donazione',
restore: 'Ripristina',
backup: 'Backup',
titleRestoreConfig: 'Ripristina la tua configurazione',
titleBackupConfig: 'Esegui il backup della tua configurazione',
},
th: {
name: 'ชื่อ',
password: 'รหัสผ่าน',
signIn: 'ลงชื่อเข้าใช้',
logout: 'ออกจากระบบ',
updateAvailable: 'มีอัปเดตพร้อมใช้งาน!',
update: 'อัปเดต',
clients: 'Clients',
new: 'ใหม่',
deleteClient: 'ลบ Client',
deleteDialog1: 'คุณแน่ใจหรือไม่ว่าต้องการลบ',
deleteDialog2: 'การกระทำนี้;ไม่สามารถยกเลิกได้',
cancel: 'ยกเลิก',
create: 'สร้าง',
createdOn: 'สร้างเมื่อ ',
lastSeen: 'เห็นครั้งสุดท้ายเมื่อ ',
totalDownload: 'ดาวน์โหลดทั้งหมด: ',
totalUpload: 'อัพโหลดทั้งหมด: ',
newClient: 'Client ใหม่',
disableClient: 'ปิดการใช้งาน Client',
enableClient: 'เปิดการใช้งาน Client',
noClients: 'ยังไม่มี Clients เลย',
showQR: 'แสดงรหัส QR',
downloadConfig: 'ดาวน์โหลดการตั้งค่า',
madeBy: 'สร้างโดย',
donate: 'บริจาค',
},
hi: {
// github.com/rahilarious
name: 'नाम',
password: 'पासवर्ड',
signIn: 'लॉगिन',
logout: 'लॉगआउट',
updateAvailable: 'अपडेट उपलब्ध है!',
update: 'अपडेट',
clients: 'उपयोगकर्ताये',
new: 'नया',
deleteClient: 'उपयोगकर्ता हटाएँ',
deleteDialog1: 'क्या आपको पक्का हटाना है',
deleteDialog2: 'यह निर्णय पलट नहीं सकता।',
cancel: 'कुछ ना करें',
create: 'बनाएं',
createdOn: 'सर्जन तारीख ',
lastSeen: 'पिछली बार देखे गए थे ',
totalDownload: 'कुल डाउनलोड: ',
totalUpload: 'कुल अपलोड: ',
newClient: 'नया उपयोगकर्ता',
disableClient: 'उपयोगकर्ता स्थगित कीजिये',
enableClient: 'उपयोगकर्ता शुरू कीजिये',
noClients: 'अभी तक कोई भी उपयोगकर्ता नहीं है।',
noPrivKey: 'ये उपयोगकर्ता की कोई भी गुप्त चाबी नहीं हे। बना नहीं सकते।',
showQR: 'क्यू आर कोड देखिये',
downloadConfig: 'डाउनलोड कॉन्फीग्यूरेशन',
madeBy: 'सर्जक',
donate: 'दान करें',
},
},
}));

35
src/layouts/Footer.vue

@ -0,0 +1,35 @@
<template>
<footer>
<p class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs">
<a
class="hover:underline"
target="_blank"
href="https://github.com/wg-easy/wg-easy"
>WireGuard Easy</a
>
© 2021-2024 by
<a
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="https://github.com/sponsors/WeeJeWel"
target="_blank"
>{{ $t('donate') }}</a
>
</p>
</footer>
</template>
<script setup lang="ts"></script>

129
src/layouts/Header.vue

@ -0,0 +1,129 @@
<template>
<header class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
<div
:class="
isLoginPage
? 'flex justify-end'
: 'flex flex-col-reverse xxs:flex-row flex-auto items-center gap-3'
"
>
<h1
v-if="!isLoginPage"
class="text-4xl dark:text-neutral-200 font-medium flex-grow self-start mb-4"
>
<img
src="/logo.png"
width="32"
class="inline align-middle dark:bg mr-2"
/><span class="align-middle">WireGuard</span>
</h1>
<div class="flex items-center grow-0 gap-3 self-end xxs:self-center">
<!-- Dark / light theme -->
<button
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.${theme.preference}`)"
@click="toggleTheme"
>
<IconsSun v-if="theme.preference === 'light'" class="w-5 h-5" />
<IconsMoon
v-else-if="theme.preference === 'dark'"
class="w-5 h-5 text-neutral-400"
/>
<IconsHalfMoon
v-else
class="w-5 h-5 fill-gray-600 dark:fill-neutral-400"
/>
</button>
<!-- Show / hide charts -->
<label
v-if="globalStore.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
v-model="uiShowCharts"
type="checkbox"
value=""
class="sr-only peer"
@change="toggleCharts"
/>
<IconsChart
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"
/>
</label>
<span
v-if="authStore.requiresPassword && !isLoginPage"
class="text-sm text-gray-400 dark:text-neutral-400 cursor-pointer hover:underline"
@click="logout"
>
{{ $t('logout') }}
<IconsLogout class="h-3 inline" />
</span>
</div>
</div>
<div class="text-sm text-gray-400 dark:text-neutral-400 mb-5" />
<div
v-if="latestRelease"
class="bg-red-800 dark:bg-red-100 p-4 text-white dark:text-red-600 text-sm font-small mb-10 rounded-md shadow-lg"
:title="`v${currentRelease} → v${latestRelease.version}`"
>
<div class="container mx-auto flex flex-row flex-auto items-center">
<div class="flex-grow">
<p class="font-bold">{{ $t('updateAvailable') }}</p>
<p>{{ latestRelease.changelog }}</p>
</div>
<a
href="https://github.com/wg-easy/wg-easy#updating"
target="_blank"
class="p-3 rounded-md bg-white dark:bg-red-100 float-right font-sm font-semibold text-red-800 dark:text-red-600 flex-shrink-0 border-2 border-red-800 dark:border-red-600 hover:border-white dark:hover:border-red-600 hover:text-white dark:hover:text-red-100 hover:bg-red-800 dark:hover:bg-red-600 transition-all"
>
{{ $t('update') }}
</a>
</div>
</div>
</header>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const globalStore = useGlobalStore();
const route = useRoute();
const isLoginPage = computed(() => route.path == '/login');
const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>(null);
const theme = useTheme();
globalStore.fetchChartType();
const uiShowCharts = ref(getItem('uiShowCharts') === '1');
function toggleTheme() {
const themeCycle = {
system: 'light',
light: 'dark',
dark: 'system',
} as const;
theme.preference = themeCycle[theme.preference];
}
function toggleCharts() {
setItem('uiShowCharts', uiShowCharts.value ? '1' : '0');
}
async function logout(e: Event) {
e.preventDefault();
try {
await authStore.logout();
navigateTo('/login');
} catch (err) {
if (err instanceof Error) {
// TODO: better ui
alert(err.message || err.toString());
}
}
}
</script>

441
src/lib/Server.js

@ -1,441 +0,0 @@
'use strict';
const bcrypt = require('bcryptjs');
const crypto = require('node:crypto');
const basicAuth = require('basic-auth');
const { createServer } = require('node:http');
const { stat, readFile } = require('node:fs/promises');
const { resolve, sep } = require('node:path');
const expressSession = require('express-session');
const debug = require('debug')('Server');
const {
createApp,
createError,
createRouter,
defineEventHandler,
fromNodeMiddleware,
getRouterParam,
toNodeListener,
readBody,
setHeader,
serveStatic,
} = require('h3');
const WireGuard = require('../services/WireGuard');
const {
PORT,
WEBUI_HOST,
RELEASE,
PASSWORD,
PASSWORD_HASH,
MAX_AGE,
LANG,
UI_TRAFFIC_STATS,
UI_CHART_TYPE,
WG_ENABLE_ONE_TIME_LINKS,
UI_ENABLE_SORT_CLIENTS,
WG_ENABLE_EXPIRES_TIME,
ENABLE_PROMETHEUS_METRICS,
PROMETHEUS_METRICS_PASSWORD,
} = require('../config');
const requiresPassword = !!PASSWORD_HASH;
const requiresPrometheusPassword = !!PROMETHEUS_METRICS_PASSWORD;
/**
* Checks if `password` matches the PASSWORD_HASH.
*
* If environment variable is not set, the password is always invalid.
*
* @param {string} password String to test
* @returns {boolean} true if matching environment, otherwise false
*/
const isPasswordValid = (password, hash) => {
if (typeof password !== 'string') {
return false;
}
if (hash) {
return bcrypt.compareSync(password, hash);
}
return false;
};
const cronJobEveryMinute = async () => {
await WireGuard.cronJobEveryMinute();
setTimeout(cronJobEveryMinute, 60 * 1000);
};
module.exports = class Server {
constructor() {
const app = createApp();
this.app = app;
app.use(fromNodeMiddleware(expressSession({
secret: crypto.randomBytes(256).toString('hex'),
resave: true,
saveUninitialized: true,
})));
const router = createRouter();
app.use(router);
router
.get('/api/release', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return RELEASE;
}))
.get('/api/lang', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `"${LANG}"`;
}))
.get('/api/remember-me', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return MAX_AGE > 0;
}))
.get('/api/ui-traffic-stats', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${UI_TRAFFIC_STATS}`;
}))
.get('/api/ui-chart-type', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `"${UI_CHART_TYPE}"`;
}))
.get('/api/wg-enable-one-time-links', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${WG_ENABLE_ONE_TIME_LINKS}`;
}))
.get('/api/ui-sort-clients', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${UI_ENABLE_SORT_CLIENTS}`;
}))
.get('/api/wg-enable-expire-time', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${WG_ENABLE_EXPIRES_TIME}`;
}))
// Authentication
.get('/api/session', defineEventHandler((event) => {
const authenticated = requiresPassword
? !!(event.node.req.session && event.node.req.session.authenticated)
: true;
return {
requiresPassword,
authenticated,
};
}))
.get('/cnf/:clientOneTimeLink', defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const clientOneTimeLink = getRouterParam(event, 'clientOneTimeLink');
const clients = await WireGuard.getClients();
const client = clients.find((client) => client.oneTimeLink === clientOneTimeLink);
if (!client) return;
const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId });
setHeader(event, 'Content-Disposition', `attachment; filename="${clientOneTimeLink}.conf"`);
setHeader(event, 'Content-Type', 'text/plain');
return config;
}))
.post('/api/session', defineEventHandler(async (event) => {
const { password, remember } = await readBody(event);
if (!requiresPassword) {
// if no password is required, the API should never be called.
// Do not automatically authenticate the user.
throw createError({
status: 401,
message: 'Invalid state',
});
}
if (!isPasswordValid(password, PASSWORD_HASH)) {
throw createError({
status: 401,
message: 'Incorrect Password',
});
}
if (MAX_AGE && remember) {
event.node.req.session.cookie.maxAge = MAX_AGE;
}
event.node.req.session.authenticated = true;
event.node.req.session.save();
debug(`New Session: ${event.node.req.session.id}`);
return { success: true };
}));
// WireGuard
app.use(
fromNodeMiddleware((req, res, next) => {
if (!requiresPassword || !req.url.startsWith('/api/')) {
return next();
}
if (req.session && req.session.authenticated) {
return next();
}
if (req.url.startsWith('/api/') && req.headers['authorization']) {
if (isPasswordValid(req.headers['authorization'], PASSWORD_HASH)) {
return next();
}
return res.status(401).json({
error: 'Incorrect Password',
});
}
return res.status(401).json({
error: 'Not Logged In',
});
}),
);
const router2 = createRouter();
app.use(router2);
router2
.delete('/api/session', defineEventHandler((event) => {
const sessionId = event.node.req.session.id;
event.node.req.session.destroy();
debug(`Deleted Session: ${sessionId}`);
return { success: true };
}))
.get('/api/wireguard/client', defineEventHandler(() => {
return WireGuard.getClients();
}))
.get('/api/wireguard/client/:clientId/qrcode.svg', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
const svg = await WireGuard.getClientQRCodeSVG({ clientId });
setHeader(event, 'Content-Type', 'image/svg+xml');
return svg;
}))
.get('/api/wireguard/client/:clientId/configuration', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
const client = await WireGuard.getClient({ clientId });
const config = await WireGuard.getClientConfiguration({ clientId });
const configName = client.name
.replace(/[^a-zA-Z0-9_=+.-]/g, '-')
.replace(/(-{2,}|-$)/g, '-')
.replace(/-$/, '')
.substring(0, 32);
setHeader(event, 'Content-Disposition', `attachment; filename="${configName || clientId}.conf"`);
setHeader(event, 'Content-Type', 'text/plain');
return config;
}))
.post('/api/wireguard/client', defineEventHandler(async (event) => {
const { name } = await readBody(event);
const { expiredDate } = await readBody(event);
await WireGuard.createClient({ name, expiredDate });
return { success: true };
}))
.delete('/api/wireguard/client/:clientId', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
await WireGuard.deleteClient({ clientId });
return { success: true };
}))
.post('/api/wireguard/client/:clientId/enable', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
await WireGuard.enableClient({ clientId });
return { success: true };
}))
.post('/api/wireguard/client/:clientId/generateOneTimeLink', defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
await WireGuard.generateOneTimeLink({ clientId });
return { success: true };
}))
.post('/api/wireguard/client/:clientId/disable', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
await WireGuard.disableClient({ clientId });
return { success: true };
}))
.put('/api/wireguard/client/:clientId/name', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
const { name } = await readBody(event);
await WireGuard.updateClientName({ clientId, name });
return { success: true };
}))
.put('/api/wireguard/client/:clientId/address', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
const { address } = await readBody(event);
await WireGuard.updateClientAddress({ clientId, address });
return { success: true };
}))
.put('/api/wireguard/client/:clientId/expireDate', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
const { expireDate } = await readBody(event);
await WireGuard.updateClientExpireDate({ clientId, expireDate });
return { success: true };
}));
const safePathJoin = (base, target) => {
// Manage web root (edge case)
if (target === '/') {
return `${base}${sep}`;
}
// Prepend './' to prevent absolute paths
const targetPath = `.${sep}${target}`;
// Resolve the absolute path
const resolvedPath = resolve(base, targetPath);
// Check if resolvedPath is a subpath of base
if (resolvedPath.startsWith(`${base}${sep}`)) {
return resolvedPath;
}
throw createError({
status: 400,
message: 'Bad Request',
});
};
// Check Prometheus credentials
app.use(
fromNodeMiddleware((req, res, next) => {
if (!requiresPrometheusPassword || !req.url.startsWith('/metrics')) {
return next();
}
const user = basicAuth(req);
if (!user) {
res.statusCode = 401;
return { error: 'Not Logged In' };
}
if (user.pass) {
if (isPasswordValid(user.pass, PROMETHEUS_METRICS_PASSWORD)) {
return next();
}
res.statusCode = 401;
return { error: 'Incorrect Password' };
}
res.statusCode = 401;
return { error: 'Not Logged In' };
}),
);
// Prometheus Metrics API
const routerPrometheusMetrics = createRouter();
app.use(routerPrometheusMetrics);
// Prometheus Routes
routerPrometheusMetrics
.get('/metrics', defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'text/plain');
if (ENABLE_PROMETHEUS_METRICS === 'true') {
return WireGuard.getMetrics();
}
return '';
}))
.get('/metrics/json', defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
if (ENABLE_PROMETHEUS_METRICS === 'true') {
return WireGuard.getMetricsJSON();
}
return '';
}));
// backup_restore
const router3 = createRouter();
app.use(router3);
router3
.get('/api/wireguard/backup', defineEventHandler(async (event) => {
const config = await WireGuard.backupConfiguration();
setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"');
setHeader(event, 'Content-Type', 'text/json');
return config;
}))
.put('/api/wireguard/restore', defineEventHandler(async (event) => {
const { file } = await readBody(event);
await WireGuard.restoreConfiguration(file);
return { success: true };
}));
// Static assets
const publicDir = '/app/www';
app.use(
defineEventHandler((event) => {
return serveStatic(event, {
getContents: (id) => {
return readFile(safePathJoin(publicDir, id));
},
getMeta: async (id) => {
const filePath = safePathJoin(publicDir, id);
const stats = await stat(filePath).catch(() => {});
if (!stats || !stats.isFile()) {
return;
}
if (id.endsWith('.html')) setHeader(event, 'Content-Type', 'text/html');
if (id.endsWith('.js')) setHeader(event, 'Content-Type', 'application/javascript');
if (id.endsWith('.json')) setHeader(event, 'Content-Type', 'application/json');
if (id.endsWith('.css')) setHeader(event, 'Content-Type', 'text/css');
if (id.endsWith('.png')) setHeader(event, 'Content-Type', 'image/png');
return {
size: stats.size,
mtime: stats.mtimeMs,
};
},
});
}),
);
if (PASSWORD) {
throw new Error('DO NOT USE PASSWORD ENVIRONMENT VARIABLE. USE PASSWORD_HASH INSTEAD.\nSee https://github.com/wg-easy/wg-easy/blob/master/How_to_generate_an_bcrypt_hash.md');
}
createServer(toNodeListener(app)).listen(PORT, WEBUI_HOST);
debug(`Listening on http://${WEBUI_HOST}:${PORT}`);
cronJobEveryMinute();
}
};

10
src/lib/ServerError.js

@ -1,10 +0,0 @@
'use strict';
module.exports = class ServerError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
}
};

80
src/lib/Util.js

@ -1,80 +0,0 @@
'use strict';
const childProcess = require('child_process');
module.exports = class Util {
static isValidIPv4(str) {
const blocks = str.split('.');
if (blocks.length !== 4) return false;
for (let value of blocks) {
value = parseInt(value, 10);
if (Number.isNaN(value)) return false;
if (value < 0 || value > 255) return false;
}
return true;
}
static promisify(fn) {
// eslint-disable-next-line func-names
return function(req, res) {
Promise.resolve().then(async () => fn(req, res))
.then((result) => {
if (res.headersSent) return;
if (typeof result === 'undefined') {
return res
.status(204)
.end();
}
return res
.status(200)
.json(result);
})
.catch((error) => {
if (typeof error === 'string') {
error = new Error(error);
}
// eslint-disable-next-line no-console
console.error(error);
return res
.status(error.statusCode || 500)
.json({
error: error.message || error.toString(),
stack: error.stack,
});
});
};
}
static async exec(cmd, {
log = true,
} = {}) {
if (typeof log === 'string') {
// eslint-disable-next-line no-console
console.log(`$ ${log}`);
} else if (log === true) {
// eslint-disable-next-line no-console
console.log(`$ ${cmd}`);
}
if (process.platform !== 'linux') {
return '';
}
return new Promise((resolve, reject) => {
childProcess.exec(cmd, {
shell: 'bash',
}, (err, stdout) => {
if (err) return reject(err);
return resolve(String(stdout).trim());
});
});
}
};

20
src/nuxt.config.ts

@ -0,0 +1,20 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
compatibilityDate: '2024-04-03',
devtools: { enabled: true },
modules: [
'@nuxtjs/i18n',
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@eschricht/nuxt-color-mode',
],
colorMode: {
preference: 'system',
fallback: 'light',
classSuffix: '',
cookieName: 'theme',
},
});

56
src/package.json

@ -5,36 +5,50 @@
"name": "wg-easy",
"version": "1.0.1",
"description": "The easiest way to run WireGuard VPN + Web-based Admin UI.",
"main": "server.js",
"private": true,
"type": "module",
"scripts": {
"serve": "DEBUG=Server,WireGuard npx nodemon server.js",
"serve-with-password": "PASSWORD=wg npm run serve",
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css"
"format": "prettier . --write",
"format:check": "prettier . --check",
"typecheck": "nuxt typecheck"
},
"author": "Emile Nijssen",
"license": "CC BY-NC-SA 4.0",
"dependencies": {
"@eschricht/nuxt-color-mode": "^1.1.5",
"@nuxtjs/i18n": "^8.3.3",
"@nuxtjs/tailwindcss": "^6.12.1",
"@pinia/nuxt": "^0.5.3",
"@tailwindcss/forms": "^0.5.8",
"apexcharts": "^3.51.0",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"crc-32": "^1.2.2",
"debug": "^4.3.7",
"express-session": "^1.18.1",
"h3": "^1.13.0",
"qrcode": "^1.5.4"
"js-sha256": "^0.11.0",
"nuxt": "^3.12.4",
"pinia": "^2.2.1",
"qrcode": "^1.5.4",
"tailwindcss": "^3.4.10",
"timeago.js": "^4.0.2",
"vue": "latest",
"vue3-apexcharts": "^1.5.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"eslint-config-athom": "^3.1.3",
"nodemon": "^3.1.7",
"tailwindcss": "^3.4.13"
"@nuxt/eslint-config": "^0.5.0",
"@types/bcryptjs": "^2.4.6",
"@types/debug": "^4.1.12",
"@types/qrcode": "^1.5.5",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",
"typescript": "^5.5.4",
"vue-tsc": "^2.0.29"
},
"nodemonConfig": {
"ignore": [
"www/*"
]
},
"engines": {
"node": ">=18"
}
"packageManager": "[email protected]"
}

73
src/pages/index.vue

@ -0,0 +1,73 @@
<template>
<main>
<div class="container mx-auto max-w-3xl px-3 md:px-0">
<div
class="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-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 class="flex md:block md:flex-shrink-0 space-x-1">
<ClientsRestoreConfig />
<ClientsBackupConfig />
<ClientsSort />
<ClientsNew />
</div>
</div>
<div>
<Clients
v-if="clientsStore.clients && clientsStore.clients.length > 0"
/>
</div>
<ClientsEmpty
v-if="clientsStore.clients && clientsStore.clients.length === 0"
/>
<div
v-if="clientsStore.clients === null"
class="text-gray-200 dark:text-red-300 p-5"
>
<IconsLoading class="w-5 animate-spin mx-auto" />
</div>
</div>
</div>
<ClientsQRCodeDialog />
<ClientsCreateDialog />
<ClientsDeleteDialog />
</main>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
const intervalId = ref<NodeJS.Timeout | null>(null);
clientsStore.refresh();
onMounted(() => {
// TODO?: replace with websocket or similar
intervalId.value = setInterval(() => {
clientsStore
.refresh({
updateCharts: globalStore.updateCharts,
})
.catch(console.error);
}, 1000);
});
onUnmounted(() => {
if (intervalId.value !== null) {
clearInterval(intervalId.value);
intervalId.value = null;
}
});
</script>

105
src/pages/login.vue

@ -0,0 +1,105 @@
<template>
<section>
<h1
class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center"
>
<img src="/logo.png" width="32" class="inline align-middle dark:bg" />
<span class="align-middle">WireGuard</span>
</h1>
<form
class="shadow rounded-md bg-white dark:bg-neutral-700 mx-auto w-64 p-5 overflow-hidden mt-10"
@submit="login"
>
<!-- 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"
>
<IconsAvatar class="w-10 h-10 m-5 text-white dark:text-white" />
</div>
<input
v-model="password"
type="password"
name="password"
:placeholder="$t('password')"
autocomplete="current-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"
/>
<label
v-if="globalStore.rememberMeEnabled"
class="inline-block mb-5 cursor-pointer whitespace-nowrap"
:title="$t('titleRememberMe')"
>
<input v-model="remember" type="checkbox" class="sr-only" />
<div
v-if="remember"
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>
<div
v-if="!remember"
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>
<span class="text-sm">{{ $t('rememberMe') }}</span>
</label>
<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"
>
<IconsLoading class="w-5 animate-spin mx-auto" />
</button>
<input
v-else
type="submit"
:class="[
{
'bg-red-800 dark:bg-red-800 hover:bg-red-700 dark:hover:bg-red-700 transition cursor-pointer':
password,
'bg-gray-200 dark:bg-neutral-800 cursor-not-allowed': !password,
},
'w-full rounded shadow py-2 text-sm text-white dark:text-white',
]"
:value="$t('signIn')"
/>
</form>
</section>
</template>
<script setup lang="ts">
const authenticating = ref(false);
const remember = ref(false);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const globalStore = useGlobalStore();
async function login(e: Event) {
e.preventDefault();
if (!password.value) return;
if (authenticating.value) return;
authenticating.value = true;
try {
const res = await authStore.login(password.value, remember.value);
if (res) {
await navigateTo('/');
}
} catch (err) {
if (err instanceof Error) {
// TODO: replace alert with actual ui error message
alert(err.message || err.toString());
}
}
authenticating.value = false;
password.value = null;
}
</script>

5
src/plugins/apexcharts.client.ts

@ -0,0 +1,5 @@
import VueApexCharts from 'vue3-apexcharts';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueApexCharts);
});

9345
src/pnpm-lock.yaml

File diff suppressed because it is too large

0
src/www/img/apple-touch-icon.png → src/public/apple-touch-icon.png

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

0
src/www/img/favicon.png → src/public/favicon.png

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

0
src/www/img/logo.png → src/public/logo.png

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

2
src/www/manifest.json → src/public/manifest.json

@ -4,7 +4,7 @@
"background_color": "#fff",
"icons": [
{
"src": "img/favicon.png",
"src": "/favicon.png",
"type": "image/png"
}
]

29
src/server.js

@ -1,29 +0,0 @@
'use strict';
require('./services/Server');
const WireGuard = require('./services/WireGuard');
WireGuard.getConfig()
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
// Handle terminate signal
process.on('SIGTERM', async () => {
// eslint-disable-next-line no-console
console.log('SIGTERM signal received.');
await WireGuard.Shutdown();
// eslint-disable-next-line no-process-exit
process.exit(0);
});
// Handle interrupt signal
process.on('SIGINT', () => {
// eslint-disable-next-line no-console
console.log('SIGINT signal received.');
});

24
src/server/api/cnf/:clientsOnteTimeLink.ts

@ -0,0 +1,24 @@
export default defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const clientOneTimeLink = getRouterParam(event, 'clientOneTimeLink');
const clients = await WireGuard.getClients();
const client = clients.find(
(client) => client.oneTimeLink === clientOneTimeLink
);
if (!client) return;
const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId });
setHeader(
event,
'Content-Disposition',
`attachment; filename="${clientOneTimeLink}.conf"`
);
setHeader(event, 'Content-Type', 'text/plain');
return config;
});

4
src/server/api/lang.get.ts

@ -0,0 +1,4 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return LANG;
});

8
src/server/api/release.get.ts

@ -0,0 +1,8 @@
export default defineEventHandler(async () => {
const release = Number.parseInt(RELEASE, 10);
const latestRelease = await fetchLatestRelease();
return {
currentRelease: release,
latestRelease: latestRelease,
};
});

4
src/server/api/remember-me.get.ts

@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
return MAX_AGE > 0;
});

16
src/server/api/session.delete.ts

@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const sessionId = session.id;
if (sessionId === undefined) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
}
await session.clear();
SERVER_DEBUG(`Deleted Session: ${sessionId}`);
return { success: true };
});

11
src/server/api/session.get.ts

@ -0,0 +1,11 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const authenticated = REQUIRES_PASSWORD
? session.data.authenticated === true
: true;
return {
requiresPassword: REQUIRES_PASSWORD,
authenticated,
};
});

43
src/server/api/session.post.ts

@ -0,0 +1,43 @@
import type { SessionConfig } from 'h3';
export default defineEventHandler(async (event) => {
const { password, remember } = await readValidatedBody(
event,
validateZod(passwordType)
);
if (!REQUIRES_PASSWORD) {
// if no password is required, the API should never be called.
// Do not automatically authenticate the user.
throw createError({
statusCode: 401,
statusMessage: 'Invalid state',
});
}
if (!isPasswordValid(password, PASSWORD_HASH)) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect Password',
});
}
const conf: SessionConfig = SESSION_CONFIG;
if (MAX_AGE && remember) {
conf.cookie = {
...(SESSION_CONFIG.cookie ?? {}),
maxAge: MAX_AGE,
};
}
const session = await useSession(event, {
...SESSION_CONFIG,
});
const data = await session.update({
authenticated: true,
});
SERVER_DEBUG(`New Session: ${data.id}`);
return { success: true, requiresPassword: REQUIRES_PASSWORD };
});

8
src/server/api/ui-chart-type.get.ts

@ -0,0 +1,8 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
const number = Number.parseInt(UI_CHART_TYPE, 10);
if (Number.isNaN(number)) {
return 0;
}
return number;
});

5
src/server/api/ui-sort-clients.get.ts

@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
const sort = UI_ENABLE_SORT_CLIENTS;
return sort === 'true' ? true : false;
});

6
src/server/api/ui-traffic-stats.get.ts

@ -0,0 +1,6 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
// Weird issue with auto import not working. This alias is needed
const stats = UI_TRAFFIC_STATS;
return stats === 'true' ? true : false;
});

5
src/server/api/wg-enable-expire-time.get.ts

@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
const expires = WG_ENABLE_EXPIRES_TIME;
return expires === 'true' ? true : false;
});

5
src/server/api/wg-enable-one-time-links.get.ts

@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
const otl = WG_ENABLE_ONE_TIME_LINKS;
return otl === 'true' ? true : false;
});

6
src/server/api/wireguard/backup.get.ts

@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const config = await WireGuard.backupConfiguration();
setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"');
setHeader(event, 'Content-Type', 'text/json');
return config;
});

9
src/server/api/wireguard/client/[clientId]/address.put.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const { address } = await readValidatedBody(event, validateZod(addressType));
await WireGuard.updateClientAddress({ clientId, address });
return { success: true };
});

20
src/server/api/wireguard/client/[clientId]/configuration.get.ts

@ -0,0 +1,20 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const client = await WireGuard.getClient({ clientId });
const config = await WireGuard.getClientConfiguration({ clientId });
const configName = client.name
.replace(/[^a-zA-Z0-9_=+.-]/g, '-')
.replace(/(-{2,}|-$)/g, '-')
.replace(/-$/, '')
.substring(0, 32);
setHeader(
event,
'Content-Disposition',
`attachment; filename="${configName || clientId}.conf"`
);
setHeader(event, 'Content-Type', 'text/plain');
return config;
});

8
src/server/api/wireguard/client/[clientId]/disable.post.ts

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
await WireGuard.disableClient({ clientId });
return { success: true };
});

8
src/server/api/wireguard/client/[clientId]/enable.post.ts

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
await WireGuard.enableClient({ clientId });
return { success: true };
});

12
src/server/api/wireguard/client/[clientId]/expireDate.put.ts

@ -0,0 +1,12 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const { expireDate } = await readValidatedBody(
event,
validateZod(expireDateType)
);
await WireGuard.updateClientExpireDate({ clientId, expireDate });
return { success: true };
});

14
src/server/api/wireguard/client/[clientId]/generateOneTimeLink.post.ts

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
await WireGuard.generateOneTimeLink({ clientId });
return { success: true };
});

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save