Browse Source

Merge remote-tracking branch 'k3ax0815/feat-admin' into setup-ui

pull/1402/head
tetuaoro 11 months ago
parent
commit
18f76e9ca9
  1. 30
      .github/workflows/codeql.yml
  2. 5
      .github/workflows/deploy-nightly.yml
  3. 11
      .github/workflows/deploy.yml
  4. 7
      .github/workflows/stale.yml
  5. 9
      CHANGELOG.md
  6. 5
      README.md
  7. 8
      src/app/app.vue
  8. 16
      src/app/components/Client/Charts.vue
  9. 4
      src/app/components/Client/Client.vue
  10. 2
      src/app/components/Client/LastSeen.vue
  11. 37
      src/app/components/ui/Toast.vue
  12. 92
      src/app/components/ui/UserMenu.vue
  13. 57
      src/app/layouts/Header.vue
  14. 52
      src/app/pages/admin.vue
  15. 83
      src/app/pages/admin/features.vue
  16. 10
      src/app/pages/admin/index.vue
  17. 115
      src/app/pages/admin/statistics.vue
  18. 2
      src/app/pages/index.vue
  19. 54
      src/app/pages/me.vue
  20. 20
      src/app/stores/auth.ts
  21. 33
      src/app/stores/global.ts
  22. 21
      src/app/utils/api.ts
  23. 1
      src/app/utils/math.ts
  24. 1
      src/nuxt.config.ts
  25. 1
      src/package.json
  26. 134
      src/pnpm-lock.yaml
  27. 8
      src/server/api/admin/features.post.ts
  28. 8
      src/server/api/admin/statistics.post.ts
  29. 2
      src/server/api/cnf/[oneTimeLink].ts
  30. 7
      src/server/api/features.get.ts
  31. 2
      src/server/api/lang.get.ts
  32. 21
      src/server/api/session.get.ts
  33. 5
      src/server/api/session.post.ts
  34. 4
      src/server/api/statistics.get.ts
  35. 2
      src/server/api/wireguard/client/[clientId]/generateOneTimeLink.post.ts
  36. 27
      src/server/middleware/auth.ts
  37. 88
      src/server/middleware/session.ts
  38. 17
      src/server/middleware/setup.ts
  39. 4
      src/server/utils/WireGuard.ts
  40. 8
      src/server/utils/session.ts
  41. 51
      src/server/utils/types.ts
  42. 6
      src/server/utils/wgHelper.ts
  43. 35
      src/services/database/lowdb.ts
  44. 69
      src/services/database/migrations/1.ts
  45. 55
      src/services/database/repositories/system.ts
  46. 1
      src/services/database/repositories/user.ts
  47. 9
      src/tailwind.config.ts

30
.github/workflows/codeql.yml

@ -2,9 +2,9 @@ name: "CodeQL"
on:
push:
branches: [ "master" ]
branches: ["master"]
pull_request:
branches: [ "master" ]
branches: ["master"]
schedule:
- cron: "15 0 * * *"
@ -21,21 +21,21 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
language: ["javascript-typescript"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

5
.github/workflows/deploy-nightly.yml

@ -54,4 +54,7 @@ jobs:
git config --global user.name 'Docs Deploy Bot'
git config --global user.email 'docs.deploy@users.noreply.github.com'
- name: Build Docs Website
run: mike deploy --push nightly
run: |
cd docs
git fetch origin gh-pages --depth=1
mike deploy --push --update-aliases nightly

11
.github/workflows/deploy.yml

@ -3,8 +3,8 @@ name: Build & Publish Latest
on:
workflow_dispatch:
push:
branches:
- master
tags:
- "v*"
jobs:
deploy:
@ -27,7 +27,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/wg-easy/wg-easy
@ -67,4 +67,7 @@ jobs:
git config --global user.name 'Docs Deploy Bot'
git config --global user.email 'docs.deploy@users.noreply.github.com'
- name: Build Docs Website
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
run: |
cd docs
git fetch origin gh-pages --depth=1
mike deploy --push --update-aliases ${{ github.ref_name }} latest

7
.github/workflows/stale.yml

@ -8,11 +8,10 @@ name: Mark stale issues and pull requests
on:
workflow_dispatch:
schedule:
- cron: '*/5 * * * *'
- cron: "*/5 * * * *"
jobs:
stale:
runs-on: ubuntu-latest
if: github.repository_owner == 'wg-easy'
permissions:
@ -20,8 +19,8 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v9
with:
- uses: actions/stale@v9
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"

9
CHANGELOG.md

@ -14,6 +14,15 @@ This update is an entire rewrite to make it even easier to set up your own VPN.
- Almost all Environment variables removed
- New and Improved UI
- API Basic Authentication
- Added Docs
- Incrementing Version -> Semantic Versioning
- CIDR Support
- IPv6 Support
- Changed API Structure
- Changed Database Structure
- Deprecated Dockerless Installations
- Added Docker Volume Mount
## Minor Changes

5
README.md

@ -62,6 +62,8 @@ And log in again.
### 2. Run WireGuard Easy
<!-- TODO: prioritize docker compose over docker run -->
To setup the IPv6 Network, simply run once:
```bash
@ -82,6 +84,7 @@ To automatically install & run wg-easy, simply run:
--ip6 fdcc:ad94:bacf:61a3::2a \
--ip 10.42.42.42 \
-v ~/.wg-easy:/etc/wireguard \
-v /lib/modules:/lib/modules:ro \
-p 51820:51820/udp \
-p 51821:51821/tcp \
--cap-add NET_ADMIN \
@ -97,7 +100,7 @@ To automatically install & run wg-easy, simply run:
The Web UI will now be available on `http://0.0.0.0:51821`.
The Prometheus metrics will now be available on `http://0.0.0.0:51821/metrics`. Grafana dashboard [21733](https://grafana.com/grafana/dashboards/21733-wireguard/)
The Prometheus metrics will now be available on `http://0.0.0.0:51821/api/metrics`. Grafana dashboard [21733](https://grafana.com/grafana/dashboards/21733-wireguard/)
> 💡 Your configuration files will be saved in `~/.wg-easy`

8
src/app/app.vue

@ -1,7 +1,12 @@
<template>
<NuxtLayout>
<NuxtLayout name="header" />
<NuxtPage />
<ToastProvider>
<NuxtPage />
<ToastViewport
class="[--viewport-padding:_25px] fixed bottom-0 right-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
/>
</ToastProvider>
<NuxtLayout name="footer" />
</NuxtLayout>
</template>
@ -11,6 +16,7 @@ const globalStore = useGlobalStore();
globalStore.fetchFeatures();
globalStore.fetchRelease();
globalStore.setLanguage();
globalStore.fetchStatistics();
useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',

16
src/app/components/Client/Charts.vue

@ -1,13 +1,13 @@
<template>
<div
v-if="globalStore.features.trafficStats.type"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
v-if="globalStore.statistics.chartType"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.statistics.chartType === 1 && 'line-chart'}`"
>
<UiChart :options="chartOptionsTX" :series="client.transferTxSeries" />
</div>
<div
v-if="globalStore.features.trafficStats.type"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
v-if="globalStore.statistics.chartType"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.statistics.chartType === 1 && 'line-chart'}`"
>
<UiChart
:options="chartOptionsRX"
@ -33,9 +33,9 @@ const chartOptionsTX = computed(() => {
colors: [CHART_COLORS.tx[theme.value]],
};
opts.chart.type =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined;
UI_CHART_TYPES[globalStore.statistics.chartType]?.type || undefined;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
UI_CHART_TYPES[globalStore.statistics.chartType]?.strokeWidth ?? 0;
return opts;
});
@ -45,9 +45,9 @@ const chartOptionsRX = computed(() => {
colors: [CHART_COLORS.rx[theme.value]],
};
opts.chart.type =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined;
UI_CHART_TYPES[globalStore.statistics.chartType]?.type || undefined;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
UI_CHART_TYPES[globalStore.statistics.chartType]?.strokeWidth ?? 0;
return opts;
});

4
src/app/components/Client/Client.vue

@ -14,7 +14,7 @@
>
<ClientAddress4 :client="client" />
<ClientInlineTransfer
v-if="!globalStore.features.trafficStats.enabled"
v-if="!globalStore.statistics.enabled"
:client="client"
/>
<ClientLastSeen :client="client" />
@ -25,7 +25,7 @@
<!-- Info -->
<div
v-if="globalStore.features.trafficStats.enabled"
v-if="globalStore.statistics.enabled"
class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end"
>
<ClientTransfer :client="client" />

2
src/app/components/Client/LastSeen.vue

@ -4,7 +4,7 @@
class="text-gray-400 dark:text-neutral-500 whitespace-nowrap"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"
>
{{ !globalStore.features.trafficStats.enabled ? ' · ' : ''
{{ !globalStore.statistics.enabled ? ' · ' : ''
}}{{ timeago(new Date(client.latestHandshakeAt)) }}
</span>
</template>

37
src/app/components/ui/Toast.vue

@ -0,0 +1,37 @@
<script setup lang="ts">
import {
ToastAction,
ToastClose,
ToastDescription,
ToastRoot,
ToastTitle,
} from 'radix-vue';
defineProps<{
title: string;
content: string;
}>();
</script>
<template>
<ToastRoot
class="bg-white rounded-md shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] p-[15px] grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center data-[state=open]:animate-slideIn data-[state=closed]:animate-hide data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-swipeOut"
>
<ToastTitle
v-if="title"
class="[grid-area:_title] mb-[5px] font-medium text-slate12 text-[15px]"
>
{{ title }}
</ToastTitle>
<ToastDescription
class="[grid-area:_description] m-0 text-slate11 text-[13px] leading-[1.3]"
>{{ content }}</ToastDescription
>
<ToastAction as-child alt-text="toast" class="[grid-area:_action]">
<slot />
</ToastAction>
<ToastClose aria-label="Close">
<span aria-hidden>×</span>
</ToastClose>
</ToastRoot>
</template>

92
src/app/components/ui/UserMenu.vue

@ -0,0 +1,92 @@
<template>
<DropdownMenuRoot v-model:open="toggleState">
<DropdownMenuTrigger>
<button
class="flex items-center pe-1 font-medium text-sm text-gray-400 rounded-full hover:text-red-800 dark:hover:text-red-800 md:me-0 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:text-neutral-400"
type="button"
>
<AvatarRoot
class="inline-flex h-8 w-8 select-none items-center justify-center overflow-hidden rounded-full align-middle mr-2"
>
<AvatarFallback
class="text-grass11 leading-1 flex h-full w-full items-center justify-center bg-white text-[15px] font-medium"
:delay-ms="600"
>
{{ fallbackName }}
</AvatarFallback>
</AvatarRoot>
{{ authStore.userData?.name }}
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="5"
class="z-10 bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-neutral-700 dark:divide-neutral-800 text-gray-700 dark:text-gray-200"
>
<DropdownMenuItem>
<div class="truncate">{{ authStore.userData?.name }}</div>
<div class="truncate">@{{ authStore.userData?.username }}</div>
</DropdownMenuItem>
<DropdownMenuItem>
<NuxtLink
to="/"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Clients
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<NuxtLink
to="/me"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Account
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem v-if="authStore.userData?.role === 'ADMIN'">
<NuxtLink
to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Admin Panel
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<button
class="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
@click.prevent="logout"
>
<IconsLogout class="h-5" />
{{ $t('logout') }}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const toggleState = ref(false);
async function logout() {
try {
await authStore.logout();
navigateTo('/login');
} catch (err) {
if (err instanceof Error) {
// TODO: better ui
alert(err.message || err.toString());
}
}
}
const fallbackName = computed(() => {
return authStore.userData?.name
.split(' ')
.map((word) => word.charAt(0).toUpperCase())
.slice(0, 2)
.join('');
});
</script>

57
src/app/layouts/Header.vue

@ -2,21 +2,23 @@
<header class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
<div
:class="
isLoginPage
hasOwnLogo
? '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>
<NuxtLink to="/" class="flex-grow self-start mb-4">
<h1
v-if="!hasOwnLogo"
class="text-4xl dark:text-neutral-200 font-medium"
>
<img
src="/logo.png"
width="32"
class="inline align-middle dark:bg mr-2"
/><span class="align-middle">WireGuard</span>
</h1>
</NuxtLink>
<div class="flex items-center grow-0 gap-3 self-end xxs:self-center">
<!-- Dark / light theme -->
<button
@ -36,7 +38,7 @@
</button>
<!-- Show / hide charts -->
<label
v-if="globalStore.features.trafficStats.type > 0"
v-if="globalStore.statistics.chartType > 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')"
>
@ -51,14 +53,7 @@
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>
<UiUserMenu v-if="loggedIn" />
</div>
</div>
<div class="text-sm text-gray-400 dark:text-neutral-400 mb-5" />
@ -86,11 +81,16 @@
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const globalStore = useGlobalStore();
const route = useRoute();
const isLoginPage = computed(() => route.path == '/login');
const hasOwnLogo = computed(
() => route.path === '/login' || route.path === '/setup'
);
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/setup'
);
const theme = useTheme();
const uiShowCharts = ref(getItem('uiShowCharts') === '1');
@ -108,17 +108,4 @@ function toggleTheme() {
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>

52
src/app/pages/admin.vue

@ -0,0 +1,52 @@
<template>
<div>
<div class="container mx-auto p-4">
<div class="flex">
<div class="w-64 bg-white dark:bg-neutral-700 rounded-lg p-4 mr-4">
<NuxtLink to="/admin">
<h2 class="text-xl font-bold dark:text-neutral-200 mb-4">
Admin Panel
</h2>
</NuxtLink>
<div class="space-y-2 flex flex-col">
<NuxtLink
v-for="(item, index) in menuItems"
:key="index"
:to="`/admin/${item.id}`"
>
<BaseButton
class="font-medium dark:text-neutral-200 p-2 rounded cursor-pointer hover:bg-red-800 transition-colors duration-200 w-full"
>
{{ item.name }}
</BaseButton>
</NuxtLink>
</div>
</div>
<div
class="flex-1 bg-white dark:text-neutral-200 dark:bg-neutral-700 rounded-lg p-6"
>
<h1 class="text-3xl font-bold mb-6">{{ activeMenuItem?.name }}</h1>
<NuxtPage />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const route = useRoute();
const menuItems = [
{ id: 'features', name: 'Features' },
{ id: 'statistics', name: 'Statistics' },
{ id: 'metrics', name: 'Metrics' },
];
const activeMenuItem = computed(() => {
return menuItems.find((item) => route.path === `/admin/${item.id}`);
});
</script>

83
src/app/pages/admin/features.vue

@ -0,0 +1,83 @@
<template>
<div class="flex flex-col">
<div v-for="(feature, key) in featuresData" :key="key" class="space-y-2">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200">
{{ feature.name }}
</h3>
<p class="text-sm text-gray-500 dark:text-neutral-300">
{{ feature.description }}
</p>
</div>
<SwitchRoot
:checked="feature.enabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-800"
:class="feature.enabled ? 'bg-red-800' : 'bg-gray-200'"
@update:checked="toggleFeature(key)"
>
<SwitchThumb
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="feature.enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</SwitchRoot>
</div>
</div>
<BaseButton class="self-end" @click="submit">Save</BaseButton>
<UiToast
v-model:open="open"
title="Saved successfully"
content="Features saved successfully"
/>
</div>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
const open = ref(false);
type ExtendedFeatures = {
[K in keyof (typeof globalStore)['features']]: {
name: string;
description: string;
enabled: boolean;
};
};
const featuresData = ref({
sortClients: {
name: 'Sort Clients',
description: 'Be able to sort Clients by Name',
enabled: globalStore.features.sortClients.enabled,
},
oneTimeLinks: {
name: 'One Time Links',
description: 'Be able to generate One Time Link to download Config',
enabled: globalStore.features.oneTimeLinks.enabled,
},
clientExpiration: {
name: 'Client Expiration',
description: 'Be able to set Date when Client will be disabled',
enabled: globalStore.features.clientExpiration.enabled,
},
} satisfies ExtendedFeatures);
function toggleFeature(key: keyof ExtendedFeatures) {
const feat = featuresData.value[key];
if (!feat) {
return;
}
feat.enabled = !feat.enabled;
}
async function submit() {
const response = await $fetch('/api/admin/features', {
method: 'post',
body: { features: featuresData.value },
});
if (response.success) {
open.value = true;
}
globalStore.fetchFeatures();
}
</script>

10
src/app/pages/admin/index.vue

@ -0,0 +1,10 @@
<template>
<div>
This is the Admin Panel. Your are running wg-easy
{{ globalStore.currentRelease }}
</div>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
</script>

115
src/app/pages/admin/statistics.vue

@ -0,0 +1,115 @@
<template>
<div class="flex flex-col">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200">
Traffic Stats
</h3>
<p class="text-sm text-gray-500 dark:text-neutral-300">
Show more concise Stats about Traffic Usage
</p>
</div>
<SwitchRoot
v-model:checked="enabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-800"
:class="enabled ? 'bg-red-800' : 'bg-gray-200'"
>
<SwitchThumb
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</SwitchRoot>
</div>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200">
Chart Type
</h3>
<p class="text-sm text-gray-500 dark:text-neutral-300">
Select Type of Chart you want to show
</p>
</div>
<SelectRoot v-model="chartType">
<SelectTrigger
class="inline-flex min-w-[160px] items-center justify-between rounded px-[15px] text-[13px] leading-none h-[35px] gap-[5px] bg-white text-grass11 shadow-[0_2px_10px] shadow-black/10 hover:bg-mauve3 focus:shadow-[0_0_0_2px] focus:shadow-black data-[placeholder]:text-green9 outline-none"
aria-label="Customize options"
>
<SelectValue placeholder="Select a fruit..." />
<IconsArrowDown class="h-3.5 w-3.5" />
</SelectTrigger>
<SelectPortal>
<SelectContent
class="min-w-[160px] bg-white rounded shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)] will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-[100]"
:side-offset="5"
>
<SelectScrollUpButton
class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default"
>
<IconsArrowUp />
</SelectScrollUpButton>
<SelectViewport class="p-[5px]">
<SelectItem
v-for="(option, index) in options"
:key="index"
class="text-[13px] leading-none text-grass11 rounded-[3px] flex items-center h-[25px] pr-[35px] pl-[25px] relative select-none data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-green9 data-[highlighted]:text-green1"
:value="option"
>
<SelectItemText>
{{ option }}
</SelectItemText>
</SelectItem>
</SelectViewport>
<SelectScrollDownButton
class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default"
>
<IconsArrowDown />
</SelectScrollDownButton>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<BaseButton class="self-end" @click="submit">Save</BaseButton>
<UiToast
v-model:open="open"
title="Saved successfully"
content="Statistics saved successfully"
/>
</div>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
const open = ref(false);
const enabled = ref(globalStore.statistics.enabled);
const options: Record<number, string> = {
0: 'None',
1: 'Line',
2: 'Area',
3: 'Bar',
};
const stringToIndex = Object.entries(options).reduce(
(obj, [k, v]) => {
obj[v] = Number.parseInt(k);
return obj;
},
{} as Record<string, number>
);
const chartType = ref(options[globalStore.statistics.chartType]);
async function submit() {
const response = await $fetch('/api/admin/statistics', {
method: 'post',
body: {
statistics: {
enabled: enabled.value,
chartType: stringToIndex[chartType.value!],
},
},
});
if (response.success) {
open.value = true;
}
globalStore.fetchStatistics();
}
</script>

2
src/app/pages/index.vue

@ -54,6 +54,8 @@ const intervalId = ref<NodeJS.Timeout | null>(null);
clientsStore.refresh();
onMounted(() => {
// TODO: remove (to avoid console spam)
return;
// TODO?: replace with websocket or similar
intervalId.value = setInterval(() => {
clientsStore

54
src/app/pages/me.vue

@ -0,0 +1,54 @@
<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">Account</p>
</div>
</div>
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-[15px] px-5">
<Label class="font-semibold dark:text-neutral-200" for="username">
Username
</Label>
<input id="username" v-model.trim="username" type="text" />
</div>
<div class="flex flex-wrap items-center gap-[15px] px-5">
<Label class="font-semibold dark:text-neutral-200" for="name">
Name
</Label>
<input id="name" v-model.trim="name" type="text" />
</div>
<div class="flex flex-wrap items-center gap-[15px] px-5">
<Label class="font-semibold dark:text-neutral-200" for="name">
E-Mail
</Label>
<input id="name" v-model.trim="email" type="text" />
</div>
<BaseButton class="self-end" @click="openPasswordModal">
Change Password
</BaseButton>
<BaseButton class="self-end" @click="submit">Save</BaseButton>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const username = ref(authStore.userData?.username);
const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
function submit() {}
function openPasswordModal() {}
</script>

20
src/app/stores/auth.ts

@ -1,5 +1,10 @@
export const useAuthStore = defineStore('Auth', () => {
const requiresPassword = ref<boolean>(true);
const userData = ref<null | {
name: string;
username: string;
role: string;
email: string | null;
}>();
/**
* @throws if unsuccessful
@ -13,8 +18,7 @@ export const useAuthStore = defineStore('Auth', () => {
* @throws if unsuccessful
*/
async function login(username: string, password: string, remember: boolean) {
const response = await api.createSession({ username, password, remember });
requiresPassword.value = response.requiresPassword;
await api.createSession({ username, password, remember });
return true as const;
}
@ -26,13 +30,11 @@ export const useAuthStore = defineStore('Auth', () => {
return response.success;
}
/**
* @throws if unsuccessful
*/
async function update() {
const session = await api.getSession();
requiresPassword.value = session.requiresPassword;
// store role etc
const { data: response } = await api.getSession();
userData.value = response.value;
}
return { requiresPassword, login, logout, update, signup };
return { userData, login, logout, update, signup };
});

33
src/app/stores/global.ts

@ -8,10 +8,6 @@ export const useGlobalStore = defineStore('Global', () => {
);
const updateAvailable = ref(false);
const features = ref({
trafficStats: {
enabled: false,
type: 0,
},
sortClients: {
enabled: false,
},
@ -22,12 +18,18 @@ export const useGlobalStore = defineStore('Global', () => {
enabled: false,
},
});
const statistics = ref({
enabled: false,
chartType: 0,
});
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const { availableLocales, locale } = useI18n();
async function setLanguage() {
const { data: lang } = await api.getLang();
const { data: lang } = await useFetch('/api/lang', {
method: 'get',
});
if (
lang.value !== getItem('lang') &&
availableLocales.includes(lang.value!)
@ -38,7 +40,9 @@ export const useGlobalStore = defineStore('Global', () => {
}
async function fetchRelease() {
const { data: release } = await api.getRelease();
const { data: release } = await useFetch('/api/release', {
method: 'get',
});
if (!release.value) {
return;
@ -50,14 +54,25 @@ export const useGlobalStore = defineStore('Global', () => {
}
async function fetchFeatures() {
const { data: apiFeatures } = await api.getFeatures();
const { data: apiFeatures } = await useFetch('/api/features', {
method: 'get',
});
if (apiFeatures.value) {
features.value = apiFeatures.value;
}
}
async function fetchStatistics() {
const { data: apiStatistics } = await useFetch('/api/statistics', {
method: 'get',
});
if (apiStatistics.value) {
statistics.value = apiStatistics.value;
}
}
const updateCharts = computed(() => {
return features.value.trafficStats.type > 0 && uiShowCharts.value;
return statistics.value.chartType > 0 && uiShowCharts.value;
});
return {
@ -68,8 +83,10 @@ export const useGlobalStore = defineStore('Global', () => {
currentRelease,
latestRelease,
updateAvailable,
statistics,
fetchRelease,
fetchFeatures,
setLanguage,
fetchStatistics,
};
});

21
src/app/utils/api.ts

@ -1,19 +1,6 @@
class API {
async getRelease() {
return useFetch('/api/release', {
method: 'get',
});
}
async getLang() {
return useFetch('/api/lang', {
method: 'get',
});
}
async getSession() {
// TODO?: use useFetch
return $fetch('/api/session', {
return useFetch('/api/session', {
method: 'get',
});
}
@ -140,12 +127,6 @@ class API {
body: { username, password },
});
}
async getFeatures() {
return useFetch('/api/features', {
method: 'get',
});
}
}
type WGClientReturn = Awaited<

1
src/app/utils/math.ts

@ -21,6 +21,7 @@ export function bytes(
}
export function dateTime(value: Date) {
// TODO: results in mismatch because of different locales
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',

1
src/nuxt.config.ts

@ -10,6 +10,7 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@eschricht/nuxt-color-mode',
'radix-vue/nuxt',
],
colorMode: {
preference: 'system',

1
src/package.json

@ -34,6 +34,7 @@
"nuxt": "^3.13.0",
"pinia": "^2.2.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.5",
"semver": "^7.6.3",
"tailwindcss": "^3.4.10",
"timeago.js": "^4.0.2",

134
src/pnpm-lock.yaml

@ -62,6 +62,9 @@ importers:
qrcode:
specifier: ^1.5.4
version: 1.5.4
radix-vue:
specifier: ^1.9.5
version: 1.9.5(vue@3.4.38(typescript@5.5.4))
semver:
specifier: ^7.6.3
version: 7.6.3
@ -751,6 +754,18 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@floating-ui/core@1.6.7':
resolution: {integrity: sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==}
'@floating-ui/dom@1.6.10':
resolution: {integrity: sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==}
'@floating-ui/utils@0.2.7':
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
'@floating-ui/vue@1.1.4':
resolution: {integrity: sha512-ammH7T3vyCx7pmm9OF19Wc42zrGnUw0QvLoidgypWsCLJMtGXEwY7paYIHO+K+oLC3mbWpzIHzeTVienYenlNg==}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
@ -759,6 +774,12 @@ packages:
resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==}
engines: {node: '>=18.18'}
'@internationalized/date@3.5.5':
resolution: {integrity: sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==}
'@internationalized/number@3.5.3':
resolution: {integrity: sha512-rd1wA3ebzlp0Mehj5YTuTI50AQEx80gWFyHcQu+u91/5NgdwBecO8BH6ipPfE+lmQ9d63vpB3H9SHoIUiupllw==}
'@intlify/bundle-utils@7.5.1':
resolution: {integrity: sha512-UovJl10oBIlmYEcWw+VIHdKY5Uv5sdPG0b/b6bOYxGLln3UwB75+2dlc0F3Fsa0RhoznQ5Rp589/BZpABpE4Xw==}
engines: {node: '>= 14.16'}
@ -1209,11 +1230,22 @@ packages:
peerDependencies:
eslint: '>=8.40.0'
'@swc/helpers@0.5.13':
resolution: {integrity: sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==}
'@tailwindcss/forms@0.5.9':
resolution: {integrity: sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==}
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20'
'@tanstack/virtual-core@3.10.7':
resolution: {integrity: sha512-ND5dfsU0n9F4gROzwNNDJmg6y8n9pI8YWxtgbfJ5UcNn7Hx+MxEXtXcQ189tS7sh8pmCObgz2qSiyRKTZxT4dg==}
'@tanstack/vue-virtual@3.10.7':
resolution: {integrity: sha512-OSK1fkvz4GaBhF80KVmBsJZoMI9ncVaUU//pI8OqTdBnepw467zcuF2Y+Ia1VC0CPYfUEALyS8n4Ar0RI/7ASg==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@ -1251,6 +1283,9 @@ packages:
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@typescript-eslint/eslint-plugin@8.4.0':
resolution: {integrity: sha512-rg8LGdv7ri3oAlenMACk9e+AR4wUV0yrrG+XKsGKOK0EVgeEDqurkXMPILG2836fW4ibokTB5v4b6Z9+GYQDEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1430,6 +1465,15 @@ packages:
'@vue/shared@3.4.38':
resolution: {integrity: sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==}
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@yr/monotone-cubic-spline@1.0.3':
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
@ -1534,6 +1578,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.4:
resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==}
engines: {node: '>=10'}
ast-kit@1.1.0:
resolution: {integrity: sha512-RlNqd4u6c/rJ5R+tN/ZTtyNrH8X0NHCvyt6gD8RHa3JjzxxHWoyaU0Ujk3Zjbh7IZqrYl1Sxm6XzZifmVxXxHQ==}
engines: {node: '>=16.14.0'}
@ -3553,6 +3601,11 @@ packages:
queue-tick@1.0.1:
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
radix-vue@1.9.5:
resolution: {integrity: sha512-vtCq+WDAZj5BQtJiChGf/oC7w3y7jaod3agcntgph7fD6aqdcghLZYcUWdgT/XNJs2bEsk+3cjK3ONPRNeFcuQ==}
peerDependencies:
vue: '>= 3.2.0'
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
@ -4992,10 +5045,38 @@ snapshots:
'@fastify/busboy@2.1.1': {}
'@floating-ui/core@1.6.7':
dependencies:
'@floating-ui/utils': 0.2.7
'@floating-ui/dom@1.6.10':
dependencies:
'@floating-ui/core': 1.6.7
'@floating-ui/utils': 0.2.7
'@floating-ui/utils@0.2.7': {}
'@floating-ui/vue@1.1.4(vue@3.4.38(typescript@5.5.4))':
dependencies:
'@floating-ui/dom': 1.6.10
'@floating-ui/utils': 0.2.7
vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.3.0': {}
'@internationalized/date@3.5.5':
dependencies:
'@swc/helpers': 0.5.13
'@internationalized/number@3.5.3':
dependencies:
'@swc/helpers': 0.5.13
'@intlify/bundle-utils@7.5.1(vue-i18n@9.14.0(vue@3.4.38(typescript@5.5.4)))':
dependencies:
'@intlify/message-compiler': 9.14.0
@ -5658,11 +5739,22 @@ snapshots:
- supports-color
- typescript
'@swc/helpers@0.5.13':
dependencies:
tslib: 2.7.0
'@tailwindcss/forms@0.5.9(tailwindcss@3.4.10)':
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.10
'@tanstack/virtual-core@3.10.7': {}
'@tanstack/vue-virtual@3.10.7(vue@3.4.38(typescript@5.5.4))':
dependencies:
'@tanstack/virtual-core': 3.10.7
vue: 3.4.38(typescript@5.5.4)
'@trysound/sax@0.2.0': {}
'@types/debug@4.1.12':
@ -5698,6 +5790,8 @@ snapshots:
'@types/semver@7.5.8': {}
'@types/web-bluetooth@0.0.20': {}
'@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)':
dependencies:
'@eslint-community/regexpp': 4.11.0
@ -5992,6 +6086,25 @@ snapshots:
'@vue/shared@3.4.38': {}
'@vueuse/core@10.11.1(vue@3.4.38(typescript@5.5.4))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.4.38(typescript@5.5.4))
vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/shared@10.11.1(vue@3.4.38(typescript@5.5.4))':
dependencies:
vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@yr/monotone-cubic-spline@1.0.3': {}
abbrev@1.1.1: {}
@ -6104,6 +6217,10 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.4:
dependencies:
tslib: 2.7.0
ast-kit@1.1.0:
dependencies:
'@babel/parser': 7.25.6
@ -8309,6 +8426,23 @@ snapshots:
queue-tick@1.0.1: {}
radix-vue@1.9.5(vue@3.4.38(typescript@5.5.4)):
dependencies:
'@floating-ui/dom': 1.6.10
'@floating-ui/vue': 1.1.4(vue@3.4.38(typescript@5.5.4))
'@internationalized/date': 3.5.5
'@internationalized/number': 3.5.3
'@tanstack/vue-virtual': 3.10.7(vue@3.4.38(typescript@5.5.4))
'@vueuse/core': 10.11.1(vue@3.4.38(typescript@5.5.4))
'@vueuse/shared': 10.11.1(vue@3.4.38(typescript@5.5.4))
aria-hidden: 1.2.4
defu: 6.1.4
fast-deep-equal: 3.1.3
nanoid: 5.0.7
vue: 3.4.38(typescript@5.5.4)
transitivePeerDependencies:
- '@vue/composition-api'
radix3@1.1.2: {}
randombytes@2.1.0:

8
src/server/api/admin/features.post.ts

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const { features } = await readValidatedBody(
event,
validateZod(featuresType)
);
await Database.system.updateFeatures(features);
return { success: true };
});

8
src/server/api/admin/statistics.post.ts

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const { statistics } = await readValidatedBody(
event,
validateZod(statisticsType)
);
await Database.system.updateStatistics(statistics);
return { success: true };
});

2
src/server/api/cnf/[oneTimeLink].ts

@ -1,6 +1,6 @@
export default defineEventHandler(async (event) => {
const system = await Database.system.get();
if (!system.oneTimeLinks.enabled) {
if (!system.features.oneTimeLinks.enabled) {
throw createError({
statusCode: 404,
statusMessage: 'Invalid state',

7
src/server/api/features.get.ts

@ -1,9 +1,4 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return {
trafficStats: system.trafficStats,
sortClients: system.sortClients,
clientExpiration: system.clientExpiration,
oneTimeLinks: system.oneTimeLinks,
};
return system.features;
});

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

@ -1,5 +1,5 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.system.get();
return system.lang;
return system.general.lang;
});

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

@ -1,9 +1,24 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const authenticated = session.data.authenticated;
if (!session.data.userId) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
}
const user = await Database.user.findById(session.data.userId);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'Not found in Database',
});
}
return {
requiresPassword: true,
authenticated,
role: user.role,
username: user.username,
name: user.name,
email: user.email,
};
});

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

@ -30,16 +30,15 @@ export default defineEventHandler(async (event) => {
if (remember) {
conf.cookie = {
...(system.sessionConfig.cookie ?? {}),
maxAge: system.sessionTimeout,
maxAge: system.general.sessionTimeout,
};
}
const session = await useSession(event, {
const session = await useSession<WGSession>(event, {
...system.sessionConfig,
});
const data = await session.update({
authenticated: true,
userId: user.id,
});

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

@ -0,0 +1,4 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return system.statistics;
});

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

@ -1,6 +1,6 @@
export default defineEventHandler(async (event) => {
const system = await Database.system.get();
if (!system.oneTimeLinks.enabled) {
if (!system.features.oneTimeLinks.enabled) {
throw createError({
status: 404,
message: 'Invalid state',

27
src/server/middleware/auth.ts

@ -1,14 +1,35 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const session = await useWGSession(event);
// Api handled by session, Setup handled with setup middleware
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/setup')) {
return;
}
if (url.pathname === '/login') {
if (session.data.authenticated) {
if (session.data.userId) {
return sendRedirect(event, '/', 302);
}
return;
}
// Require auth for every page other than Login
// TODO: investigate /__nuxt_error (error page when unauthenticated)
if (!session.data.userId) {
return sendRedirect(event, '/login', 302);
}
if (url.pathname === '/') {
if (!session.data.authenticated) {
if (url.pathname.startsWith('/admin')) {
const user = await Database.user.findById(session.data.userId);
if (!user) {
return sendRedirect(event, '/login', 302);
}
if (user.role !== 'ADMIN') {
throw createError({
statusCode: 403,
statusMessage: 'Not allowed to access Admin Panel',
});
}
}
});

88
src/server/middleware/session.ts

@ -1,5 +1,9 @@
import type { User } from '~~/services/database/repositories/user';
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
// If one method of a route is public, every method is public!
// Handle api routes
if (
!url.pathname.startsWith('/api/') ||
url.pathname === '/api/account/setup' ||
@ -12,37 +16,81 @@ export default defineEventHandler(async (event) => {
}
const system = await Database.system.get();
const session = await getSession(event, system.sessionConfig);
if (session.id && session.data.authenticated) {
return;
}
const session = await getSession<WGSession>(event, system.sessionConfig);
const authorization = getHeader(event, 'Authorization');
if (url.pathname.startsWith('/api/') && authorization) {
let user: User | undefined = undefined;
if (session.data.userId) {
// Handle if authenticating using Session
user = await Database.user.findById(session.data.userId);
} else if (authorization) {
// Handle if authenticating using Header
const [method, value] = authorization.split(' ');
// Support Basic Authentication
// TODO: support personal access token or similar
if (method !== 'Basic' || !value) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const basicValue = Buffer.from(value, 'base64').toString('utf-8');
// Split by first ":"
const index = basicValue.indexOf(':');
const username = basicValue.substring(0, index);
const password = basicValue.substring(index + 1);
if (!username || !password) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const users = await Database.user.findAll();
const user = users.find((user) => user.id == session.data.userId);
if (!user)
const foundUser = users.find((v) => v.username === username);
if (!foundUser) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const userHashPassword = user.password;
const passwordValid = await isPasswordValid(
authorization,
userHashPassword
);
if (passwordValid) {
return;
const userHashPassword = foundUser.password;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!passwordValid) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect Password',
});
}
user = foundUser;
}
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect Password',
statusMessage: 'Not logged in',
});
}
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
if (!user.enabled) {
throw createError({
statusCode: 403,
statusMessage: 'Account is disabled',
});
}
if (url.pathname.startsWith('/api/admin')) {
if (user.role !== 'ADMIN') {
throw createError({
statusCode: 403,
statusMessage: 'Missing Permissions',
});
}
}
});

17
src/server/middleware/setup.ts

@ -2,16 +2,17 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
if (
url.pathname === '/setup' ||
url.pathname === '/api/account/setup' ||
url.pathname === '/api/features'
) {
// User can't be logged in, and public routes can be accessed whenever
if (url.pathname.startsWith('/api/')) {
return;
}
const users = await Database.user.findAll();
if (users.length === 0) {
// If not setup
if (url.pathname.startsWith('/setup')) {
return;
}
if (url.pathname.startsWith('/api/')) {
throw createError({
statusCode: 400,
@ -19,5 +20,11 @@ export default defineEventHandler(async (event) => {
});
}
return sendRedirect(event, '/setup', 302);
} else {
// If already set up
if (!url.pathname.startsWith('/setup')) {
return;
}
return sendRedirect(event, '/login', 302);
}
});

4
src/server/utils/WireGuard.ts

@ -318,7 +318,7 @@ class WireGuard {
const clients = await Database.client.findAll();
const system = await Database.system.get();
// Expires Feature
if (system.clientExpiration.enabled) {
if (system.features.clientExpiration.enabled) {
for (const client of Object.values(clients)) {
if (client.enabled !== true) continue;
if (
@ -331,7 +331,7 @@ class WireGuard {
}
}
// One Time Link Feature
if (system.oneTimeLinks.enabled) {
if (system.features.oneTimeLinks.enabled) {
for (const client of Object.values(clients)) {
if (
client.oneTimeLink !== null &&

8
src/server/utils/session.ts

@ -1,10 +1,10 @@
import type { H3Event } from 'h3';
export type WGSession = {
authenticated: boolean;
};
export type WGSession = Partial<{
userId: string;
}>;
export async function useWGSession(event: H3Event) {
const system = await Database.system.get();
return useSession<Partial<WGSession>>(event, system.sessionConfig);
return useSession<WGSession>(event, system.sessionConfig);
}

51
src/server/utils/types.ts

@ -58,6 +58,27 @@ const oneTimeLink = z
.min(1, 'oneTimeLink must be at least 1 Character')
.pipe(safeStringRefine);
const features = z.record(
z.string({ message: 'key must be a valid string' }),
z.object(
{
enabled: z.boolean({ message: 'enabled must be a valid boolean' }),
},
{ message: 'value must be a valid object' }
),
{ message: 'features must be a valid record' }
);
const statistics = z.object(
{
enabled: z.boolean({ message: 'enabled must be a valid boolean' }),
chartType: z.number({ message: 'chartType must be a valid number' }),
},
{ message: 'statistics must be a valid object' }
);
const objectMessage = 'Body must be a valid object';
export const clientIdType = z.object(
{
clientId: id,
@ -69,28 +90,28 @@ export const address4Type = z.object(
{
address4: address4,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const nameType = z.object(
{
name: name,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const expireDateType = z.object(
{
expireDate: expireDate,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const oneTimeLinkType = z.object(
{
oneTimeLink: oneTimeLink,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const createType = z.object(
@ -98,14 +119,14 @@ export const createType = z.object(
name: name,
expireDate: expireDate,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const fileType = z.object(
{
file: file,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const credentialsType = z.object(
@ -114,7 +135,7 @@ export const credentialsType = z.object(
password: password,
remember: remember,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const passwordType = z.object(
@ -122,7 +143,21 @@ export const passwordType = z.object(
username: username,
password: password,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const featuresType = z.object(
{
features: features,
},
{ message: objectMessage }
);
export const statisticsType = z.object(
{
statistics: statistics,
},
{ message: objectMessage }
);
export function validateZod<T>(schema: ZodSchema<T>) {

6
src/server/utils/wgHelper.ts

@ -28,8 +28,8 @@ AllowedIPs = ${allowedIps.join(', ')}`;
[Interface]
PrivateKey = ${system.interface.privateKey}
Address = ${system.interface.address4}/${cidr4Block}, ${system.interface.address6}/${cidr6Block}
ListenPort = ${system.wgPort}
MTU = ${system.userConfig.serverMtu}
ListenPort = ${system.interface.port}
MTU = ${system.interface.mtu}
PreUp = ${system.iptables.PreUp}
PostUp = ${system.iptables.PostUp}
PreDown = ${system.iptables.PreDown}
@ -51,7 +51,7 @@ PublicKey = ${system.interface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${system.wgHost}:${system.wgConfigPort}`;
Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
},
generatePrivateKey: () => {

35
src/services/database/lowdb.ts

@ -18,7 +18,14 @@ import {
type NewClient,
type OneTimeLink,
} from './repositories/client';
import { SystemRepository } from './repositories/system';
import {
AvailableFeatures,
ChartType,
SystemRepository,
type Feature,
type Features,
type Statistics,
} from './repositories/system';
const DEBUG = debug('LowDB');
@ -37,6 +44,31 @@ export class LowDBSystem extends SystemRepository {
}
return system;
}
async updateFeatures(features: Record<string, Feature>) {
DEBUG('Update Features');
this.#db.update((v) => {
for (const key in features) {
if (AvailableFeatures.includes(key as keyof Features)) {
v.system.features[key as keyof Features].enabled =
features[key]!.enabled;
}
}
});
}
async updateStatistics(statistics: Statistics) {
DEBUG('Update Statistics');
this.#db.update((v) => {
v.system.statistics.enabled = statistics.enabled;
if (
statistics.chartType >= ChartType.None &&
statistics.chartType <= ChartType.Bar
) {
v.system.statistics.chartType = statistics.chartType;
}
});
}
}
export class LowDBUser extends UserRepository {
@ -77,6 +109,7 @@ export class LowDBUser extends UserRepository {
id: crypto.randomUUID(),
password: hash,
username,
email: null,
name: 'Administrator',
role: isUserEmpty ? 'ADMIN' : 'CLIENT',
enabled: true,

69
src/services/database/migrations/1.ts

@ -16,52 +16,62 @@ export async function run1(db: Low<Database>) {
const database: Database = {
migrations: [],
system: {
general: {
sessionTimeout: 3600, // 1 hour
lang: 'en',
},
// Config to configure Server
interface: {
privateKey: privateKey,
publicKey: publicKey,
address4: stringifyIp({ number: cidr4.start + 1n, version: 4 }),
address6: stringifyIp({ number: cidr6.start + 1n, version: 6 }),
mtu: 1420,
port: 51820,
device: 'eth0',
},
sessionTimeout: 3600, // 1 hour
lang: 'en',
// Config to configure Peer & Client Config
userConfig: {
mtu: 1420,
serverMtu: 1420,
persistentKeepalive: 0,
address4Range: address4Range,
address6Range: address6Range,
defaultDns: ['1.1.1.1', '2606:4700:4700::1111'],
allowedIps: ['0.0.0.0/0', '::/0'],
// TODO: host has to be configured when onboarding
host: '',
port: 51820,
},
wgDevice: 'eth0',
// TODO: wgHost has to be configured when onboarding
wgHost: '',
wgPort: 51820,
wgConfigPort: 51820,
// Config to configure Firewall
iptables: {
PreUp: '',
PostUp: '',
PreDown: '',
PostDown: '',
},
trafficStats: {
enabled: false,
type: ChartType.None,
},
clientExpiration: {
enabled: false,
},
oneTimeLinks: {
enabled: false,
features: {
clientExpiration: {
enabled: false,
},
oneTimeLinks: {
enabled: false,
},
sortClients: {
enabled: false,
},
},
sortClients: {
statistics: {
enabled: false,
chartType: ChartType.None,
},
prometheus: {
enabled: false,
password: null,
metrics: {
prometheus: {
enabled: false,
password: null,
},
},
sessionConfig: {
// TODO: be able to invalidate all sessions
password: getRandomHex(256),
name: 'wg-easy',
cookie: {},
@ -71,26 +81,25 @@ export async function run1(db: Low<Database>) {
clients: {},
};
// TODO: properly check if ipv6 support
database.system.iptables.PostUp =
`iptables -t nat -A POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.wgDevice} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
`iptables -t nat -A POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.interface.device} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
ip6tables -t nat -A POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.wgDevice} -j MASQUERADE;
ip6tables -A INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
ip6tables -t nat -A POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.interface.device} -j MASQUERADE;
ip6tables -A INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
ip6tables -A FORWARD -i wg0 -j ACCEPT;
ip6tables -A FORWARD -o wg0 -j ACCEPT;`
.split('\n')
.join(' ');
database.system.iptables.PostDown =
`iptables -t nat -D POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.wgDevice} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
`iptables -t nat -D POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.interface.device} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
ip6tables -t nat -D POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.wgDevice} -j MASQUERADE;
ip6tables -D INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
ip6tables -t nat -D POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.interface.device} -j MASQUERADE;
ip6tables -D INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
ip6tables -D FORWARD -i wg0 -j ACCEPT;
ip6tables -D FORWARD -o wg0 -j ACCEPT;`
.split('\n')

55
src/services/database/repositories/system.ts

@ -14,16 +14,20 @@ export type WGInterface = {
publicKey: string;
address4: string;
address6: string;
mtu: number;
port: number;
device: string;
};
export type WGConfig = {
mtu: number;
serverMtu: number;
persistentKeepalive: number;
address4Range: string;
address6Range: string;
defaultDns: string[];
allowedIps: string[];
host: string;
port: number;
};
export enum ChartType {
@ -33,9 +37,9 @@ export enum ChartType {
Bar = 3,
}
export type TrafficStats = {
export type Statistics = {
enabled: boolean;
type: ChartType;
chartType: ChartType;
};
export type Prometheus = {
@ -47,31 +51,45 @@ export type Feature = {
enabled: boolean;
};
export type Metrics = {
prometheus: Prometheus;
};
export type General = {
sessionTimeout: number;
lang: Lang;
};
export type Features = {
clientExpiration: Feature;
oneTimeLinks: Feature;
sortClients: Feature;
};
export const AvailableFeatures: (keyof Features)[] = [
'clientExpiration',
'oneTimeLinks',
'sortClients',
] as const;
/**
* Representing the WireGuard network configuration data structure of a computer interface system.
*/
export type System = {
interface: WGInterface;
general: General;
// maxAge
sessionTimeout: number;
lang: Lang;
interface: WGInterface;
userConfig: WGConfig;
wgDevice: string;
wgHost: string;
wgPort: number;
wgConfigPort: number;
iptables: IpTables;
trafficStats: TrafficStats;
clientExpiration: Feature;
oneTimeLinks: Feature;
sortClients: Feature;
features: Features;
statistics: Statistics;
metrics: Metrics;
prometheus: Prometheus;
sessionConfig: SessionConfig;
};
@ -85,4 +103,7 @@ export abstract class SystemRepository {
* Retrieves the system configuration data from the database.
*/
abstract get(): Promise<System>;
abstract updateFeatures(features: Record<string, Feature>): Promise<void>;
abstract updateStatistics(statistics: Statistics): Promise<void>;
}

1
src/services/database/repositories/user.ts

@ -17,6 +17,7 @@ export type User = {
username: string;
password: string;
name: string;
email: string | null;
/** ISO String */
createdAt: string;
/** ISO String */

9
src/tailwind.config.ts

@ -1,6 +1,6 @@
import type { Config } from 'tailwindcss';
import type { PluginAPI } from 'tailwindcss/types/config';
// import { red } from 'tailwindcss/colors.js';
import tailwindForms from '@tailwindcss/forms';
export default {
darkMode: 'selector',
@ -15,12 +15,6 @@ export default {
xl: '1280px',
'2xl': '1536px',
},
extend: {
colors: {
// DEFAULT: red[800],
// primary: red[800],
},
},
},
plugins: [
function addDisabledClass({ addUtilities }: PluginAPI) {
@ -32,5 +26,6 @@ export default {
};
addUtilities(newUtilities);
},
tailwindForms,
],
} satisfies Config;

Loading…
Cancel
Save