Browse Source

Feat: Settings, UI, General Improvements (#1572)

* deprecate other languages

new ui has too many new strings

* fix wrong license in readme

* properly fetch release

* order safe data structure for migrations

* empty server allowed ips by default

* show userconfig in admin panel

* remove routes, fix config

* add ability to update clients

* handle form submit using js

avoid weird behavior with FormData

* global toast, be able to update client

* update packages

* fix date field

* delete client using radix dialog

* remove lang from backend, let users decide

* be able to change interface and general

* be able to update user config

* consistent allowedips

* fix array field

* improve avatar, code cleanup

* basic metrics support

* remove dateTime helper

* be able to change hooks

* start cidr update

* be able to update cidr
pull/1619/head
Bernd Storath 3 months ago
committed by GitHub
parent
commit
b2394d1e73
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      .vscode/settings.json
  2. 2
      README.md
  3. 2
      package.json
  4. 10
      src/app/app.vue
  5. 4
      src/app/components/ClientCard/Avatar.vue
  6. 6
      src/app/components/ClientCard/Config.vue
  7. 2
      src/app/components/ClientCard/LastSeen.vue
  8. 2
      src/app/components/ClientCard/Name.vue
  9. 2
      src/app/components/Clients/CreateDialog.vue
  10. 126
      src/app/components/Clients/DeleteDialog.vue
  11. 49
      src/app/components/admin/CidrDialog.vue
  12. 3
      src/app/components/base/Avatar.vue
  13. 1
      src/app/components/base/Switch.vue
  14. 3
      src/app/components/base/Toast.vue
  15. 9
      src/app/components/form/ActionField.vue
  16. 28
      src/app/components/form/ArrayField.vue
  17. 26
      src/app/components/form/DateField.vue
  18. 5
      src/app/components/form/Element.vue
  19. 1
      src/app/components/form/NumberField.vue
  20. 1
      src/app/components/form/TextField.vue
  21. 1
      src/app/components/header/Update.vue
  22. 13
      src/app/components/ui/ChooseLang.vue
  23. 13
      src/app/layouts/default.vue
  24. 4
      src/app/layouts/setup.vue
  25. 3
      src/app/pages/admin.vue
  26. 108
      src/app/pages/admin/config.vue
  27. 24
      src/app/pages/admin/defaults.vue
  28. 58
      src/app/pages/admin/hooks.vue
  29. 62
      src/app/pages/admin/index.vue
  30. 71
      src/app/pages/admin/interface.vue
  31. 156
      src/app/pages/clients/[id].vue
  32. 4
      src/app/pages/index.vue
  33. 7
      src/app/pages/login.vue
  34. 28
      src/app/pages/setup/1.vue
  35. 8
      src/app/pages/setup/4.vue
  36. 8
      src/app/pages/setup/5.vue
  37. 7
      src/app/pages/setup/migrate.vue
  38. 24
      src/app/stores/auth.ts
  39. 28
      src/app/stores/global.ts
  40. 13
      src/app/stores/modal.ts
  41. 33
      src/app/stores/setup.ts
  42. 26
      src/app/stores/toast.ts
  43. 46
      src/app/utils/api.ts
  44. 1
      src/app/utils/localStorage.ts
  45. 11
      src/app/utils/math.ts
  46. 49
      src/i18n/i18n.config.ts
  47. 5
      src/i18n/localeDetector.ts
  48. 56
      src/i18n/locales/be.json
  49. 27
      src/i18n/locales/ca.json
  50. 32
      src/i18n/locales/de.json
  51. 24
      src/i18n/locales/en.json
  52. 37
      src/i18n/locales/es.json
  53. 122
      src/i18n/locales/fr.json
  54. 28
      src/i18n/locales/hi.json
  55. 27
      src/i18n/locales/is.json
  56. 31
      src/i18n/locales/it.json
  57. 37
      src/i18n/locales/ko.json
  58. 27
      src/i18n/locales/nl.json
  59. 27
      src/i18n/locales/no.json
  60. 27
      src/i18n/locales/pl.json
  61. 27
      src/i18n/locales/pt.json
  62. 56
      src/i18n/locales/ru.json
  63. 27
      src/i18n/locales/th.json
  64. 38
      src/i18n/locales/tr.json
  65. 38
      src/i18n/locales/ua.json
  66. 38
      src/i18n/locales/vi.json
  67. 40
      src/i18n/locales/zh-chs.json
  68. 40
      src/i18n/locales/zh-cht.json
  69. 14
      src/nuxt.config.ts
  70. 21
      src/package.json
  71. 2175
      src/pnpm-lock.yaml
  72. 4
      src/server/api/admin/general.get.ts
  73. 8
      src/server/api/admin/general.post.ts
  74. 4
      src/server/api/admin/hooks.get.ts
  75. 9
      src/server/api/admin/hooks.post.ts
  76. 4
      src/server/api/admin/interface.get.ts
  77. 9
      src/server/api/admin/interface.post.ts
  78. 5
      src/server/api/admin/lang.post.ts
  79. 9
      src/server/api/admin/userconfig/cidr.post.ts
  80. 4
      src/server/api/admin/userconfig/index.get.ts
  81. 9
      src/server/api/admin/userconfig/index.post.ts
  82. 12
      src/server/api/client/[clientId]/address4.put.ts
  83. 11
      src/server/api/client/[clientId]/index.post.ts
  84. 9
      src/server/api/client/[clientId]/name.put.ts
  85. 5
      src/server/api/lang.get.ts
  86. 2
      src/server/api/session.post.ts
  87. 14
      src/server/api/setup/1.post.ts
  88. 5
      src/server/api/setup/migrate.post.ts
  89. 1
      src/server/middleware/session.ts
  90. 0
      src/server/routes/cnf/[oneTimeLink].ts
  91. 14
      src/server/routes/metrics/index.get.ts
  92. 13
      src/server/routes/metrics/json.get.ts
  93. 77
      src/server/utils/WireGuard.ts
  94. 2
      src/server/utils/release.ts
  95. 27
      src/server/utils/template.ts
  96. 225
      src/server/utils/types.ts
  97. 14
      src/server/utils/wgHelper.ts
  98. 126
      src/services/database/lowdb.ts
  99. 51
      src/services/database/migrations/1.ts
  100. 19
      src/services/database/migrations/index.ts

3
.vscode/settings.json

@ -21,5 +21,6 @@
], ],
"i18n-ally.sortKeys": false, "i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": false, "i18n-ally.keepFulfilled": false,
"i18n-ally.keystyle": "nested" "i18n-ally.keystyle": "nested",
"editor.gotoLocation.multipleDefinitions": "goto"
} }

2
README.md

@ -153,7 +153,7 @@ For less common or specific edge-case scenarios, please refer to the detailed in
## License ## License
This project is licensed under the GPL-3.0-only License - see the LICENSE file for details This project is licensed under the AGPL-3.0-only License - see the LICENSE file for details
This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Jason A. Donenfeld, ZX2C4 or Edge Security This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Jason A. Donenfeld, ZX2C4 or Edge Security

2
package.json

@ -5,5 +5,5 @@
"dev": "docker compose -f docker-compose.dev.yml up", "dev": "docker compose -f docker-compose.dev.yml up",
"build": "docker build -t wg-easy ." "build": "docker build -t wg-easy ."
}, },
"packageManager": "[email protected].0" "packageManager": "[email protected].3"
} }

10
src/app/app.vue

@ -4,14 +4,18 @@
<NuxtPage /> <NuxtPage />
<ToastViewport <ToastViewport
class="fixed bottom-0 right-0 z-[2147483647] m-0 flex w-[390px] max-w-[100vw] list-none flex-col gap-[10px] p-[var(--viewport-padding)] outline-none [--viewport-padding:_25px]" class="fixed bottom-0 right-0 z-[2147483647] m-0 flex w-[390px] max-w-[100vw] list-none flex-col gap-[10px] p-[var(--viewport-padding)] outline-none [--viewport-padding:_25px]"
/> >
<BaseToast ref="toast" />
</ToastViewport>
</NuxtLayout> </NuxtLayout>
</ToastProvider> </ToastProvider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const globalStore = useGlobalStore(); const toast = useToast();
globalStore.setLanguage(); const toastRef = useTemplateRef('toast');
toast.setToast(toastRef);
useHead({ useHead({
bodyAttrs: { bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800', class: 'bg-gray-50 dark:bg-neutral-800',

4
src/app/components/ClientCard/Avatar.vue

@ -22,9 +22,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ defineProps<{
client: LocalClient; client: LocalClient;
}>(); }>();
console.log(props.client.avatar);
</script> </script>

6
src/app/components/ClientCard/Config.vue

@ -1,12 +1,12 @@
<template> <template>
<NuxtLink <a
:to="'/api/client/' + client.id + '/configuration'" :href="'/api/client/' + client.id + '/configuration'"
download download
class="inline-block rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white" class="inline-block rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white"
:title="$t('downloadConfig')" :title="$t('downloadConfig')"
> >
<IconsDownload class="w-5" /> <IconsDownload class="w-5" />
</NuxtLink> </a>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

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

@ -2,7 +2,7 @@
<span <span
v-if="client.latestHandshakeAt" v-if="client.latestHandshakeAt"
class="whitespace-nowrap text-gray-400 dark:text-neutral-500" class="whitespace-nowrap text-gray-400 dark:text-neutral-500"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))" :title="$t('lastSeen') + $d(new Date(client.latestHandshakeAt))"
> >
· {{ timeago(new Date(client.latestHandshakeAt)) }} · {{ timeago(new Date(client.latestHandshakeAt)) }}
</span> </span>

2
src/app/components/ClientCard/Name.vue

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="text-sm text-gray-700 md:text-base dark:text-neutral-200" class="text-sm text-gray-700 md:text-base dark:text-neutral-200"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))" :title="$t('createdOn') + $d(new Date(client.createdAt))"
> >
<span class="border-b-2 border-t-2 border-transparent"> <span class="border-b-2 border-t-2 border-transparent">
{{ client.name }} {{ client.name }}

2
src/app/components/Clients/CreateDialog.vue

@ -127,4 +127,6 @@
<script setup lang="ts"> <script setup lang="ts">
const modalStore = useModalStore(); const modalStore = useModalStore();
// TODO: use radix
</script> </script>

126
src/app/components/Clients/DeleteDialog.vue

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

49
src/app/components/admin/CidrDialog.vue

@ -0,0 +1,49 @@
<template>
<DialogRoot :modal="true">
<DialogTrigger :class="triggerClass"><slot /></DialogTrigger>
<DialogPortal>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
/>
<DialogContent
class="data-[state=open]:animate-contentShow fixed left-1/2 top-1/2 z-[100] max-h-[85vh] w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md p-6 shadow-2xl focus:outline-none dark:bg-neutral-700"
>
<DialogTitle
class="m-0 text-lg font-semibold text-gray-900 dark:text-neutral-200"
>
Change CIDR
</DialogTitle>
<DialogDescription
class="mb-5 mt-2 text-sm leading-normal text-gray-500 dark:text-neutral-300"
>
<FormGroup>
<FormTextField id="address4" v-model="address4" label="IPv4" />
<FormTextField id="address6" v-model="address6" label="IPv6" />
</FormGroup>
</DialogDescription>
<div class="mt-6 flex justify-end gap-2">
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('change', address4, address6)"
>Change</BaseButton
>
</DialogClose>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script lang="ts" setup>
defineEmits(['change']);
const props = defineProps<{
triggerClass?: string;
address4: string;
address6: string;
}>();
const address4 = ref(props.address4);
const address6 = ref(props.address6);
</script>

3
src/app/components/base/Avatar.vue

@ -3,9 +3,8 @@
class="mr-2 inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle" class="mr-2 inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle"
> >
<AvatarImage <AvatarImage
v-if="img"
class="h-full w-full rounded-[inherit] object-cover" class="h-full w-full rounded-[inherit] object-cover"
:src="img" :src="img ?? ''"
/> />
<AvatarFallback <AvatarFallback
class="leading-1 flex h-full w-full items-center justify-center bg-white text-sm font-medium" class="leading-1 flex h-full w-full items-center justify-center bg-white text-sm font-medium"

1
src/app/components/base/Switch.vue

@ -2,6 +2,7 @@
<SwitchRoot <SwitchRoot
:id="id" :id="id"
v-model:checked="data" v-model:checked="data"
:name="id"
class="relative flex h-6 w-10 cursor-default rounded-full bg-gray-200 shadow-sm focus-within:outline focus-within:outline-red-700 data-[state=checked]:bg-red-800 dark:bg-neutral-400" class="relative flex h-6 w-10 cursor-default rounded-full bg-gray-200 shadow-sm focus-within:outline focus-within:outline-red-700 data-[state=checked]:bg-red-800 dark:bg-neutral-400"
> >
<SwitchThumb <SwitchThumb

3
src/app/components/base/Toast.vue

@ -11,11 +11,12 @@ defineExpose({
publish, publish,
}); });
// TODO: support multiple types (info, success, error, warning)
const count = reactive<{ title: string; message: string }[]>([]); const count = reactive<{ title: string; message: string }[]>([]);
function publish(e: { title: string; message: string }) { function publish(e: { title: string; message: string }) {
count.push({ title: e.title, message: e.message }); count.push({ title: e.title, message: e.message });
console.log(count.length);
} }
</script> </script>

9
src/app/components/form/ActionField.vue

@ -1,11 +1,16 @@
<template> <template>
<input <input
:value="label" :value="label"
type="button" :type="type ?? 'button'"
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/> />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ label: string }>(); import type { InputTypeHTMLAttribute } from 'vue';
defineProps<{
label: string;
type?: InputTypeHTMLAttribute;
}>();
</script> </script>

28
src/app/components/form/ArrayField.vue

@ -1,28 +1,32 @@
<template> <template>
<div class="flex flex-col"> <div v-if="data?.length === 0">
<div v-for="(item, i) in data" :key="item"> {{ emptyText || 'No items' }}
</div>
<div v-else class="flex flex-col">
<div v-for="(item, i) in data" :key="i">
<input <input
:value="item" :value="item"
:name="name"
type="text" type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
@input="update(i)" @input="update($event, i)"
/> />
<input type="button" value="-" @click="del(i)" /> <input type="button" value="-" @click="del(i)" />
</div> </div>
<input type="button" value="Add" @click="add" />
</div> </div>
<input type="button" value="Add" @click="add" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const data = defineModel<string[] | null>(); const data = defineModel<string[]>();
defineProps<{ emptyText?: string[]; name: string }>();
function update(i: number) { function update(e: Event, i: number) {
return (v: string) => { const v = (e.target as HTMLInputElement).value;
if (!data.value) { if (!data.value) {
return; return;
} }
data.value[i] = v; data.value[i] = v;
};
} }
function add() { function add() {

26
src/app/components/form/DateField.vue

@ -0,0 +1,26 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model="data"
:name="id"
type="date"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
const [data] = defineModel<string | null>({
set(value) {
const temp = value?.trim() ?? null;
if (temp === '') {
return null;
}
return temp;
},
});
</script>

5
src/app/components/form/Element.vue

@ -0,0 +1,5 @@
<template>
<form>
<slot />
</form>
</template>

1
src/app/components/form/NumberField.vue

@ -5,6 +5,7 @@
<input <input
:id="id" :id="id"
v-model.number="data" v-model.number="data"
:name="id"
type="number" type="number"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/> />

1
src/app/components/form/TextField.vue

@ -5,6 +5,7 @@
<input <input
:id="id" :id="id"
v-model="data" v-model="data"
:name="id"
type="text" type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/> />

1
src/app/components/header/Update.vue

@ -23,5 +23,4 @@
<script lang="ts" setup> <script lang="ts" setup>
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
globalStore.fetchRelease();
</script> </script>

13
src/app/components/ui/ChooseLang.vue

@ -2,7 +2,7 @@
<SelectRoot v-model="langProxy" :default-value="locale"> <SelectRoot v-model="langProxy" :default-value="locale">
<SelectTrigger <SelectTrigger
class="inline-flex h-[35px] min-w-[160px] items-center justify-between gap-[5px] rounded px-[15px] text-[13px] leading-none dark:bg-neutral-500 dark:text-white" class="inline-flex h-[35px] min-w-[160px] items-center justify-between gap-[5px] rounded px-[15px] text-[13px] leading-none dark:bg-neutral-500 dark:text-white"
aria-label="Customise language" aria-label="Customize language"
> >
<SelectValue :placeholder="$t('setup.chooseLang')" /> <SelectValue :placeholder="$t('setup.chooseLang')" />
<IconsArrowDown class="size-4" /> <IconsArrowDown class="size-4" />
@ -31,16 +31,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { LOCALES } from '#shared/locales'; // TODO: improve
const { locale } = useI18n(); const { locales, locale, setLocale } = useI18n();
const emit = defineEmits(['update:lang']);
const langProxy = ref(locale); const langProxy = ref(locale);
watch(langProxy, (newVal) => { watchEffect(() => {
emit('update:lang', newVal); setLocale(langProxy.value);
}); });
const langs = LOCALES.sort((a, b) => a.code.localeCompare(b.code)); const langs = locales.value.sort((a, b) => a.code.localeCompare(b.code));
</script> </script>

13
src/app/layouts/default.vue

@ -4,12 +4,12 @@
<div <div
class="mb-5" class="mb-5"
:class=" :class="
hasOwnLogo loggedIn
? 'flex justify-end' ? 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
: 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row' : 'flex justify-end'
" "
> >
<HeaderLogo v-if="!hasOwnLogo" /> <HeaderLogo v-if="loggedIn" />
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center"> <div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
<HeaderThemeSwitch /> <HeaderThemeSwitch />
<HeaderChartToggle /> <HeaderChartToggle />
@ -55,13 +55,10 @@
<script setup lang="ts"> <script setup lang="ts">
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
globalStore.fetchRelease();
const route = useRoute(); const route = useRoute();
const hasOwnLogo = computed(
() => route.path === '/login' || route.path === '/setup'
);
const loggedIn = computed( const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/setup' () => route.path !== '/login' && route.path !== '/setup'
); );

4
src/app/layouts/setup.vue

@ -17,13 +17,9 @@
</div> </div>
</PanelBody> </PanelBody>
</Panel> </Panel>
<BaseToast ref="toast" />
</main> </main>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const setupStore = useSetupStore(); const setupStore = useSetupStore();
const savedRef = useTemplateRef('toast');
setupStore.setErrorRef(savedRef);
</script> </script>

3
src/app/pages/admin.vue

@ -42,8 +42,9 @@ const route = useRoute();
const menuItems = [ const menuItems = [
{ id: '', name: 'General' }, { id: '', name: 'General' },
{ id: 'defaults', name: 'Defaults' }, { id: 'config', name: 'Config' },
{ id: 'interface', name: 'Interface' }, { id: 'interface', name: 'Interface' },
{ id: 'hooks', name: 'Hooks' },
{ id: 'metrics', name: 'Metrics' }, { id: 'metrics', name: 'Metrics' },
]; ];

108
src/app/pages/admin/config.vue

@ -0,0 +1,108 @@
<template>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Connection</FormHeading>
<FormTextField id="host" v-model="data.host" label="Host" />
<FormNumberField id="port" v-model="data.port" label="Port" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="keepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<AdminCidrDialog
trigger-class="col-span-2"
:address6="data.address6Range"
:address4="data.address4Range"
@change="changeCidr"
>
<FormActionField label="Change CIDR" class="w-full" />
</AdminCidrDialog>
</FormGroup>
</FormElement>
</main>
</template>
<script lang="ts" setup>
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/userconfig`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/userconfig`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
async function changeCidr(address4: string, address6: string) {
try {
const res = await $fetch(`/api/admin/userconfig/cidr`, {
method: 'post',
body: { address4, address6 },
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Changed CIDR',
});
if (!res.success) {
throw new Error('Failed to change CIDR');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
</script>

24
src/app/pages/admin/defaults.vue

@ -1,24 +0,0 @@
<template>
<div>
<FormGroup>
<FormHeading>Connection</FormHeading>
<FormTextField id="host" label="Host" />
<FormTextField id="port" label="Port" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField />
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormArrayField />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" label="MTU" />
<FormNumberField id="keepalive" label="Persistent Keepalive" />
</FormGroup>
</div>
</template>
<script setup lang="ts"></script>

58
src/app/pages/admin/hooks.vue

@ -0,0 +1,58 @@
<template>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormTextField id="PreUp" v-model="data.PreUp" label="PreUp" />
<FormTextField id="PostUp" v-model="data.PostUp" label="PostUp" />
<FormTextField id="PreDown" v-model="data.PreDown" label="PreDown" />
<FormTextField id="PostDown" v-model="data.PostDown" label="PostDown" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template>
<script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/hooks`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/hooks`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

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

@ -1,9 +1,59 @@
<template> <template>
<div> <main v-if="data">
<FormGroup> <FormElement @submit.prevent="submit">
<FormNumberField id="session" label="Session Timeout" /> <FormGroup>
</FormGroup> <FormNumberField
</div> id="session"
v-model="data.sessionTimeout"
label="Session Timeout"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/general`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

71
src/app/pages/admin/interface.vue

@ -1,19 +1,58 @@
<template> <template>
<div> <main v-if="data">
<FormGroup> <FormElement @submit.prevent="submit">
<FormHeading>Interface Settings</FormHeading> <FormGroup>
<FormNumberField id="mtu" label="MTU" /> <FormHeading>Interface Settings</FormHeading>
<FormNumberField id="port" label="Port" /> <FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormTextField id="device" label="Device" /> <FormNumberField id="port" v-model="data.port" label="Port" />
</FormGroup> <FormTextField id="device" v-model="data.device" label="Device" />
<FormGroup> </FormGroup>
<FormHeading>Scripts</FormHeading> <FormGroup>
<FormTextField id="mtu" label="PreUp" /> <FormHeading>Actions</FormHeading>
<FormTextField id="port" label="PostUp" /> <FormActionField type="submit" label="Save" />
<FormTextField id="device" label="PreDown" /> <FormActionField label="Revert" @click="revert" />
<FormTextField id="device" label="PostDown" /> </FormGroup>
</FormGroup> </FormElement>
</div> </main>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/interface`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

156
src/app/pages/clients/[id].vue

@ -5,45 +5,69 @@
<PanelHeadTitle :text="data.name" /> <PanelHeadTitle :text="data.name" />
</PanelHead> </PanelHead>
<PanelBody> <PanelBody>
<FormGroup> <FormElement @submit.prevent="submit">
<FormHeading> <FormGroup>
{{ $t('me.sectionGeneral') }} <FormHeading>
</FormHeading> {{ $t('me.sectionGeneral') }}
<FormTextField id="name" v-model.trim="data.name" label="Name" /> </FormHeading>
<FormSwitchField <FormTextField id="name" v-model.trim="data.name" label="Name" />
id="enabled" <FormSwitchField
v-model="data.enabled" id="enabled"
label="Enabled" v-model="data.enabled"
/> label="Enabled"
</FormGroup> />
<FormGroup> <FormDateField
<FormHeading>Address</FormHeading> id="expiresAt"
<FormTextField id="ipv4" v-model.trim="data.address4" label="IPv4" /> v-model.trim="data.expiresAt"
<FormTextField id="ipv6" v-model.trim="data.address6" label="IPv6" /> label="Expire Date"
</FormGroup> />
<FormGroup> </FormGroup>
<FormHeading>Allowed IPs</FormHeading> <FormGroup>
<FormArrayField v-model="data.allowedIPs" /> <FormHeading>Address</FormHeading>
</FormGroup> <FormTextField
<FormGroup> id="address4"
<FormHeading>Server Allowed IPs</FormHeading> v-model.trim="data.address4"
<FormArrayField v-model="data.serverAllowedIPs" /> label="IPv4"
</FormGroup> />
<FormGroup></FormGroup> <FormTextField
<FormGroup> id="address6"
<FormHeading>Advanced</FormHeading> v-model.trim="data.address6"
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" /> label="IPv6"
<FormNumberField />
id="keepalive" </FormGroup>
v-model="data.persistentKeepalive" <FormGroup>
label="Persistent Keepalive" <FormHeading>Allowed IPs</FormHeading>
/> <FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormHeading>Actions</FormHeading> <FormHeading>Server Allowed IPs</FormHeading>
<FormActionField label="Delete!" /> <FormArrayField
<FormActionField label="Revert!" @click="revert" /> v-model="data.serverAllowedIPs"
</FormGroup> name="serverAllowedIPs"
/>
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="persistentKeepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<ClientsDeleteDialog
trigger-class="col-span-2"
@delete="deleteClient"
>
<FormActionField label="Delete" class="w-full" />
</ClientsDeleteDialog>
</FormGroup>
</FormElement>
</PanelBody> </PanelBody>
</Panel> </Panel>
</main> </main>
@ -52,15 +76,69 @@
<script lang="ts" setup> <script lang="ts" setup>
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.update(); authStore.update();
const router = useRouter();
const route = useRoute(); const route = useRoute();
const toast = useToast();
const id = route.params.id as string; const id = route.params.id as string;
const { data: _data, refresh } = await useFetch(`/api/client/${id}`, { const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
method: 'get', method: 'get',
}); });
const data = toRef(_data.value); const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/client/${id}`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
router.push('/');
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() { async function revert() {
await refresh(); await refresh();
data.value = toRef(_data.value).value; data.value = toRef(_data.value).value;
} }
async function deleteClient() {
try {
const res = await $fetch(`/api/client/${id}`, {
method: 'delete',
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Deleted',
});
if (!res.success) {
throw new Error('Failed to delete');
}
router.push('/');
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
</script> </script>

4
src/app/pages/index.vue

@ -27,7 +27,6 @@
<ClientsQRCodeDialog /> <ClientsQRCodeDialog />
<ClientsCreateDialog /> <ClientsCreateDialog />
<ClientsDeleteDialog />
</main> </main>
</template> </template>
@ -37,6 +36,9 @@ authStore.update();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const clientsStore = useClientsStore(); const clientsStore = useClientsStore();
// TODO?: use hover card to show more detailed info without leaving the page
// or do something like a accordion
const intervalId = ref<NodeJS.Timeout | null>(null); const intervalId = ref<NodeJS.Timeout | null>(null);
clientsStore.refresh(); clientsStore.refresh();

7
src/app/pages/login.vue

@ -74,8 +74,6 @@
:value="$t('signIn')" :value="$t('signIn')"
/> />
</form> </form>
<BaseToast ref="toast" />
</main> </main>
</template> </template>
@ -89,7 +87,7 @@ const remember = ref(false);
const username = ref<null | string>(null); const username = ref<null | string>(null);
const password = ref<null | string>(null); const password = ref<null | string>(null);
const authStore = useAuthStore(); const authStore = useAuthStore();
const toast = useTemplateRef('toast'); const toast = useToast();
async function login(e: Event) { async function login(e: Event) {
e.preventDefault(); e.preventDefault();
@ -108,7 +106,8 @@ async function login(e: Event) {
} }
} catch (error) { } catch (error) {
if (error instanceof FetchError) { if (error instanceof FetchError) {
toast.value?.publish({ toast.showToast({
type: 'error',
title: t('error.login'), title: t('error.login'),
message: error.data.message, message: error.data.message,
}); });

28
src/app/pages/setup/1.vue

@ -4,39 +4,23 @@
{{ $t('setup.messageSetupLanguage') }} {{ $t('setup.messageSetupLanguage') }}
</p> </p>
<div class="mb-8 flex justify-center"> <div class="mb-8 flex justify-center">
<UiChooseLang @update:lang="handleEventUpdateLang" /> <UiChooseLang />
</div> </div>
<div><BaseButton @click="updateLang">Continue</BaseButton></div> <div><BaseButton @click="nextStep">Continue</BaseButton></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FetchError } from 'ofetch';
definePageMeta({ definePageMeta({
layout: 'setup', layout: 'setup',
}); });
const { t, locale, setLocale } = useI18n();
function handleEventUpdateLang(value: string) {
setLocale(value);
}
const setupStore = useSetupStore(); const setupStore = useSetupStore();
setupStore.setStep(1); setupStore.setStep(1);
const router = useRouter(); const router = useRouter();
async function updateLang() {
try { async function nextStep() {
await setupStore.step1(locale.value); router.push('/setup/2');
router.push('/setup/2');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
title: t('setup.requirements'),
message: error.data.message,
});
}
}
} }
</script> </script>

8
src/app/pages/setup/4.vue

@ -55,10 +55,13 @@ const username = ref<null | string>(null);
const password = ref<null | string>(null); const password = ref<null | string>(null);
const accept = ref<boolean>(true); const accept = ref<boolean>(true);
const toast = useToast();
async function newAccount() { async function newAccount() {
try { try {
if (!username.value || !password.value) { if (!username.value || !password.value) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: t('setup.emptyFields'), message: t('setup.emptyFields'),
}); });
@ -69,7 +72,8 @@ async function newAccount() {
await router.push('/setup/5'); await router.push('/setup/5');
} catch (error) { } catch (error) {
if (error instanceof FetchError) { if (error instanceof FetchError) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: error.data.message, message: error.data.message,
}); });

8
src/app/pages/setup/5.vue

@ -43,9 +43,12 @@ const router = useRouter();
const host = ref<null | string>(null); const host = ref<null | string>(null);
const port = ref<number>(51820); const port = ref<number>(51820);
const toast = useToast();
async function updateHostPort() { async function updateHostPort() {
if (!host.value || !port.value) { if (!host.value || !port.value) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: t('setup.emptyFields'), message: t('setup.emptyFields'),
}); });
@ -57,7 +60,8 @@ async function updateHostPort() {
await router.push('/setup/success'); await router.push('/setup/success');
} catch (error) { } catch (error) {
if (error instanceof FetchError) { if (error instanceof FetchError) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: error.data.message, message: error.data.message,
}); });

7
src/app/pages/setup/migrate.vue

@ -37,10 +37,12 @@ function onChangeFile(evt: Event) {
} }
const router = useRouter(); const router = useRouter();
const toast = useToast();
async function sendFile() { async function sendFile() {
if (!backupFile.value) { if (!backupFile.value) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: t('setup.emptyFields'), message: t('setup.emptyFields'),
}); });
@ -54,7 +56,8 @@ async function sendFile() {
await router.push('/setup/success'); await router.push('/setup/success');
} catch (error) { } catch (error) {
if (error instanceof FetchError) { if (error instanceof FetchError) {
setupStore.handleError({ toast.showToast({
type: 'error',
title: t('setup.requirements'), title: t('setup.requirements'),
message: error.data.message, message: error.data.message,
}); });

24
src/app/stores/auth.ts

@ -1,16 +1,16 @@
export const useAuthStore = defineStore('Auth', () => { export const useAuthStore = defineStore('Auth', () => {
const userData = ref<null | { const { data: userData, refresh: update } = useFetch('/api/session', {
name: string; method: 'get',
username: string; });
role: string;
email: string | null;
}>();
/** /**
* @throws if unsuccessful * @throws if unsuccessful
*/ */
async function login(username: string, password: string, remember: boolean) { async function login(username: string, password: string, remember: boolean) {
await api.createSession({ username, password, remember }); await $fetch('/api/session', {
method: 'post',
body: { username, password, remember },
});
return true as const; return true as const;
} }
@ -18,15 +18,11 @@ export const useAuthStore = defineStore('Auth', () => {
* @throws if unsuccessful * @throws if unsuccessful
*/ */
async function logout() { async function logout() {
const response = await api.deleteSession(); const response = await $fetch('/api/session', {
method: 'delete',
});
return response.success; return response.success;
} }
async function update() {
// store role etc
const { data: response } = await api.getSession();
userData.value = response.value;
}
return { userData, login, logout, update }; return { userData, login, logout, update };
}); });

28
src/app/stores/global.ts

@ -3,21 +3,6 @@ import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('Global', () => { export const useGlobalStore = defineStore('Global', () => {
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const { availableLocales, locale } = useI18n();
async function setLanguage() {
const { data: lang } = await useFetch('/api/lang', {
method: 'get',
});
if (
lang.value !== getItem('lang') &&
availableLocales.includes(lang.value!)
) {
setItem('lang', lang.value!);
locale.value = lang.value!;
}
}
const currentRelease = ref<null | string>(null); const currentRelease = ref<null | string>(null);
const latestRelease = ref<null | { version: string; changelog: string }>( const latestRelease = ref<null | { version: string; changelog: string }>(
null null
@ -46,20 +31,8 @@ export const useGlobalStore = defineStore('Global', () => {
const uiChartType = ref(getItem('uiChartType') ?? 'area'); const uiChartType = ref(getItem('uiChartType') ?? 'area');
/**
* @throws if unsuccessful
*/
async function updateLang(lang: string) {
const response = await $fetch('/api/admin/lang', {
method: 'post',
body: { lang },
});
return response.success;
}
return { return {
sortClient, sortClient,
setLanguage,
currentRelease, currentRelease,
latestRelease, latestRelease,
updateAvailable, updateAvailable,
@ -67,6 +40,5 @@ export const useGlobalStore = defineStore('Global', () => {
uiShowCharts, uiShowCharts,
toggleCharts, toggleCharts,
uiChartType, uiChartType,
updateLang,
}; };
}); });

13
src/app/stores/modal.ts

@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
export const useModalStore = defineStore('Modal', () => { export const useModalStore = defineStore('Modal', () => {
const clientsStore = useClientsStore(); const clientsStore = useClientsStore();
const clientDelete = ref<null | WGClient>(null);
const clientCreate = ref<null | boolean>(null); const clientCreate = ref<null | boolean>(null);
const clientCreateName = ref<string>(''); const clientCreateName = ref<string>('');
const clientExpireDate = ref<string>(''); const clientExpireDate = ref<string>('');
@ -19,23 +18,11 @@ export const useModalStore = defineStore('Modal', () => {
.finally(() => clientsStore.refresh().catch(console.error)); .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 { return {
clientDelete,
clientCreate, clientCreate,
clientCreateName, clientCreateName,
clientExpireDate, clientExpireDate,
qrcode, qrcode,
createClient, createClient,
deleteClient,
}; };
}); });

33
src/app/stores/setup.ts

@ -1,17 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
export const useSetupStore = defineStore('Setup', () => { export const useSetupStore = defineStore('Setup', () => {
/**
* @throws if unsuccessful
*/
async function step1(lang: string) {
const response = await $fetch('/api/setup/1', {
method: 'post',
body: { lang },
});
return response.success;
}
/** /**
* @throws if unsuccessful * @throws if unsuccessful
*/ */
@ -45,25 +34,6 @@ export const useSetupStore = defineStore('Setup', () => {
return response.success; return response.success;
} }
type SetupError = {
title: string;
message: string;
};
type ErrorRef = {
value: { publish: (e: SetupError) => void } | null;
};
const errorRef = ref<null | ErrorRef>(null);
function setErrorRef(a: ErrorRef | null) {
errorRef.value = a;
}
function handleError(e: SetupError) {
errorRef.value?.value?.publish(e);
}
const step = ref(1); const step = ref(1);
const totalSteps = ref(6); const totalSteps = ref(6);
function setStep(i: number) { function setStep(i: number) {
@ -71,12 +41,9 @@ export const useSetupStore = defineStore('Setup', () => {
} }
return { return {
step1,
step4, step4,
step5, step5,
runMigration, runMigration,
setErrorRef,
handleError,
step, step,
totalSteps, totalSteps,
setStep, setStep,

26
src/app/stores/toast.ts

@ -0,0 +1,26 @@
export const useToast = defineStore('Toast', () => {
type ToastInterface = {
publish: (e: { title: string; message: string }) => void;
};
type ToastRef = Ref<null | ToastInterface>;
const toast = ref<Ref<ToastRef> | null>(null);
function setToast(toastInstance: ToastRef) {
toast.value = toastInstance;
}
function showToast({
title,
message,
}: {
type: 'success' | 'error';
title: string;
message: string;
}) {
toast.value?.value?.publish({ title, message });
}
return { setToast, showToast };
});

46
src/app/utils/api.ts

@ -1,31 +1,4 @@
class API { class API {
async getSession() {
return useFetch('/api/session', {
method: 'get',
});
}
async createSession({
username,
password,
remember,
}: {
username: string;
password: string | null;
remember: boolean;
}) {
return $fetch('/api/session', {
method: 'post',
body: { username, password, remember },
});
}
async deleteSession() {
return $fetch('/api/session', {
method: 'delete',
});
}
async getClients() { async getClients() {
return useFetch('/api/client', { return useFetch('/api/client', {
method: 'get', method: 'get',
@ -45,31 +18,12 @@ class API {
}); });
} }
async deleteClient({ clientId }: { clientId: string }) {
return $fetch(`/api/client/${clientId}`, {
method: 'delete',
});
}
async showOneTimeLink({ clientId }: { clientId: string }) { async showOneTimeLink({ clientId }: { clientId: string }) {
return $fetch(`/api/client/${clientId}/generateOneTimeLink`, { return $fetch(`/api/client/${clientId}/generateOneTimeLink`, {
method: 'post', method: 'post',
}); });
} }
async updateClientExpireDate({
clientId,
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
return $fetch(`/api/client/${clientId}/expireDate`, {
method: 'put',
body: { expireDate },
});
}
async restoreConfiguration(file: string) { async restoreConfiguration(file: string) {
return $fetch('/api/wireguard/restore', { return $fetch('/api/wireguard/restore', {
method: 'put', method: 'put',

1
src/app/utils/localStorage.ts

@ -1,6 +1,5 @@
export type LocalStorage = { export type LocalStorage = {
uiShowCharts: '1' | '0'; uiShowCharts: '1' | '0';
lang: string;
uiChartType: 'area' | 'bar' | 'line'; uiChartType: 'area' | 'bar' | 'line';
}; };

11
src/app/utils/math.ts

@ -20,17 +20,6 @@ export function bytes(
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
} }
export function dateTime(value: Date) {
// TODO: results in mismatch because of different locales
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(value);
}
/** /**
* Sorts an array of objects by a specified property in ascending or descending order. * Sorts an array of objects by a specified property in ascending or descending order.
* *

49
src/i18n/i18n.config.ts

@ -1,56 +1,9 @@
import en from './locales/en.json'; import en from './locales/en.json';
import ua from './locales/ua.json';
import ru from './locales/ru.json';
import tr from './locales/tr.json';
import no from './locales/no.json';
import pl from './locales/pl.json';
import fr from './locales/fr.json';
import de from './locales/de.json';
import ca from './locales/ca.json';
import es from './locales/es.json';
import ko from './locales/ko.json';
import vi from './locales/vi.json';
import nl from './locales/nl.json';
import is from './locales/is.json';
import pt from './locales/pt.json';
import zhChs from './locales/zh-chs.json';
import zhCht from './locales/zh-cht.json';
import it from './locales/it.json';
import th from './locales/th.json';
import hi from './locales/hi.json';
export default defineI18nConfig(() => ({ export default defineI18nConfig(() => ({
fallbackLocale: 'en',
legacy: false, legacy: false,
locale: 'en', fallbackLocale: 'en',
messages: { messages: {
en, en,
ua,
ru,
// Müslüm Barış Korkmazer @babico
tr,
// github.com/digvalley
no,
// github.com/archont94
pl,
// github.com/clem3109
fr,
de,
// github.com/guillembonet
ca,
// github.com/amarqz
es,
ko,
// https://github.com/hoangneeee
vi,
nl,
is,
pt,
zhChs,
zhCht,
it,
th,
// github.com/rahilarious
hi,
}, },
})); }));

5
src/i18n/localeDetector.ts

@ -4,7 +4,10 @@ export default defineI18nLocaleDetector((event, config) => {
return query.toString(); return query.toString();
} }
const cookie = tryCookieLocale(event, { lang: '', name: 'i18n_locale' }); const cookie = tryCookieLocale(event, {
lang: '',
name: 'i18n_redirected',
});
if (cookie) { if (cookie) {
return cookie.toString(); return cookie.toString();
} }

56
src/i18n/locales/be.json

@ -1,56 +0,0 @@
{
"setup": {
"welcome": "Сардэчна запрашаем на старонку пачатковай налады wg-easy!",
"msg": "Калі ласка, увядзіце лагін для адміністратара і прыдумайце бяспечны пароль. Гэтыя дадзеныя будуць выкарыстоўвацца для ўваходу ў панэль адміністравання.",
"newPassword": "Новы пароль",
"accept": "Я згодны з умовай",
"submitBtn": "Стварыць уліковы запіс адміністратара",
"usernameCondition": "Імя карыстальніка павінна быць не менш за 8 сімвалаў.",
"passwordCondition": "Пароль павінен быць не менш за 12 сімвалаў, уключваючы 1 вялікую літару, 1 малую літару, 1 лічбу і 1 спецыяльны сімвал.",
"usernamePlaceholder": "Адміністратар",
"passwordPlaceholder": "Надзейны пароль"
},
"name": "Імя",
"password": "Пароль",
"signIn": "Увайсці",
"logout": "Выйсці",
"updateAvailable": "Даступна абнаўленне!",
"update": "Абнавіць",
"clients": "Кліенты",
"new": "Стварыць",
"deleteClient": "Выдаліць кліента",
"deleteDialog1": "Вы ўпэўнены, што хочаце выдаліць",
"deleteDialog2": "Гэта дзеянне немагчыма адмяніць.",
"cancel": "Закрыць",
"create": "Стварыць",
"createdOn": "Створана ў ",
"lastSeen": "Апошняе падключэнне ў ",
"totalDownload": "Усяго спампавана: ",
"totalUpload": "Усяго загружана: ",
"newClient": "Стварыць кліента",
"disableClient": "Выключыць кліента",
"enableClient": "Уключыць кліента",
"noClients": "Пакуль няма кліентаў.",
"noPrivKey": "Немагчыма стварыць канфігурацыю: у кліента няма вядомага прыватнага ключа.",
"showQR": "Паказаць QR-код",
"downloadConfig": "Спампаваць канфігурацыю",
"madeBy": "Аўтар",
"donate": "Падзякаваць",
"toggleCharts": "Паказаць/схаваць графікі",
"theme": {
"dark": "Цёмная тэма",
"light": "Светлая тэма",
"system": "Як у сістэме"
},
"restore": "Аднавіць",
"backup": "Рэзервовая копія",
"titleRestoreConfig": "Аднавіць канфігурацыю",
"titleBackupConfig": "Стварыць рэзервовую копію канфігурацыі",
"rememberMe": "Запомніць мяне",
"titleRememberMe": "Заставацца ў сістэме пасля закрыцця браўзера",
"sort": "Сартыроўка",
"ExpireDate": "Дата заканчэння тэрміну",
"Permanent": "Бестэрмінова",
"OneTimeLink": "Стварыць кароткую аднаразовую спасылку",
"errorInit": "Памылка ініцыялізацыі."
}

27
src/i18n/locales/ca.json

@ -1,27 +0,0 @@
{
"name": "Nom",
"password": "Contrasenya",
"signIn": "Iniciar sessió",
"logout": "Tanca sessió",
"updateAvailable": "Hi ha una actualització disponible!",
"update": "Actualitza",
"clients": "Clients",
"new": "Nou",
"deleteClient": "Esborra client",
"deleteDialog1": "Estàs segur que vols esborrar aquest client?",
"deleteDialog2": "Aquesta acció no es pot desfer.",
"cancel": "Cancel·la",
"create": "Crea",
"createdOn": "Creat el ",
"lastSeen": "Última connexió el ",
"totalDownload": "Baixada total: ",
"totalUpload": "Pujada total: ",
"newClient": "Nou client",
"disableClient": "Desactiva client",
"enableClient": "Activa client",
"noClients": "Encara no hi ha cap client.",
"showQR": "Mostra codi QR",
"downloadConfig": "Descarrega configuració",
"madeBy": "Fet per",
"donate": "Donatiu"
}

32
src/i18n/locales/de.json

@ -1,32 +0,0 @@
{
"name": "Name",
"password": "Passwort",
"signIn": "Anmelden",
"logout": "Abmelden",
"updateAvailable": "Eine Aktualisierung steht zur Verfügung!",
"update": "Aktualisieren",
"clients": "Clients",
"new": "Neu",
"deleteClient": "Client löschen",
"deleteDialog1": "Möchtest du wirklich löschen?",
"deleteDialog2": "Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"create": "Erstellen",
"createdOn": "Erstellt am ",
"lastSeen": "Zuletzt Online ",
"totalDownload": "Gesamt Download: ",
"totalUpload": "Gesamt Upload: ",
"newClient": "Neuer Client",
"disableClient": "Client deaktivieren",
"enableClient": "Client aktivieren",
"noClients": "Es wurden noch keine Clients konfiguriert.",
"noPrivKey": "Es ist kein Private Key für diesen Client bekannt. Eine Konfiguration kann nicht erstellt werden.",
"showQR": "Zeige den QR Code",
"downloadConfig": "Konfiguration herunterladen",
"madeBy": "Erstellt von",
"donate": "Spenden",
"restore": "Wiederherstellen",
"backup": "Sichern",
"titleRestoreConfig": "Stelle deine Konfiguration wieder her",
"titleBackupConfig": "Sichere deine Konfiguration"
}

24
src/i18n/locales/en.json

@ -19,7 +19,7 @@
"warning": "First of all, make sure you have a backup of your data if you want to migrate your users to your new wg-easy.", "warning": "First of all, make sure you have a backup of your data if you want to migrate your users to your new wg-easy.",
"next": "Click on the arrow button to proceed to the next step." "next": "Click on the arrow button to proceed to the next step."
}, },
"messageSetupLanguage": "Please choose a default language.", "messageSetupLanguage": "Please choose a language for the setup.",
"messageSetupCreateAdminUser": "Please first enter an admin username and a strong secure password. This information will be used to log in to your administration panel.", "messageSetupCreateAdminUser": "Please first enter an admin username and a strong secure password. This information will be used to log in to your administration panel.",
"messageSetupHostPort": "Please enter the host and port information. This will be used for the client configuration when setting up WireGuard on their devices.", "messageSetupHostPort": "Please enter the host and port information. This will be used for the client configuration when setting up WireGuard on their devices.",
"messageSetupMigration": "Please provide the backup file if you want to migrate your data from your previous wg-easy version to your new setup.", "messageSetupMigration": "Please provide the backup file if you want to migrate your data from your previous wg-easy version to your new setup.",
@ -39,10 +39,25 @@
"migration": "Restore the backup" "migration": "Restore the backup"
}, },
"zod": { "zod": {
"stringMalformed": "String is malformed",
"id": "Client ID must be a valid UUID", "id": "Client ID must be a valid UUID",
"address": "IP Address must be a valid string",
"addressMin": "IP Address must be a be at least 1 Character",
"address4": "IPv4 Address must be a valid string", "address4": "IPv4 Address must be a valid string",
"address4Min": "IPv4 Address must be a be at least 1 Character",
"address6": "IPv6 Address must be a valid string",
"address6Min": "IPv6 Address must be a be at least 1 Character",
"allowedIps": "Allowed IPs must be a valid array of strings",
"allowedIpsMin": "Allowed IPs must have at least 1 item",
"serverAllowedIps": "Allowed IPs must be a valid array of strings",
"name": "Name must be a valid string", "name": "Name must be a valid string",
"nameMin": "Name must be at least 1 Character", "nameMin": "Name must be at least 1 Character",
"mtu": "MTU must be a valid number",
"mtuMin": "MTU must be at least 1280",
"mtuMax": "MTU must be at most 9000",
"persistentKeepalive": "Persistent Keepalive must be a valid number",
"persistentKeepaliveMin": "Persistent Keepalive must be at least 0",
"persistentKeepaliveMax": "Persistent Keepalive must be at most 65535",
"file": "File must be a valid string", "file": "File must be a valid string",
"username": "Username must be a valid string", "username": "Username must be a valid string",
"usernameMin": "Username must be at least 8 Characters", "usernameMin": "Username must be at least 8 Characters",
@ -70,7 +85,12 @@
"hostMin": "Host must contain at least 1 character", "hostMin": "Host must contain at least 1 character",
"port": "Port must be a valid number", "port": "Port must be a valid number",
"portMin": "Port must be at least 1", "portMin": "Port must be at least 1",
"portMax": "Port must be at most 65535" "portMax": "Port must be at most 65535",
"sessionTimeout": "Session Timeout must be a valid number",
"device": "Device must be a valid string",
"deviceMin": "Device must be at least 1 Character",
"hook": "Hook must be a valid string",
"dns": "DNS must be a valid array of strings"
}, },
"name": "Name", "name": "Name",
"username": "Username", "username": "Username",

37
src/i18n/locales/es.json

@ -1,37 +0,0 @@
{
"name": "Nombre",
"password": "Contraseña",
"signIn": "Iniciar sesión",
"logout": "Cerrar sesión",
"updateAvailable": "¡Hay una actualización disponible!",
"update": "Actualizar",
"clients": "Clientes",
"new": "Nuevo",
"deleteClient": "Eliminar cliente",
"deleteDialog1": "¿Estás seguro de que quieres borrar este cliente?",
"deleteDialog2": "Esta acción no podrá ser revertida.",
"cancel": "Cancelar",
"create": "Crear",
"createdOn": "Creado el ",
"lastSeen": "Última conexión el ",
"totalDownload": "Total descargado: ",
"totalUpload": "Total subido: ",
"newClient": "Nuevo cliente",
"disableClient": "Desactivar cliente",
"enableClient": "Activar cliente",
"noClients": "Aún no hay ningún cliente.",
"showQR": "Mostrar código QR",
"downloadConfig": "Descargar configuración",
"madeBy": "Hecho por",
"donate": "Donar",
"toggleCharts": "Mostrar/Ocultar gráficos",
"theme": {
"dark": "Modo oscuro",
"light": "Modo claro",
"system": "Modo automático"
},
"restore": "Restaurar",
"backup": "Realizar copia de seguridad",
"titleRestoreConfig": "Restaurar su configuración",
"titleBackupConfig": "Realizar copia de seguridad de su configuración"
}

122
src/i18n/locales/fr.json

@ -1,122 +0,0 @@
{
"pages": {
"me": "Compte",
"clients": "Clients"
},
"me": {
"sectionGeneral": "Général",
"sectionPassword": "Mot de passe"
},
"email": "E-Mail",
"save": "Enregistrer",
"updatePassword": "Changer le mot de passe",
"currentPassword": "Mot de passe actuel",
"confirmPassword": "Confirmer le mot de passe",
"setup": {
"welcome": "Bienvenue à votre première installation de wg-easy !",
"messageWelcome": {
"whatIs": "Vous avez trouvé le moyen le plus simple d'installer et de gérer WireGuard sur n'importe quel hôte Linux !",
"warning": "Tout d'abord, assurez-vous d'avoir une sauvegarde de vos données si vous voulez migrer vos utilisateurs vers votre nouveau wg-easy.",
"next": "Cliquez sur la flèche pour passer à l'étape suivante."
},
"messageSetupLanguage": "Sélectionner votre langue.",
"messageSetupCreateAdminUser": "Veuillez renseigner votre nom d'utilisateur et votre mot de passe. Ces informations seront utilisées pour vous connecter à votre page d'administration.",
"messageSetupHostPort": "Veuillez entrer les informations de l'hôte et du port. Cela sera utilisé pour la configuration du client lors de la configuration de WireGuard sur leurs appareils.",
"messageSetupMigration": " Veuillez fournir le fichier de sauvegarde si vous souhaitez migrer vos données de la version précédente de wg-easy vers votre nouvelle installation.",
"messageSetupValidation": "Bienvenue sur wg-easy ! La meilleur application pour administrer son serveur VPN WireGuard.",
"emptyFields": "Des champs sont requis",
"chooseLang": "Choisir une langue...",
"newPassword": "Nouveau mot de passe",
"accept": "J'accepte les conditions d'utilisation",
"submitBtn": "Créer un compte administrateur",
"usernamePlaceholder": "Administrateur",
"passwordPlaceholder": "Mot de passe sapau",
"requirements": "Conditions de configuration",
"host": "Hôte",
"hostPlaceholder": "wg-easy.example.com",
"port": "Port",
"portPlaceholder": "443",
"migration": "Restaurer la sauvegarde"
},
"zod": {
"id": "L'ID du client doit être un UUID valide",
"address4": "L'adresse IPv4 doit être une chaîne valide",
"name": "Le nom doit être une chaîne valide",
"nameMin": "Le nom doit contenir au moins 1 caractère",
"file": "Le fichier doit être une chaîne valide",
"username": "Le nom d'utilisateur doit être une chaîne valide",
"usernameMin": "Le nom d'utilisateur doit contenir au moins 8 caractères",
"password": "Le mot de passe doit être une chaîne valide",
"passwordMin": "Le mot de passe doit contenir au moins 12 caractères",
"passwordUppercase": "Le mot de passe doit contenir au moins 1 lettre majuscule",
"passwordLowercase": "Le mot de passe doit contenir au moins 1 lettre minuscule",
"passwordNumber": "Le mot de passe doit contenir au moins 1 chiffre",
"passwordSpecial": "Le mot de passe doit contenir au moins 1 caractère spécial",
"accept": "Veuillez accpter les conditions d'utilisation",
"remember": "La case « se souvenir de moi » doit être un booléen valide",
"expireDate": "La date d'expiration doit être une chaîne valide",
"expireDateMin": "La date d'expiration doit contenir au moins 1 caractère",
"otl": "Le lien à usage unique doit être une chaîne valide",
"otlMin": "Le lien à usage unique doit contenir au moins 1 caractère",
"features": "La clé doit être une chaîne valide",
"ftBool": "Le paramètre « activé » doit être un booléen valide",
"ftObj": "La valeur doit être un objet valide",
"ftObj2": "Les fonctionnalités doivent être un enregistrement valide",
"stat": "Les statistiques doivent être un objet valide",
"statBool": "Le paramètre « activé » doit être un booléen valide",
"statNumber": "Le type de graphique doit être un nombre valide",
"body": "Le corps doit être un objet valide",
"host": "L'hôte doit être une chaîne valide",
"hostMin": "L'hôte doit contenir au moins 1 caractère",
"port": "Le port doit être un nombre valide",
"portMin": "Le port doit être au moins 1",
"portMax": "Le port doit être au maximum 65535"
},
"name": "Nom",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"signIn": "Se Connecter",
"logout": "Se déconnecter",
"updateAvailable": "Une mise à jour est disponible !",
"update": "Mise à jour",
"new": "Nouveau",
"deleteClient": "Supprimer ce client",
"deleteDialog1": "Êtes-vous sûr de vouloir supprimer",
"deleteDialog2": "Cette action ne peut pas être annulée.",
"cancel": "Annuler",
"create": "Créer",
"createdOn": "Créé le ",
"lastSeen": "Dernière connexion le ",
"totalDownload": "Téléchargement total : ",
"totalUpload": "Téléversement total : ",
"newClient": "Nouveau client",
"disableClient": "Désactiver ce client",
"enableClient": "Activer ce client",
"noClients": "Aucun client pour le moment.",
"noPrivKey": "Ce client n'a pas de clé privée connue. Impossible de créer la configuration.",
"showQR": "Afficher le code QR",
"downloadConfig": "Télécharger la configuration",
"madeBy": "Développé par",
"donate": "Faire un don",
"toggleCharts": "Afficher/masquer les graphiques",
"theme": {
"dark": "Thème sombre",
"light": "Thème clair",
"system": "Thème du système"
},
"restore": "Restaurer",
"backup": "Sauvegarder",
"titleRestoreConfig": "Restaurer votre configuration",
"titleBackupConfig": "Sauvegarder votre configuration",
"rememberMe": "Se souvenir de moi",
"titleRememberMe": "Restez connecté après la fermeture du navigateur",
"sort": "Trier",
"ExpireDate": "Date d'expiration",
"Permanent": "Permanent",
"OneTimeLink": "Générer un lien court à usage unique",
"errorInit": "Échec de l'initialisation.",
"error": {
"clear": "Effacer",
"login": "Erreur de connexion"
}
}

28
src/i18n/locales/hi.json

@ -1,28 +0,0 @@
{
"name": "नाम",
"password": "पासवर्ड",
"signIn": "लॉगिन",
"logout": "लॉगआउट",
"updateAvailable": "अपडेट उपलब्ध है!",
"update": "अपडेट",
"clients": "उपयोगकर्ताये",
"new": "नया",
"deleteClient": "उपयोगकर्ता हटाएँ",
"deleteDialog1": "क्या आपको पक्का हटाना है",
"deleteDialog2": "यह निर्णय पलट नहीं सकता।",
"cancel": "कुछ ना करें",
"create": "बनाएं",
"createdOn": "सर्जन तारीख ",
"lastSeen": "पिछली बार देखे गए थे ",
"totalDownload": "कुल डाउनलोड: ",
"totalUpload": "कुल अपलोड: ",
"newClient": "नया उपयोगकर्ता",
"disableClient": "उपयोगकर्ता स्थगित कीजिये",
"enableClient": "उपयोगकर्ता शुरू कीजिये",
"noClients": "अभी तक कोई भी उपयोगकर्ता नहीं है।",
"noPrivKey": "ये उपयोगकर्ता की कोई भी गुप्त चाबी नहीं हे। बना नहीं सकते।",
"showQR": "क्यू आर कोड देखिये",
"downloadConfig": "डाउनलोड कॉन्फीग्यूरेशन",
"madeBy": "सर्जक",
"donate": "दान करें"
}

27
src/i18n/locales/is.json

@ -1,27 +0,0 @@
{
"name": "Nafn",
"password": "Lykilorð",
"signIn": "Skrá inn",
"logout": "Útskráning",
"updateAvailable": "Það er uppfærsla í boði!",
"update": "Uppfæra",
"clients": "Viðskiptavinir",
"new": "Nýtt",
"deleteClient": "Eyða viðskiptavin",
"deleteDialog1": "Ertu viss um að þú viljir eyða",
"deleteDialog2": "Þessi aðgerð getur ekki verið afturkallað.",
"cancel": "Hætta við",
"create": "Búa til",
"createdOn": "Búið til á ",
"lastSeen": "Síðast séð á ",
"totalDownload": "Samtals Niðurhlaða: ",
"totalUpload": "Samtals Upphlaða: ",
"newClient": "Nýr Viðskiptavinur",
"disableClient": "Gera viðskiptavin óvirkan",
"enableClient": "Gera viðskiptavin virkan",
"noClients": "Engir viðskiptavinir ennþá.",
"showQR": "Sýna QR-kóða",
"downloadConfig": "Niðurhal Stillingar",
"madeBy": "Gert af",
"donate": "Gefa"
}

31
src/i18n/locales/it.json

@ -1,31 +0,0 @@
{
"name": "Nome",
"password": "Password",
"signIn": "Accedi",
"logout": "Esci",
"updateAvailable": "È disponibile un aggiornamento!",
"update": "Aggiorna",
"clients": "Client",
"new": "Nuovo",
"deleteClient": "Elimina Client",
"deleteDialog1": "Sei sicuro di voler eliminare",
"deleteDialog2": "Questa azione non può essere annullata.",
"cancel": "Annulla",
"create": "Crea",
"createdOn": "Creato il ",
"lastSeen": "Visto l'ultima volta il ",
"totalDownload": "Totale Download: ",
"totalUpload": "Totale Upload: ",
"newClient": "Nuovo Client",
"disableClient": "Disabilita Client",
"enableClient": "Abilita Client",
"noClients": "Non ci sono ancora client.",
"showQR": "Mostra codice QR",
"downloadConfig": "Scarica configurazione",
"madeBy": "Realizzato da",
"donate": "Donazione",
"restore": "Ripristina",
"backup": "Backup",
"titleRestoreConfig": "Ripristina la tua configurazione",
"titleBackupConfig": "Esegui il backup della tua configurazione"
}

37
src/i18n/locales/ko.json

@ -1,37 +0,0 @@
{
"name": "이름",
"password": "암호",
"signIn": "로그인",
"logout": "로그아웃",
"updateAvailable": "업데이트가 있습니다!",
"update": "업데이트",
"clients": "클라이언트",
"new": "추가",
"deleteClient": "클라이언트 삭제",
"deleteDialog1": "삭제 하시겠습니까?",
"deleteDialog2": "이 작업은 취소할 수 없습니다.",
"cancel": "취소",
"create": "생성",
"createdOn": "생성일: ",
"lastSeen": "마지막 사용 날짜: ",
"totalDownload": "총 다운로드: ",
"totalUpload": "총 업로드: ",
"newClient": "새로운 클라이언트",
"disableClient": "클라이언트 비활성화",
"enableClient": "클라이언트 활성화",
"noClients": "아직 클라이언트가 없습니다.",
"showQR": "QR 코드 표시",
"downloadConfig": "구성 다운로드",
"madeBy": "만든 사람",
"donate": "기부",
"toggleCharts": "차트 표시/숨기기",
"theme": {
"dark": "어두운 테마",
"light": "밝은 테마",
"system": "자동 테마"
},
"restore": "복원",
"backup": "백업",
"titleRestoreConfig": "구성 파일 복원",
"titleBackupConfig": "구성 파일 백업"
}

27
src/i18n/locales/nl.json

@ -1,27 +0,0 @@
{
"name": "Naam",
"password": "Wachtwoord",
"signIn": "Inloggen",
"logout": "Uitloggen",
"updateAvailable": "Nieuw update beschikbaar!",
"update": "update",
"clients": "clients",
"new": "Nieuw",
"deleteClient": "client verwijderen",
"deleteDialog1": "Weet je zeker dat je wilt verwijderen",
"deleteDialog2": "Deze actie kan niet ongedaan worden gemaakt.",
"cancel": "Annuleren",
"create": "Creëren",
"createdOn": "Gemaakt op ",
"lastSeen": "Laatst gezien op ",
"totalDownload": "Totaal Gedownload: ",
"totalUpload": "Totaal Geupload: ",
"newClient": "Nieuwe client",
"disableClient": "client uitschakelen",
"enableClient": "client inschakelen",
"noClients": "Er zijn nog geen clients.",
"showQR": "QR-code weergeven",
"downloadConfig": "Configuratie downloaden",
"madeBy": "Gemaakt door",
"donate": "Doneren"
}

27
src/i18n/locales/no.json

@ -1,27 +0,0 @@
{
"name": "Navn",
"password": "Passord",
"signIn": "Logg Inn",
"logout": "Logg Ut",
"updateAvailable": "En ny oppdatering er tilgjengelig!",
"update": "Oppdater",
"clients": "Klienter",
"new": "Ny",
"deleteClient": "Slett Klient",
"deleteDialog1": "Er du sikker på at du vil slette?",
"deleteDialog2": "Denne handlingen kan ikke angres",
"cancel": "Avbryt",
"create": "Opprett",
"createdOn": "Opprettet ",
"lastSeen": "Sist sett ",
"totalDownload": "Total Nedlasting: ",
"totalUpload": "Total Opplasting: ",
"newClient": "Ny Klient",
"disableClient": "Deaktiver Klient",
"enableClient": "Aktiver Klient",
"noClients": "Ingen klienter opprettet enda.",
"showQR": "Vis QR Kode",
"downloadConfig": "Last Ned Konfigurasjon",
"madeBy": "Laget av",
"donate": "Doner"
}

27
src/i18n/locales/pl.json

@ -1,27 +0,0 @@
{
"name": "Nazwa",
"password": "Hasło",
"signIn": "Zaloguj się",
"logout": "Wyloguj się",
"updateAvailable": "Dostępna aktualizacja!",
"update": "Aktualizuj",
"clients": "Klienci",
"new": "Stwórz klienta",
"deleteClient": "Usuń klienta",
"deleteDialog1": "Jesteś pewny że chcesz usunąć",
"deleteDialog2": "Tej akcji nie da się cofnąć.",
"cancel": "Anuluj",
"create": "Stwórz",
"createdOn": "Utworzono ",
"lastSeen": "Ostatnio widziany ",
"totalDownload": "Całkowite pobieranie: ",
"totalUpload": "Całkowite wysyłanie: ",
"newClient": "Nowy klient",
"disableClient": "Wyłączenie klienta",
"enableClient": "Włączenie klienta",
"noClients": "Nie ma jeszcze klientów.",
"showQR": "Pokaż kod QR",
"downloadConfig": "Pobierz konfigurację",
"madeBy": "Stworzone przez",
"donate": "Wsparcie autora"
}

27
src/i18n/locales/pt.json

@ -1,27 +0,0 @@
{
"name": "Nome",
"password": "Palavra Chave",
"signIn": "Entrar",
"logout": "Sair",
"updateAvailable": "Existe uma atualização disponível!",
"update": "Atualizar",
"clients": "Clientes",
"new": "Novo",
"deleteClient": "Apagar Clientes",
"deleteDialog1": "Tem certeza que pretende apagar",
"deleteDialog2": "Esta ação não pode ser revertida.",
"cancel": "Cancelar",
"create": "Criar",
"createdOn": "Criado em ",
"lastSeen": "Último acesso em ",
"totalDownload": "Total Download: ",
"totalUpload": "Total Upload: ",
"newClient": "Novo Cliente",
"disableClient": "Desativar Cliente",
"enableClient": "Ativar Cliente",
"noClients": "Não existem ainda clientes.",
"showQR": "Apresentar o código QR",
"downloadConfig": "Descarregar Configuração",
"madeBy": "Feito por",
"donate": "Doar"
}

56
src/i18n/locales/ru.json

@ -1,56 +0,0 @@
{
"setup": {
"welcome": "Добро пожаловать на страницу начальной настройки wg-easy !",
"msg": "Пожалуйста, введите логин для администратора и придумайте безопасный пароль. Эти данные будут использоваться для входа в панель администрирования.",
"newPassword": "Новый пароль",
"accept": "Я согласен с условием",
"submitBtn": "Создать учетную запись администратора",
"usernameCondition": "Имя пользователя должно быть не менее 8 символов.",
"passwordCondition": "Пароль должен быть не менее 12 символов, включая 1 заглавную букву, 1 прописную букву, 1 цифру и 1 специальный символ.",
"usernamePlaceholder": "Администратор",
"passwordPlaceholder": "Надежный пароль"
},
"name": "Имя",
"password": "Пароль",
"signIn": "Войти",
"logout": "Выйти",
"updateAvailable": "Доступно обновление!",
"update": "Обновить",
"clients": "Клиенты",
"new": "Создать",
"deleteClient": "Удалить клиента",
"deleteDialog1": "Вы уверены, что хотите удалить",
"deleteDialog2": "Это действие невозможно отменить.",
"cancel": "Закрыть",
"create": "Создать",
"createdOn": "Создано в ",
"lastSeen": "Последнее подключение в ",
"totalDownload": "Всего скачано: ",
"totalUpload": "Всего загружено: ",
"newClient": "Создать клиента",
"disableClient": "Выключить клиента",
"enableClient": "Включить клиента",
"noClients": "Пока нет клиентов.",
"noPrivKey": "Невозможно создать конфигурацию: у клиента нет известного приватного ключа.",
"showQR": "Показать QR-код",
"downloadConfig": "Скачать конфигурацию",
"madeBy": "Автор",
"donate": "Поблагодарить",
"toggleCharts": "Показать/скрыть графики",
"theme": {
"dark": "Тёмная тема",
"light": "Светлая тема",
"system": "Как в системе"
},
"restore": "Восстановить",
"backup": "Резервная копия",
"titleRestoreConfig": "Восстановить конфигурацию",
"titleBackupConfig": "Создать резервную копию конфигурации",
"rememberMe": "Запомнить меня",
"titleRememberMe": "Оставаться в системе после закрытия браузера",
"sort": "Сортировка",
"ExpireDate": "Дата истечения срока",
"Permanent": "Бессрочно",
"OneTimeLink": "Создать короткую одноразовую ссылку",
"errorInit": "Ошибка инициализации."
}

27
src/i18n/locales/th.json

@ -1,27 +0,0 @@
{
"name": "ชื่อ",
"password": "รหัสผ่าน",
"signIn": "ลงชื่อเข้าใช้",
"logout": "ออกจากระบบ",
"updateAvailable": "มีอัปเดตพร้อมใช้งาน!",
"update": "อัปเดต",
"clients": "Clients",
"new": "ใหม่",
"deleteClient": "ลบ Client",
"deleteDialog1": "คุณแน่ใจหรือไม่ว่าต้องการลบ",
"deleteDialog2": "การกระทำนี้;ไม่สามารถยกเลิกได้",
"cancel": "ยกเลิก",
"create": "สร้าง",
"createdOn": "สร้างเมื่อ ",
"lastSeen": "เห็นครั้งสุดท้ายเมื่อ ",
"totalDownload": "ดาวน์โหลดทั้งหมด: ",
"totalUpload": "อัพโหลดทั้งหมด: ",
"newClient": "Client ใหม่",
"disableClient": "ปิดการใช้งาน Client",
"enableClient": "เปิดการใช้งาน Client",
"noClients": "ยังไม่มี Clients เลย",
"showQR": "แสดงรหัส QR",
"downloadConfig": "ดาวน์โหลดการตั้งค่า",
"madeBy": "สร้างโดย",
"donate": "บริจาค"
}

38
src/i18n/locales/tr.json

@ -1,38 +0,0 @@
{
"name": "İsim",
"password": "Şifre",
"signIn": "Giriş Yap",
"logout": "Çıkış Yap",
"updateAvailable": "Mevcut bir güncelleme var!",
"update": "Güncelle",
"clients": "Kullanıcılar",
"new": "Yeni",
"deleteClient": "Kullanıcı Sil",
"deleteDialog1": "Silmek istediğine emin misin",
"deleteDialog2": "Bu işlem geri alınamaz.",
"cancel": "İptal",
"create": "Oluştur",
"createdOn": "Şu saatte oluşturuldu: ",
"lastSeen": "Son görülme tarihi: ",
"totalDownload": "Toplam İndirme: ",
"totalUpload": "Toplam Yükleme: ",
"newClient": "Yeni Kullanıcı",
"disableClient": "Kullanıcıyı Devre Dışı Bırak",
"enableClient": "Kullanıcıyı Etkinleştir",
"noClients": "Henüz kullanıcı yok.",
"noPrivKey": "Bu istemcinin bilinen bir özel anahtarı yok. Yapılandırma oluşturulamıyor.",
"showQR": "QR Kodunu Göster",
"downloadConfig": "Yapılandırmayı İndir",
"madeBy": "Yapan Kişi: ",
"donate": "Bağış Yap",
"toggleCharts": "Grafiği göster/gizle",
"theme": {
"dark": "Karanlık tema",
"light": "Açık tema",
"system": "Otomatik tema"
},
"restore": "Geri yükle",
"backup": "Yedekle",
"titleRestoreConfig": "Yapılandırmanızı geri yükleyin",
"titleBackupConfig": "Yapılandırmanızı yedekleyin"
}

38
src/i18n/locales/ua.json

@ -1,38 +0,0 @@
{
"name": "Ім`я",
"password": "Пароль",
"signIn": "Увійти",
"logout": "Вихід",
"updateAvailable": "Доступне оновлення!",
"update": "Оновити",
"clients": "Клієнти",
"new": "Новий",
"deleteClient": "Видалити клієнта",
"deleteDialog1": "Ви впевнені, що бажаєте видалити",
"deleteDialog2": "Цю дію неможливо скасувати.",
"cancel": "Скасувати",
"create": "Створити",
"createdOn": "Створено ",
"lastSeen": "Останнє підключення в ",
"totalDownload": "Всього завантажено: ",
"totalUpload": "Всього відправлено: ",
"newClient": "Новий клієнт",
"disableClient": "Вимкнути клієнта",
"enableClient": "Увімкнути клієнта",
"noClients": "Ще немає клієнтів.",
"noPrivKey": "У цього клієнта немає відомого приватного ключа. Неможливо створити конфігурацію.",
"showQR": "Показати QR-код",
"downloadConfig": "Завантажити конфігурацію",
"madeBy": "Зроблено",
"donate": "Пожертвувати",
"toggleCharts": "Показати/сховати діаграми",
"theme": {
"dark": "Темна тема",
"light": "Світла тема",
"system": "Автоматична тема"
},
"restore": "Відновити",
"backup": "Резервна копія",
"titleRestoreConfig": "Відновити конфігурацію",
"titleBackupConfig": "Створити резервну копію конфігурації"
}

38
src/i18n/locales/vi.json

@ -1,38 +0,0 @@
{
"name": "Tên",
"password": "Mật khẩu",
"signIn": "Đăng nhập",
"logout": "Đăng xuất",
"updateAvailable": "Có bản cập nhật mới!",
"update": "Cập nhật",
"clients": "Danh sách người dùng",
"new": "Mới",
"deleteClient": "Xóa người dùng",
"deleteDialog1": "Bạn có chắc chắn muốn xóa",
"deleteDialog2": "Thao tác này không thể hoàn tác.",
"cancel": "Huỷ",
"create": "Tạo",
"createdOn": "Được tạo lúc ",
"lastSeen": "Lần xem cuối vào ",
"totalDownload": "Tổng dung lượng tải xuống: ",
"totalUpload": "Tổng dung lượng tải lên: ",
"newClient": "Người dùng mới",
"disableClient": "Vô hiệu hóa người dùng",
"enableClient": "Kích hoạt người dùng",
"noClients": "Hiện chưa có người dùng nào.",
"showQR": "Hiển thị mã QR",
"downloadConfig": "Tải xuống cấu hình",
"madeBy": "Được tạo bởi",
"donate": "Ủng hộ",
"toggleCharts": "Mở/Ẩn Biểu đồ",
"theme": {
"dark": "Dark theme",
"light": "Light theme",
"system": "System theme"
},
"restore": "Khôi phục",
"backup": "Sao lưu",
"titleRestoreConfig": "Khôi phục cấu hình của bạn",
"titleBackupConfig": "Sao lưu cấu hình của bạn",
"sort": "Sắp xếp"
}

40
src/i18n/locales/zh-chs.json

@ -1,40 +0,0 @@
{
"name": "名称",
"password": "密码",
"signIn": "登录",
"logout": "退出",
"updateAvailable": "有新版本可用!",
"update": "更新",
"clients": "客户端",
"new": "新建",
"deleteClient": "删除客户端",
"deleteDialog1": "您确定要删除",
"deleteDialog2": "此操作无法撤销。",
"cancel": "取消",
"create": "创建",
"createdOn": "创建于 ",
"lastSeen": "最后访问于 ",
"totalDownload": "总下载: ",
"totalUpload": "总上传: ",
"newClient": "新建客户端",
"disableClient": "禁用客户端",
"enableClient": "启用客户端",
"noClients": "目前没有客户端。",
"noPrivKey": "此客户端没有已知的私钥。无法创建配置。",
"showQR": "显示二维码",
"downloadConfig": "下载配置",
"madeBy": "由",
"donate": "捐赠",
"toggleCharts": "显示/隐藏图表",
"theme": { "dark": "暗黑主题", "light": "明亮主题", "system": "自动主题" },
"restore": "恢复",
"backup": "备份",
"titleRestoreConfig": "恢复您的配置",
"titleBackupConfig": "备份您的配置",
"rememberMe": "记住我",
"titleRememberMe": "关闭浏览器后保持登录",
"sort": "排序",
"ExpireDate": "到期日期",
"Permanent": "永久",
"OneTimeLink": "生成一次性短链接"
}

40
src/i18n/locales/zh-cht.json

@ -1,40 +0,0 @@
{
"name": "名字",
"password": "密碼",
"signIn": "登入",
"logout": "登出",
"updateAvailable": "有新版本可以使用!",
"update": "更新",
"clients": "使用者",
"new": "建立",
"deleteClient": "刪除使用者",
"deleteDialog1": "您確定要刪除",
"deleteDialog2": "此作業無法復原。",
"cancel": "取消",
"create": "建立",
"createdOn": "建立於 ",
"lastSeen": "最後存取於 ",
"totalDownload": "總下載: ",
"totalUpload": "總上傳: ",
"newClient": "新用戶",
"disableClient": "停用使用者",
"enableClient": "啟用使用者",
"noClients": "目前沒有使用者。",
"noPrivKey": "此使用者沒有已知的私鑰。無法創建配置。",
"showQR": "顯示 QR Code",
"downloadConfig": "下載 Config 檔",
"madeBy": "由",
"donate": "抖內",
"toggleCharts": "顯示/隱藏圖表",
"theme": { "dark": "暗黑主題", "light": "明亮主題", "system": "自動主題" },
"restore": "恢復",
"backup": "備份",
"titleRestoreConfig": "恢復您的配置",
"titleBackupConfig": "備份您的配置",
"rememberMe": "記住我",
"titleRememberMe": "關閉瀏覽器後保持登錄",
"sort": "排序",
"ExpireDate": "到期日期",
"Permanent": "永久",
"OneTimeLink": "生成一次性短鏈接"
}

14
src/nuxt.config.ts

@ -23,10 +23,24 @@ export default defineNuxtConfig({
experimental: { experimental: {
localeDetector: './localeDetector.ts', localeDetector: './localeDetector.ts',
}, },
locales: [
{
code: 'en',
language: 'en-US',
name: 'English',
},
],
defaultLocale: 'en',
vueI18n: './i18n.config.ts',
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
},
}, },
nitro: { nitro: {
esbuild: { esbuild: {
options: { options: {
// to support big int
target: 'es2020', target: 'es2020',
}, },
}, },

21
src/package.json

@ -14,37 +14,38 @@
"format": "prettier . --write", "format": "prettier . --write",
"format:check": "prettier . --check", "format:check": "prettier . --check",
"typecheck": "nuxt typecheck", "typecheck": "nuxt typecheck",
"check:runall": "nuxt build && nuxt typecheck && eslint . && prettier . --check" "check:all": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build"
}, },
"dependencies": { "dependencies": {
"@eschricht/nuxt-color-mode": "^1.1.5", "@eschricht/nuxt-color-mode": "^1.1.5",
"@nuxtjs/i18n": "^9.1.1", "@nuxtjs/i18n": "^9.1.1",
"@nuxtjs/tailwindcss": "^6.12.2", "@nuxtjs/tailwindcss": "^6.12.2",
"@pinia/nuxt": "^0.9.0", "@pinia/nuxt": "^0.9.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.10",
"apexcharts": "^4.2.0", "apexcharts": "^4.3.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"basic-auth": "^2.0.1", "basic-auth": "^2.0.1",
"cidr-tools": "^11.0.2", "cidr-tools": "^11.0.2",
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"debug": "^4.4.0", "debug": "^4.4.0",
"ip-bigint": "^8.2.0", "ip-bigint": "^8.2.0",
"is-cidr": "^5.1.0",
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"js-sha256": "^0.11.0", "js-sha256": "^0.11.0",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"nuxt": "^3.14.1592", "nuxt": "^3.15.1",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-vue": "^1.9.11", "radix-vue": "^1.9.12",
"semver": "^7.6.3", "semver": "^7.6.3",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.17",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"vue": "latest", "vue": "latest",
"vue3-apexcharts": "^1.8.0", "vue3-apexcharts": "^1.8.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^0.7.3", "@nuxt/eslint-config": "^0.7.5",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
@ -52,8 +53,8 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.9",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.2.0"
}, },
"packageManager": "[email protected].0" "packageManager": "[email protected].3"
} }

2175
src/pnpm-lock.yaml

File diff suppressed because it is too large

4
src/server/api/admin/general.get.ts

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

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

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

4
src/server/api/admin/hooks.get.ts

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

9
src/server/api/admin/hooks.post.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(hooksUpdateType, event)
);
await Database.system.updateHooks(data);
await WireGuard.saveConfig();
return { success: true };
});

4
src/server/api/admin/interface.get.ts

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

9
src/server/api/admin/interface.post.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(interfaceUpdateType, event)
);
await Database.system.updateInterface(data);
await WireGuard.saveConfig();
return { success: true };
});

5
src/server/api/admin/lang.post.ts

@ -1,5 +0,0 @@
export default defineEventHandler(async (event) => {
const { lang } = await readValidatedBody(event, validateZod(langType));
await Database.system.updateLang(lang);
return { success: true };
});

9
src/server/api/admin/userconfig/cidr.post.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(cidrUpdateType, event)
);
await WireGuard.updateAddressRange(data);
return { success: true };
});

4
src/server/api/admin/userconfig/index.get.ts

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

9
src/server/api/admin/userconfig/index.post.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(userConfigUpdateType, event)
);
await Database.system.updateUserConfig(data);
await WireGuard.saveConfig();
return { success: true };
});

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

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

11
src/server/api/client/[clientId]/expireDate.put.ts → src/server/api/client/[clientId]/index.post.ts

@ -3,13 +3,16 @@ export default defineEventHandler(async (event) => {
event, event,
validateZod(clientIdType) validateZod(clientIdType)
); );
const { expireDate } = await readValidatedBody(
const data = await readValidatedBody(
event, event,
validateZod(expireDateType) validateZod(clientUpdateType, event)
); );
await WireGuard.updateClientExpireDate({
await WireGuard.updateClient({
clientId, clientId,
expireDate, client: data,
}); });
return { success: true }; return { success: true };
}); });

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

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

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

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

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

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

14
src/server/api/setup/1.post.ts

@ -1,14 +0,0 @@
export default defineEventHandler(async (event) => {
const setupDone = await Database.setup.done();
if (setupDone) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
const { lang } = await readValidatedBody(event, validateZod(langType));
await Database.system.updateLang(lang);
await Database.setup.set(2);
return { success: true };
});

5
src/server/api/setup/migrate.post.ts

@ -61,11 +61,10 @@ export default defineEventHandler(async (event) => {
clients: {} as Database['clients'], clients: {} as Database['clients'],
}; };
for (const [oldId, oldClient] of Object.entries(oldConfig.clients)) { for (const oldClient of Object.values(oldConfig.clients)) {
const address6 = nextIPv6(db.system, db.clients); const address6 = nextIPv6(db.system, db.clients);
await Database.client.create({ await Database.client.create({
id: oldId,
address4: oldClient.address, address4: oldClient.address,
enabled: oldClient.enabled, enabled: oldClient.enabled,
name: oldClient.name, name: oldClient.name,
@ -74,7 +73,7 @@ export default defineEventHandler(async (event) => {
publicKey: oldClient.publicKey, publicKey: oldClient.publicKey,
expiresAt: null, expiresAt: null,
oneTimeLink: null, oneTimeLink: null,
allowedIPs: [...db.system.userConfig.allowedIps], allowedIps: [...db.system.userConfig.allowedIps],
serverAllowedIPs: [], serverAllowedIPs: [],
persistentKeepalive: 0, persistentKeepalive: 0,
address6: address6, address6: address6,

1
src/server/middleware/session.ts

@ -8,7 +8,6 @@ export default defineEventHandler(async (event) => {
!url.pathname.startsWith('/api/') || !url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/api/setup/') || url.pathname.startsWith('/api/setup/') ||
url.pathname === '/api/session' || url.pathname === '/api/session' ||
url.pathname === '/api/lang' ||
url.pathname === '/api/release' url.pathname === '/api/release'
) { ) {
return; return;

0
src/server/api/cnf/[oneTimeLink].ts → src/server/routes/cnf/[oneTimeLink].ts

14
src/server/routes/metrics/index.get.ts

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
// TODO: check password
const system = await Database.system.get();
if (!system.metrics.prometheus.enabled) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
});
}
setHeader(event, 'Content-Type', 'text/plain');
return WireGuard.getMetrics();
});

13
src/server/routes/metrics/json.get.ts

@ -0,0 +1,13 @@
export default defineEventHandler(async () => {
// TODO: check password
const system = await Database.system.get();
if (!system.metrics.prometheus.enabled) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
});
}
return WireGuard.getMetricsJSON();
});

77
src/server/utils/WireGuard.ts

@ -1,11 +1,13 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import debug from 'debug'; import debug from 'debug';
import crypto from 'node:crypto';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import CRC32 from 'crc-32'; import CRC32 from 'crc-32';
import isCidr from 'is-cidr';
import type { NewClient } from '~~/services/database/repositories/client'; import type {
import { isIPv4 } from 'is-ip'; CreateClient,
UpdateClient,
} from '~~/services/database/repositories/client';
const DEBUG = debug('WireGuard'); const DEBUG = debug('WireGuard');
@ -59,7 +61,7 @@ class WireGuard {
createdAt: new Date(client.createdAt), createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt), updatedAt: new Date(client.updatedAt),
expiresAt: client.expiresAt, expiresAt: client.expiresAt,
allowedIPs: client.allowedIPs, allowedIps: client.allowedIps,
oneTimeLink: client.oneTimeLink, oneTimeLink: client.oneTimeLink,
persistentKeepalive: null as string | null, persistentKeepalive: null as string | null,
latestHandshakeAt: null as Date | null, latestHandshakeAt: null as Date | null,
@ -140,11 +142,7 @@ class WireGuard {
const address6 = nextIPv6(system, clients); const address6 = nextIPv6(system, clients);
// Create Client const client: CreateClient = {
const id = crypto.randomUUID();
const client: NewClient = {
id,
name, name,
address4, address4,
address6, address6,
@ -154,8 +152,8 @@ class WireGuard {
oneTimeLink: null, oneTimeLink: null,
expiresAt: null, expiresAt: null,
enabled: true, enabled: true,
allowedIPs: [...system.userConfig.allowedIps], allowedIps: [...system.userConfig.allowedIps],
serverAllowedIPs: null, serverAllowedIPs: [],
persistentKeepalive: system.userConfig.persistentKeepalive, persistentKeepalive: system.userConfig.persistentKeepalive,
mtu: system.userConfig.mtu, mtu: system.userConfig.mtu,
}; };
@ -208,55 +206,48 @@ class WireGuard {
await this.saveConfig(); await this.saveConfig();
} }
async updateClientName({ async updateClient({
clientId, clientId,
name, client,
}: { }: {
clientId: string; clientId: string;
name: string; client: UpdateClient;
}) { }) {
await Database.client.updateName(clientId, name); // TODO: validate ipv4, v6, expire date etc
await Database.client.update(clientId, client);
await this.saveConfig(); await this.saveConfig();
} }
async updateClientAddress({ async updateAddressRange({
clientId,
address4, address4,
address6,
}: { }: {
clientId: string;
address4: string; address4: string;
address6: string;
}) { }) {
if (!isIPv4(address4)) { // TODO: be able to revert if error
throw createError({
statusCode: 400, if (!isCidr(address4) || !isCidr(address6)) {
statusMessage: `Invalid Address: ${address4}`, throw new Error('Invalid CIDR');
});
} }
await Database.client.updateAddress4(clientId, address4); await Database.system.updateAddressRange(address4, address6);
await this.saveConfig(); const systems = await Database.system.get();
} const clients = await Database.client.findAll();
async updateClientExpireDate({ for (const _client of Object.values(clients)) {
clientId, const clients = await Database.client.findAll();
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
let updatedDate: string | null = null;
if (expireDate) { const client = structuredClone(_client) as DeepWriteable<typeof _client>;
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
updatedDate = date.toISOString();
}
await Database.client.updateExpirationDate(clientId, updatedDate); client.address4 = nextIPv4(systems, clients);
client.address6 = nextIPv6(systems, clients);
await Database.client.update(client.id, {
...client,
});
}
await this.saveConfig(); await this.saveConfig();
} }

2
src/server/utils/release.ts

@ -30,7 +30,7 @@ async function fetchLatestRelease() {
try { try {
const response = await $fetch<GithubRelease>( const response = await $fetch<GithubRelease>(
'https://api.github.com/repos/wg-easy/wg-easy/releases/latest', 'https://api.github.com/repos/wg-easy/wg-easy/releases/latest',
{ method: 'get' } { method: 'get', timeout: 5000 }
); );
if (!response) { if (!response) {
throw new Error('Empty Response'); throw new Error('Empty Response');

27
src/server/utils/template.ts

@ -0,0 +1,27 @@
import type { DeepReadonly } from 'vue';
import type { System } from '~~/services/database/repositories/system';
/**
* Replace all {{key}} in the template with the values[key]
*/
export function template(templ: string, values: Record<string, string>) {
return templ.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return values[key] !== undefined ? values[key] : match;
});
}
/**
* Available keys:
* - address4: IPv4 address range
* - address6: IPv6 address range
* - device: Network device
* - port: Port number
*/
export function iptablesTemplate(templ: string, system: DeepReadonly<System>) {
return template(templ, {
address4: system.userConfig.address4Range,
address6: system.userConfig.address6Range,
device: system.interface.device,
port: system.interface.port.toString(),
});
}

225
src/server/utils/types.ts

@ -1,68 +1,16 @@
import type { ZodSchema } from 'zod'; import type { ZodSchema, ZodTypeDef } from 'zod';
import { z, ZodError } from 'zod'; import { z, ZodError } from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3'; import type { H3Event, EventHandlerRequest } from 'h3';
import { LOCALES } from '#shared/locales';
// TODO: use i18n for messages const objectMessage = 'zod.body';
const safeStringRefine = z const safeStringRefine = z
.string() .string()
.refine( .refine(
(v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype', (v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype',
{ message: 'String is malformed' } { message: 'zod.stringMalformed' }
); );
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
const address4 = z.string({ message: 'zod.address4' }).pipe(safeStringRefine);
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin') // i18n key
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin') // i18n key
.regex(/[A-Z]/, 'zod.passwordUppercase') // i18n key
.regex(/[a-z]/, 'zod.passwordLowercase') // i18n key
.regex(/\d/, 'zod.passwordNumber') // i18n key
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial') // i18n key
.pipe(safeStringRefine);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
}); // i18n key
const remember = z.boolean({ message: 'zod.remember' }); // i18n key
const expireDate = z
.string({ message: 'zod.expireDate' }) // i18n key
.min(1, 'zod.expireDateMin') // i18n key
.pipe(safeStringRefine)
.nullable();
const oneTimeLink = z
.string({ message: 'zod.otl' }) // i18n key
.min(1, 'zod.otlMin') // i18n key
.pipe(safeStringRefine);
const statistics = z.object(
{
enabled: z.boolean({ message: 'zod.statBool' }), // i18n key
chartType: z.number({ message: 'zod.statNumber' }), // i18n key
},
{ message: 'zod.stat' } // i18n key
);
const host = z const host = z
.string({ message: 'zod.host' }) .string({ message: 'zod.host' })
.min(1, 'zod.hostMin') .min(1, 'zod.hostMin')
@ -73,47 +21,24 @@ const port = z
.min(1, 'zod.portMin') .min(1, 'zod.portMin')
.max(65535, 'zod.portMax'); .max(65535, 'zod.portMax');
const objectMessage = 'zod.body'; // i18n key
const langs = LOCALES.map((lang) => lang.code);
const lang = z.enum(['', ...langs]);
export const langType = z.object({
lang: lang,
});
export const hostPortType = z.object({ export const hostPortType = z.object({
host: host, host: host,
port: port, port: port,
}); });
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
export const clientIdType = z.object( export const clientIdType = z.object(
{ {
clientId: id, clientId: id,
}, },
{ message: "This shouldn't happen" }
);
export const address4Type = z.object(
{
address4: address4,
},
{ message: objectMessage }
);
export const nameType = z.object(
{
name: name,
},
{ message: objectMessage } { message: objectMessage }
); );
export const expireDateType = z.object( const oneTimeLink = z
{ .string({ message: 'zod.otl' })
expireDate: expireDate, .min(1, 'zod.otlMin')
}, .pipe(safeStringRefine);
{ message: objectMessage }
);
export const oneTimeLinkType = z.object( export const oneTimeLinkType = z.object(
{ {
@ -122,6 +47,17 @@ export const oneTimeLinkType = z.object(
{ message: objectMessage } { message: objectMessage }
); );
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const expireDate = z
.string({ message: 'zod.expireDate' })
.min(1, 'zod.expireDateMin')
.pipe(safeStringRefine)
.nullable();
export const createType = z.object( export const createType = z.object(
{ {
name: name, name: name,
@ -130,6 +66,9 @@ export const createType = z.object(
{ message: objectMessage } { message: objectMessage }
); );
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
export const fileType = z.object( export const fileType = z.object(
{ {
file: file, file: file,
@ -143,6 +82,22 @@ export const fileType_ = z.object(
{ message: objectMessage } { message: objectMessage }
); );
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin')
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin')
.regex(/[A-Z]/, 'zod.passwordUppercase')
.regex(/[a-z]/, 'zod.passwordLowercase')
.regex(/\d/, 'zod.passwordNumber')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial')
.pipe(safeStringRefine);
const remember = z.boolean({ message: 'zod.remember' });
export const credentialsType = z.object( export const credentialsType = z.object(
{ {
username: username, username: username,
@ -160,6 +115,10 @@ export const passwordType = z.object(
{ message: objectMessage } { message: objectMessage }
); );
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
});
export const passwordSetupType = z.object( export const passwordSetupType = z.object(
{ {
username: username, username: username,
@ -169,15 +128,93 @@ export const passwordSetupType = z.object(
{ message: objectMessage } { message: objectMessage }
); );
export const statisticsType = z.object( const address = z
{ .string({ message: 'zod.address' })
statistics: statistics, .min(1, { message: 'zod.addressMin' })
}, .pipe(safeStringRefine);
{ message: objectMessage }
); const address4 = z
.string({ message: 'zod.address4' })
.min(1, { message: 'zod.address4Min' })
.pipe(safeStringRefine);
const address6 = z
.string({ message: 'zod.address6' })
.min(1, { message: 'zod.address6Min' })
.pipe(safeStringRefine);
const allowedIps = z
.array(address, { message: 'zod.allowedIps' })
.min(1, { message: 'zod.allowedIpsMin' });
const mtu = z
.number({ message: 'zod.mtu' })
.min(1280, { message: 'zod.mtuMin' })
.max(9000, { message: 'zod.mtuMax' });
const persistentKeepalive = z
.number({ message: 'zod.persistentKeepalive' })
.min(0, 'zod.persistentKeepaliveMin')
.max(65535, 'zod.persistentKeepaliveMax');
export const clientUpdateType = z.object({
name: name,
enabled: z.boolean(),
expiresAt: expireDate,
address4: address4,
address6: address6,
allowedIps: allowedIps,
serverAllowedIPs: z.array(address, { message: 'zod.serverAllowedIPs' }),
mtu: mtu,
persistentKeepalive: persistentKeepalive,
});
export const generalUpdateType = z.object({
sessionTimeout: z.number({ message: 'zod.sessionTimeout' }),
});
const device = z
.string({ message: 'zod.device' })
.min(1, 'zod.deviceMin')
.pipe(safeStringRefine);
export const interfaceUpdateType = z.object({
mtu: mtu,
port: port,
device: device,
});
export const userConfigUpdateType = z.object({
host: host,
port: port,
allowedIps: allowedIps,
defaultDns: z.array(address, { message: 'zod.dns' }),
mtu: mtu,
persistentKeepalive: persistentKeepalive,
});
const hook = z.string({ message: 'zod.hook' }).pipe(safeStringRefine);
export const hooksUpdateType = z.object({
PreUp: hook,
PostUp: hook,
PreDown: hook,
PostDown: hook,
});
export const cidrUpdateType = z.object({
address4: address,
address6: address,
});
// from https://github.com/airjp73/rvf/blob/7e7c35d98015ea5ecff5affaf89f78296e84e8b9/packages/zod-form-data/src/helpers.ts#L117
type FormDataLikeInput = {
[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
entries(): IterableIterator<[string, FormDataEntryValue]>;
};
export function validateZod<T>( export function validateZod<T>(
schema: ZodSchema<T>, schema: ZodSchema<T> | ZodSchema<T, ZodTypeDef, FormData | FormDataLikeInput>,
event?: H3Event<EventHandlerRequest> event?: H3Event<EventHandlerRequest>
) { ) {
return async (data: unknown) => { return async (data: unknown) => {
@ -197,7 +234,7 @@ export function validateZod<T>(
let m = v.message; let m = v.message;
if (t) { if (t) {
m = t(m); // m key else v.message m = t(m);
} }
return m; return m;
@ -208,3 +245,7 @@ export function validateZod<T>(
} }
}; };
} }
export type DeepWriteable<T> = {
-readonly [P in keyof T]: DeepWriteable<T[P]>;
};

14
src/server/utils/wgHelper.ts

@ -33,10 +33,10 @@ PrivateKey = ${system.interface.privateKey}
Address = ${system.interface.address4}/${cidr4Block}, ${system.interface.address6}/${cidr6Block} Address = ${system.interface.address4}/${cidr4Block}, ${system.interface.address6}/${cidr6Block}
ListenPort = ${system.interface.port} ListenPort = ${system.interface.port}
MTU = ${system.interface.mtu} MTU = ${system.interface.mtu}
PreUp = ${system.iptables.PreUp} PreUp = ${iptablesTemplate(system.hooks.PreUp, system)}
PostUp = ${system.iptables.PostUp} PostUp = ${iptablesTemplate(system.hooks.PostUp, system)}
PreDown = ${system.iptables.PreDown} PreDown = ${iptablesTemplate(system.hooks.PreDown, system)}
PostDown = ${system.iptables.PostDown}`; PostDown = ${iptablesTemplate(system.hooks.PostDown, system)}`;
}, },
generateClientConfig: ( generateClientConfig: (
@ -55,7 +55,7 @@ MTU = ${client.mtu}
[Peer] [Peer]
PublicKey = ${system.interface.publicKey} PublicKey = ${system.interface.publicKey}
PresharedKey = ${client.preSharedKey} PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs.join(', ')} AllowedIPs = ${client.allowedIps.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive} PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${system.userConfig.host}:${system.userConfig.port}`; Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
}, },
@ -112,7 +112,7 @@ Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
publicKey, publicKey,
preSharedKey, preSharedKey,
endpoint, endpoint,
allowedIPs, allowedIps,
latestHandshakeAt, latestHandshakeAt,
transferRx, transferRx,
transferTx, transferTx,
@ -123,7 +123,7 @@ Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
publicKey, publicKey,
preSharedKey, preSharedKey,
endpoint: endpoint === '(none)' ? null : endpoint, endpoint: endpoint === '(none)' ? null : endpoint,
allowedIPs, allowedIps,
latestHandshakeAt: latestHandshakeAt:
latestHandshakeAt === '0' latestHandshakeAt === '0'
? null ? null

126
src/services/database/lowdb.ts

@ -1,26 +1,33 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import debug from 'debug'; import debug from 'debug';
import { JSONFilePreset } from 'lowdb/node';
import type { Low } from 'lowdb';
import type { DeepReadonly } from 'vue';
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import { import {
DatabaseProvider, DatabaseProvider,
DatabaseError, DatabaseError,
DEFAULT_DATABASE, DEFAULT_DATABASE,
} from './repositories/database'; } from './repositories/database';
import { JSONFilePreset } from 'lowdb/node';
import type { Low } from 'lowdb';
import { UserRepository, type User } from './repositories/user'; import { UserRepository, type User } from './repositories/user';
import type { Database } from './repositories/database'; import type { Database } from './repositories/database';
import { migrationRunner } from './migrations'; import { migrationRunner } from './migrations';
import { import {
ClientRepository, ClientRepository,
type Client, type UpdateClient,
type NewClient, type CreateClient,
type OneTimeLink, type OneTimeLink,
} from './repositories/client'; } from './repositories/client';
import { SystemRepository, type Lang } from './repositories/system'; import {
SystemRepository,
type General,
type UpdateWGConfig,
type UpdateWGInterface,
type WGHooks,
} from './repositories/system';
import { SetupRepository, type Steps } from './repositories/setup'; import { SetupRepository, type Steps } from './repositories/setup';
import type { DeepReadonly } from 'vue';
const DEBUG = debug('LowDB'); const DEBUG = debug('LowDB');
@ -73,13 +80,6 @@ class LowDBSystem extends SystemRepository {
return makeReadonly(system); return makeReadonly(system);
} }
async updateLang(lang: Lang): Promise<void> {
DEBUG('Update Language');
this.#db.update((v) => {
v.system.general.lang = lang;
});
}
async updateClientsHostPort(host: string, port: number): Promise<void> { async updateClientsHostPort(host: string, port: number): Promise<void> {
DEBUG('Update Clients Host and Port endpoint'); DEBUG('Update Clients Host and Port endpoint');
this.#db.update((v) => { this.#db.update((v) => {
@ -87,6 +87,63 @@ class LowDBSystem extends SystemRepository {
v.system.userConfig.port = port; v.system.userConfig.port = port;
}); });
} }
async updateGeneral(general: General) {
DEBUG('Update General');
this.#db.update((v) => {
v.system.general = general;
});
}
async updateInterface(wgInterface: UpdateWGInterface) {
DEBUG('Update Interface');
this.#db.update((v) => {
const oldInterface = v.system.interface;
v.system.interface = {
...oldInterface,
...wgInterface,
};
});
}
async updateUserConfig(userConfig: UpdateWGConfig) {
DEBUG('Update User Config');
this.#db.update((v) => {
const oldUserConfig = v.system.userConfig;
v.system.userConfig = {
...oldUserConfig,
...userConfig,
};
});
}
async updateHooks(hooks: WGHooks) {
DEBUG('Update Hooks');
this.#db.update((v) => {
v.system.hooks = hooks;
});
}
/**
* updates the address range and the interface address
*/
async updateAddressRange(address4Range: string, address6Range: string) {
DEBUG('Update Address Range');
const cidr4 = parseCidr(address4Range);
const cidr6 = parseCidr(address6Range);
this.#db.update((v) => {
v.system.userConfig.address4Range = address4Range;
v.system.userConfig.address6Range = address6Range;
v.system.interface.address4 = stringifyIp({
number: cidr4.start + 1n,
version: 4,
});
v.system.interface.address6 = stringifyIp({
number: cidr6.start + 1n,
version: 6,
});
});
}
} }
class LowDBUser extends UserRepository { class LowDBUser extends UserRepository {
@ -173,12 +230,13 @@ class LowDBClient extends ClientRepository {
return makeReadonly(this.#db.data.clients[id]); return makeReadonly(this.#db.data.clients[id]);
} }
async create(client: NewClient) { async create(client: CreateClient) {
DEBUG('Create Client'); DEBUG('Create Client');
const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
const newClient: Client = { ...client, createdAt: now, updatedAt: now }; const newClient = { ...client, createdAt: now, updatedAt: now, id };
await this.#db.update((data) => { await this.#db.update((data) => {
data.clients[client.id] = newClient; data.clients[id] = newClient;
}); });
} }
@ -200,24 +258,6 @@ class LowDBClient extends ClientRepository {
}); });
} }
async updateName(id: string, name: string) {
DEBUG('Update Client Name');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].name = name;
}
});
}
async updateAddress4(id: string, address4: string) {
DEBUG('Update Client Address4');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].address4 = address4;
}
});
}
async updateExpirationDate(id: string, expirationDate: string | null) { async updateExpirationDate(id: string, expirationDate: string | null) {
DEBUG('Update Client Expiration Date'); DEBUG('Update Client Expiration Date');
await this.#db.update((data) => { await this.#db.update((data) => {
@ -249,6 +289,22 @@ class LowDBClient extends ClientRepository {
} }
}); });
} }
async update(id: string, client: UpdateClient) {
DEBUG('Create Client');
const now = new Date().toISOString();
await this.#db.update((data) => {
const oldClient = data.clients[id];
if (!oldClient) {
return;
}
data.clients[id] = {
...oldClient,
...client,
updatedAt: now,
};
});
}
} }
export default class LowDB extends DatabaseProvider { export default class LowDB extends DatabaseProvider {

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

@ -18,7 +18,6 @@ export async function run1(db: Low<Database>) {
system: { system: {
general: { general: {
sessionTimeout: 3600, // 1 hour sessionTimeout: 3600, // 1 hour
lang: 'en',
}, },
// Config to configure Server // Config to configure Server
interface: { interface: {
@ -41,12 +40,30 @@ export async function run1(db: Low<Database>) {
host: '', host: '',
port: 51820, port: 51820,
}, },
// Config to configure Firewall // Config to configure Firewall or general hooks
iptables: { hooks: {
PreUp: '', PreUp: '',
PostUp: '', PostUp: [
'iptables -t nat -A POSTROUTING -s {{address4}} -o {{device}} -j MASQUERADE;',
'iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT;',
'iptables -A FORWARD -i wg0 -j ACCEPT;',
'iptables -A FORWARD -o wg0 -j ACCEPT;',
'ip6tables -t nat -A POSTROUTING -s {{address6}} -o {{device}} -j MASQUERADE;',
'ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT;',
'ip6tables -A FORWARD -i wg0 -j ACCEPT;',
'ip6tables -A FORWARD -o wg0 -j ACCEPT;',
].join(' '),
PreDown: '', PreDown: '',
PostDown: '', PostDown: [
'iptables -t nat -D POSTROUTING -s {{address4}} -o {{device}} -j MASQUERADE;',
'iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT;',
'iptables -D FORWARD -i wg0 -j ACCEPT;',
'iptables -D FORWARD -o wg0 -j ACCEPT;',
'ip6tables -t nat -D POSTROUTING -s {{address6}} -o {{device}} -j MASQUERADE;',
'ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT;',
'ip6tables -D FORWARD -i wg0 -j ACCEPT;',
'ip6tables -D FORWARD -o wg0 -j ACCEPT;',
].join(' '),
}, },
metrics: { metrics: {
prometheus: { prometheus: {
@ -65,30 +82,6 @@ export async function run1(db: Low<Database>) {
clients: {}, clients: {},
}; };
database.system.iptables.PostUp =
`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.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.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.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')
.join(' ');
db.data = database; db.data = database;
db.write(); db.write();
} }

19
src/services/database/migrations/index.ts

@ -4,10 +4,10 @@ import { run1 } from './1';
export type MIGRATION_FN = (db: Low<Database>) => Promise<void>; export type MIGRATION_FN = (db: Low<Database>) => Promise<void>;
const MIGRATION_LIST = { const MIGRATION_LIST = [
// Adds Initial Database Structure // Adds Initial Database Structure
'1': run1, { id: '1', fn: run1 },
} satisfies Record<string, MIGRATION_FN>; ] satisfies { id: string; fn: MIGRATION_FN }[];
/** /**
* Runs all migrations * Runs all migrations
@ -15,18 +15,15 @@ const MIGRATION_LIST = {
*/ */
export async function migrationRunner(db: Low<Database>) { export async function migrationRunner(db: Low<Database>) {
const ranMigrations = db.data.migrations; const ranMigrations = db.data.migrations;
const runMigrations = Object.keys( for (const migration of MIGRATION_LIST) {
MIGRATION_LIST if (ranMigrations.includes(migration.id)) {
) as (keyof typeof MIGRATION_LIST)[];
for (const migrationId of runMigrations) {
if (ranMigrations.includes(migrationId)) {
continue; continue;
} }
try { try {
await MIGRATION_LIST[migrationId](db); await migration.fn(db);
db.data.migrations.push(migrationId); db.data.migrations.push(migration.id);
} catch (e) { } catch (e) {
throw new Error(`Failed to run Migration ${migrationId}: ${e}`); throw new Error(`Failed to run Migration ${migration.id}: ${e}`);
} }
} }
await db.write(); await db.write();

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

Loading…
Cancel
Save