Browse Source

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
pull/1244/head
Bernd Storath 12 months ago
parent
commit
881d4abbfe
  1. 2
      docker-compose.yml
  2. 2
      src/app.vue
  3. 6
      src/assets/css/app.css
  4. 71
      src/components/Client/Address.vue
  5. 42
      src/components/Client/Avatar.vue
  6. 140
      src/components/Client/Charts.vue
  7. 55
      src/components/Client/Client.vue
  8. 40
      src/components/Client/Config.vue
  9. 28
      src/components/Client/Delete.vue
  10. 51
      src/components/Client/InlineTransfer.vue
  11. 20
      src/components/Client/LastSeen.vue
  12. 76
      src/components/Client/Name.vue
  13. 38
      src/components/Client/QRCode.vue
  14. 40
      src/components/Client/Switch.vue
  15. 67
      src/components/Client/Transfer.vue
  16. 13
      src/components/Clients/Clients.vue
  17. 33
      src/components/Clients/Empty.vue
  18. 30
      src/components/Clients/New.vue
  19. 17
      src/components/ui/Chart.vue
  20. 8
      src/eslint.config.mjs
  21. 11
      src/layouts/Footer.vue
  22. 64
      src/layouts/Header.vue
  23. 1450
      src/pages/index.vue
  24. 57
      src/pages/login.vue
  25. 2
      src/server/api/session.delete.ts
  26. 2
      src/server/api/session.post.ts
  27. 14
      src/server/middleware/auth.ts
  28. 21
      src/server/middleware/session.ts
  29. 6
      src/server/plugins/shutdown.ts
  30. 34
      src/stores/auth.ts
  31. 110
      src/stores/clients.ts
  32. 81
      src/stores/global.ts
  33. 36
      src/stores/modal.ts
  34. 6
      src/tailwind.config.ts
  35. 15
      src/utils/chart.ts
  36. 31
      src/utils/math.ts

2
docker-compose.yml

@ -39,7 +39,7 @@ services:
cap_add:
- NET_ADMIN
- SYS_MODULE
# - NET_RAW # ⚠️ Uncomment if using Podman
# - NET_RAW # ⚠️ Uncomment if using Podman
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1

2
src/app.vue

@ -9,8 +9,6 @@
</template>
<script setup lang="ts">
import '~/assets/css/app.css';
useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',

6
src/assets/css/app.css

@ -1,6 +0,0 @@
[v-cloak] {
display: none;
}
.line-chart .apexcharts-svg {
transform: translateY(3px);
}

71
src/components/Client/Address.vue

@ -0,0 +1,71 @@
<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());
"
>
<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>
</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>

42
src/components/Client/Avatar.vue

@ -0,0 +1,42 @@
<template>
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative">
<svg
class="w-6 m-2 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd"
/>
</svg>
<img
v-if="client.avatar"
:src="client.avatar"
class="w-10 rounded-full absolute top-0 left-0"
/>
<div
v-if="
client.latestHandshakeAt &&
new Date().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>

140
src/components/Client/Charts.vue

@ -0,0 +1,140 @@
<template>
<div
v-if="globalStore.uiChartType"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`"
>
<ClientOnly>
<Chart :options="chartOptionsTX" :series="client.transferTxSeries" />
</ClientOnly>
</div>
<div
v-if="globalStore.uiChartType"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`"
>
<ClientOnly>
<Chart
:options="chartOptionsRX"
:series="client.transferRxSeries"
style="transform: scaleY(-1)"
/>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
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 || false;
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType].strokeWidth;
return opts;
});
const chartOptionsRX = computed(() => {
const opts = {
...chartOptions,
colors: [CHART_COLORS.rx[theme.value]],
};
opts.chart.type = UI_CHART_TYPES[globalStore.uiChartType].type || false;
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType].strokeWidth;
return opts;
});
const chartOptions = {
chart: {
type: false as string | boolean,
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,
},
},
},
};
</script>
<style scoped lang="css">
.line-chart .apexcharts-svg {
transform: translateY(3px);
}
</style>

55
src/components/Client/Client.vue

@ -0,0 +1,55 @@
<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>
</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" />
<ClientDelete :client="client" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const globalStore = useGlobalStore();
</script>

40
src/components/Client/Config.vue

@ -0,0 +1,40 @@
<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();
}
"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

28
src/components/Client/Delete.vue

@ -0,0 +1,28 @@
<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"
>
<svg
class="w-5"
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>
</button>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const modalStore = useModalStore();
</script>

51
src/components/Client/InlineTransfer.vue

@ -0,0 +1,51 @@
<template>
<!-- Inline Transfer TX -->
<span
v-if="client.transferTx"
class="whitespace-nowrap"
:title="$t('totalDownload') + bytes(client.transferTx)"
>
·
<svg
class="align-middle h-3 inline"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ bytes(client.transferTxCurrent) }}/s
</span>
<!-- Inline Transfer RX -->
<span
v-if="client.transferRx"
class="whitespace-nowrap"
:title="$t('totalUpload') + bytes(client.transferRx)"
>
·
<svg
class="align-middle h-3 inline"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ 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>

76
src/components/Client/Name.vue

@ -0,0 +1,76 @@
<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());
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</span>
</div>
</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: WGClient, 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>

38
src/components/Client/QRCode.vue

@ -0,0 +1,38 @@
<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`
"
>
<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="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>
</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>

67
src/components/Client/Transfer.vue

@ -0,0 +1,67 @@
<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)"
>
<svg
class="align-middle h-3 inline mt-0.5"
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>
<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)"
>
<svg
class="align-middle h-3 inline mt-0.5"
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>
<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>

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>

33
src/components/Clients/Empty.vue

@ -0,0 +1,33 @@
<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 = '';
"
>
<svg
class="w-4 mr-2"
inline
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span class="text-sm">{{ $t('newClient') }}</span>
</button>
</p>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

30
src/components/Clients/New.vue

@ -0,0 +1,30 @@
<template>
<button
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-l-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-r-full md:rounded inline-flex items-center transition"
@click="
modalStore.clientCreate = true;
modalStore.clientCreateName = '';
"
>
<svg
class="w-4 md:mr-2"
inline
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span class="max-md:hidden text-sm">{{ $t('new') }}</span>
</button>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
</script>

17
src/components/ui/Chart.vue

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

8
src/eslint.config.mjs

@ -1,4 +1,10 @@
import { createConfigForNuxt } from '@nuxt/eslint-config/flat';
import eslintConfigPrettier from 'eslint-config-prettier';
export default createConfigForNuxt().append(eslintConfigPrettier);
export default createConfigForNuxt()
.append({
rules: {
'vue/no-multiple-template-root': 'off',
},
})
.append(eslintConfigPrettier);

11
src/layouts/Footer.vue

@ -1,9 +1,6 @@
<template>
<footer>
<p
v-cloak
class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs"
>
<p class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs">
<a
class="hover:underline"
target="_blank"
@ -35,8 +32,4 @@
</footer>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'footer',
});
</script>
<script setup lang="ts"></script>

64
src/layouts/Header.vue

@ -1,5 +1,5 @@
<template>
<header class="container mx-auto max-w-3xl">
<header class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
<div
class="flex flex-col-reverse xxs:flex-row flex-auto items-center gap-3"
>
@ -92,7 +92,7 @@
</svg>
</label>
<span
v-if="requiresPassword && authenticated"
v-if="authStore.requiresPassword && authStore.authenticated"
class="text-sm text-gray-400 dark:text-neutral-400 cursor-pointer hover:underline"
@click="logout"
>
@ -139,32 +139,7 @@
</template>
<script setup lang="ts">
definePageMeta({
layout: 'header',
});
type ClientPersist = {
transferRxHistory: number[];
transferRxPrevious: number;
transferRxCurrent: number;
transferRxSeries: { name: string; data: number[] }[];
hoverRx?: unknown;
transferTxHistory: number[];
transferTxPrevious: number;
transferTxCurrent: number;
transferTxSeries: { name: string; data: number[] }[];
hoverTx?: unknown;
};
type LocalClient = WGClient & {
avatar?: string;
transferMax?: number;
} & Omit<ClientPersist, 'transferRxPrevious' | 'transferTxPrevious'>;
const authenticated = ref<null | boolean>(null);
const requiresPassword = ref<null | boolean>(null);
const clients = ref<null | LocalClient[]>(null);
const authStore = useAuthStore();
const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>(null);
@ -188,31 +163,16 @@ function toggleCharts() {
setItem('uiShowCharts', uiShowCharts.value ? '1' : '0');
}
function logout(e: Event) {
async function logout(e: Event) {
e.preventDefault();
api
.deleteSession()
.then(() => {
authenticated.value = false;
clients.value = null;
window.location.replace('/login');
})
.catch((err) => {
try {
await authStore.logout();
navigateTo('/login');
} catch (err) {
if (err instanceof Error) {
// TODO: better ui
alert(err.message || err.toString());
});
}
}
}
onMounted(() => {
api
.getSession()
.then((session) => {
authenticated.value = session.authenticated;
requiresPassword.value = session.requiresPassword;
})
.catch((err) => {
alert(err.message || err.toString());
});
});
</script>

1450
src/pages/index.vue

File diff suppressed because it is too large

57
src/pages/login.vue

@ -64,15 +64,16 @@
</svg>
</button>
<input
v-if="!authenticating && password"
v-else
type="submit"
class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white hover:bg-red-700 dark:hover:bg-red-700 transition cursor-pointer"
:value="$t('signIn')"
/>
<input
v-if="!authenticating && !password"
type="submit"
class="bg-gray-200 dark:bg-neutral-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed"
:class="[
{
'bg-red-800 dark:bg-red-800 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>
@ -80,43 +81,29 @@
</template>
<script setup lang="ts">
const authenticated = ref<null | boolean>(null);
const authenticating = ref(false);
const password = ref<null | string>(null);
const requiresPassword = ref<null | boolean>(null);
const authStore = useAuthStore();
function login(e: Event) {
async function login(e: Event) {
e.preventDefault();
if (!password.value) return;
if (authenticating.value) return;
authenticating.value = true;
api
.createSession({
password: password.value,
})
.then(async () => {
const session = await api.getSession();
authenticated.value = session.authenticated;
requiresPassword.value = session.requiresPassword;
window.location.replace('/');
})
.catch((err) => {
try {
const res = await authStore.login(password.value);
if (res) {
await navigateTo('/');
}
} catch (err) {
if (err instanceof Error) {
// TODO: replace alert with actual ui error message
alert(err.message || err.toString());
})
.finally(() => {
authenticating.value = false;
password.value = null;
});
}
onMounted(() => {
api.getSession().then((session) => {
if (session.authenticated || !session.requiresPassword) {
window.location.replace('/');
}
});
});
}
authenticating.value = false;
password.value = null;
}
</script>

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

@ -3,7 +3,7 @@ export default defineEventHandler(async (event) => {
const sessionId = session.id;
if (sessionId === undefined) {
return createError({
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});

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

@ -26,5 +26,5 @@ export default defineEventHandler(async (event) => {
SERVER_DEBUG(`New Session: ${data.id}`);
return { success: true };
return { success: true, requiresPassword: REQUIRES_PASSWORD };
});

14
src/server/middleware/auth.ts

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const session = await useWGSession(event);
if (url.pathname === '/login') {
if (!REQUIRES_PASSWORD || session.data.authenticated) {
return sendRedirect(event, '/', 302)
}
}
if (url.pathname === '/') {
if (!session.data.authenticated) {
return sendRedirect(event, '/login', 302)
}
}
});

21
src/server/middleware/session.ts

@ -1,18 +1,13 @@
export default defineEventHandler(async (event) => {
if (event.node.req.url === undefined) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request',
});
}
const url = getRequestURL(event);
if (
!REQUIRES_PASSWORD ||
!event.node.req.url.startsWith('/api/') ||
event.node.req.url === '/api/session' ||
event.node.req.url === '/api/lang' ||
event.node.req.url === '/api/release' ||
event.node.req.url === '/api/ui-chart-type' ||
event.node.req.url === '/api/ui-traffic-stats'
!url.pathname.startsWith('/api/') ||
url.pathname === '/api/session' ||
url.pathname === '/api/lang' ||
url.pathname === '/api/release' ||
url.pathname === '/api/ui-chart-type' ||
url.pathname === '/api/ui-traffic-stats'
) {
return;
}
@ -22,7 +17,7 @@ export default defineEventHandler(async (event) => {
}
const authorization = getHeader(event, 'Authorization');
if (event.node.req.url.startsWith('/api/') && authorization) {
if (url.pathname.startsWith('/api/') && authorization) {
if (isPasswordValid(authorization)) {
return;
}

6
src/server/plugins/shutdown.ts

@ -0,0 +1,6 @@
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('close', () => {
console.log('Shutting down');
WireGuard.Shutdown();
});
});

34
src/stores/auth.ts

@ -0,0 +1,34 @@
export const useAuthStore = defineStore('Auth', () => {
const authenticated = ref<boolean>(false);
const requiresPassword = ref<boolean>(true);
/**
* @throws if unsuccessful
*/
async function login(password: string) {
const response = await api.createSession({ password });
authenticated.value = response.success;
requiresPassword.value = response.requiresPassword;
return true as const;
}
/**
* @throws if unsuccessful
*/
async function logout() {
const response = await api.deleteSession();
authenticated.value = !response.success;
return response.success;
}
/**
* @throws if unsuccessful
*/
async function update() {
const response = await api.getSession();
authenticated.value = response.authenticated;
requiresPassword.value = response.requiresPassword;
}
return { requiresPassword, authenticated, login, logout, update };
});

110
src/stores/clients.ts

@ -0,0 +1,110 @@
import { defineStore } from 'pinia';
import { sha256 } from 'js-sha256';
export type LocalClient = WGClient & {
avatar?: string;
transferMax?: number;
} & Omit<ClientPersist, 'transferRxPrevious' | 'transferTxPrevious'>;
export type ClientPersist = {
transferRxHistory: number[];
transferRxPrevious: number;
transferRxCurrent: number;
transferRxSeries: { name: string; data: number[] }[];
hoverRx?: unknown;
transferTxHistory: number[];
transferTxPrevious: number;
transferTxCurrent: number;
transferTxSeries: { name: string; data: number[] }[];
hoverTx?: unknown;
};
export const useClientsStore = defineStore('Clients', () => {
const clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({});
async function refresh({ updateCharts = false } = {}) {
const _clients = await api.getClients();
clients.value = _clients.map((client) => {
let avatar = undefined;
if (client.name.includes('@') && client.name.includes('.')) {
avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`;
}
if (!clientsPersist.value[client.id]) {
clientsPersist.value[client.id] = {
transferRxHistory: Array(50).fill(0),
transferRxPrevious: client.transferRx ?? 0,
transferTxHistory: Array(50).fill(0),
transferTxPrevious: client.transferTx ?? 0,
transferRxCurrent: 0,
transferTxCurrent: 0,
transferRxSeries: [],
transferTxSeries: [],
};
}
const clientPersist = clientsPersist.value[client.id];
// Debug
// client.transferRx = this.clientsPersist[client.id].transferRxPrevious + Math.random() * 1000;
// client.transferTx = this.clientsPersist[client.id].transferTxPrevious + Math.random() * 1000;
// client.latestHandshakeAt = new Date();
// this.requiresPassword = true;
clientPersist.transferRxCurrent =
(client.transferRx ?? 0) - clientPersist.transferRxPrevious;
clientPersist.transferRxPrevious = client.transferRx ?? 0;
clientPersist.transferTxCurrent =
(client.transferTx ?? 0) - clientPersist.transferTxPrevious;
clientPersist.transferTxPrevious = client.transferTx ?? 0;
let transferMax = undefined;
if (updateCharts) {
clientPersist.transferRxHistory.push(clientPersist.transferRxCurrent);
clientPersist.transferRxHistory.shift();
clientPersist.transferTxHistory.push(clientPersist.transferTxCurrent);
clientPersist.transferTxHistory.shift();
clientPersist.transferTxSeries = [
{
name: 'Tx',
data: clientPersist.transferTxHistory,
},
];
clientPersist.transferRxSeries = [
{
name: 'Rx',
data: clientPersist.transferRxHistory,
},
];
transferMax = Math.max(
...clientPersist.transferTxHistory,
...clientPersist.transferRxHistory
);
}
return {
...client,
avatar,
transferTxHistory: clientPersist.transferTxHistory,
transferRxHistory: clientPersist.transferRxHistory,
transferMax,
transferTxSeries: clientPersist.transferTxSeries,
transferRxSeries: clientPersist.transferRxSeries,
transferTxCurrent: clientPersist.transferTxCurrent,
transferRxCurrent: clientPersist.transferRxCurrent,
hoverTx: clientPersist.hoverTx,
hoverRx: clientPersist.hoverRx,
};
});
}
return { clients, clientsPersist, refresh };
});

81
src/stores/global.ts

@ -0,0 +1,81 @@
import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('Global', () => {
const uiChartType = ref(0);
const uiShowCharts = ref(getItem('uiShowCharts') === '1');
const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>(
null
);
const uiTrafficStats = ref(false);
const { availableLocales, locale } = useI18n();
async function fetchRelease() {
const lang = await api.getLang();
if (lang !== getItem('lang') && availableLocales.includes(lang)) {
setItem('lang', lang);
locale.value = lang;
}
const _currentRelease = await api.getRelease();
const _latestRelease = await fetch(
'https://wg-easy.github.io/wg-easy/changelog.json'
)
.then((res) => res.json())
.then((releases) => {
const releasesArray = Object.entries(releases).map(
([version, changelog]) => ({
version: parseInt(version, 10),
changelog: changelog as string,
})
);
releasesArray.sort((a, b) => {
return b.version - a.version;
});
return releasesArray[0];
});
if (_currentRelease >= _latestRelease.version) return;
currentRelease.value = _currentRelease;
latestRelease.value = _latestRelease;
}
async function fetchChartType() {
api
.getChartType()
.then((res) => {
uiChartType.value = res;
})
.catch(() => {
uiChartType.value = 0;
});
}
async function fetchUITrafficStats() {
api
.getUITrafficStats()
.then((res) => {
uiTrafficStats.value = res;
})
.catch(() => {
uiTrafficStats.value = false;
});
}
const updateCharts = computed(() => {
return uiChartType.value > 0 && uiShowCharts.value;
});
return {
uiChartType,
uiShowCharts,
uiTrafficStats,
updateCharts,
fetchRelease,
fetchChartType,
fetchUITrafficStats,
};
});

36
src/stores/modal.ts

@ -0,0 +1,36 @@
import { defineStore } from 'pinia';
export const useModalStore = defineStore('Modal', () => {
const clientsStore = useClientsStore();
const clientDelete = ref<null | WGClient>(null);
const clientCreate = ref<null | boolean>(null);
const clientCreateName = ref<string>('');
const qrcode = ref<null | string>(null);
function createClient() {
const name = clientCreateName.value;
if (!name) return;
api
.createClient({ name })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
function deleteClient(client: WGClient | null) {
if (client === null) {
return;
}
api
.deleteClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
return {
clientDelete,
clientCreate,
clientCreateName,
qrcode,
createClient,
deleteClient,
};
});

6
src/tailwind.config.ts

@ -1,6 +1,6 @@
import type { Config } from 'tailwindcss';
import type { PluginAPI } from 'tailwindcss/types/config';
import * as colors from 'tailwindcss/colors.js';
// import { red } from 'tailwindcss/colors.js';
export default {
darkMode: 'selector',
@ -17,8 +17,8 @@ export default {
},
extend: {
colors: {
DEFAULT: colors.red[800],
primary: colors.red[800],
// DEFAULT: red[800],
// primary: red[800],
},
},
},

15
src/utils/chart.ts

@ -0,0 +1,15 @@
export const UI_CHART_TYPES = [
{ type: false, strokeWidth: 0 },
{ type: 'line', strokeWidth: 3 },
{ type: 'area', strokeWidth: 0 },
{ type: 'bar', strokeWidth: 0 },
];
export const CHART_COLORS = {
rx: { light: 'rgba(128,128,128,0.3)', dark: 'rgba(255,255,255,0.3)' },
tx: { light: 'rgba(128,128,128,0.4)', dark: 'rgba(255,255,255,0.3)' },
gradient: {
light: ['rgba(0,0,0,1.0)', 'rgba(0,0,0,1.0)'],
dark: ['rgba(128,128,128,0)', 'rgba(128,128,128,0)'],
},
};

31
src/utils/math.ts

@ -0,0 +1,31 @@
export function bytes(
bytes: number,
decimals = 2,
kib = false,
maxunit?: string
) {
if (bytes === 0) return '0 B';
if (Number.isNaN(bytes) && !Number.isFinite(bytes)) return 'NaN';
const k = kib ? 1024 : 1000;
const dm =
decimals != null && !Number.isNaN(decimals) && decimals >= 0 ? decimals : 2;
const sizes = kib
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'BiB']
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
let i = Math.floor(Math.log(bytes) / Math.log(k));
if (maxunit !== undefined) {
const index = sizes.indexOf(maxunit);
if (index !== -1) i = index;
}
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function dateTime(value: Date) {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(value);
}
Loading…
Cancel
Save