Browse Source

Feat: Info (#1666)

* add tooltip info, extract strings

* multi type toast

* improve useSubmit, i18n

* better login screen

* improve

* consistent folder casing

* consistent casing

* fix even more stuff

* temp

* fix type errors

* remove armv6/7 support for now

* add information to client page

* optimize dockerfile

* update base image in Dockerfile to use node:lts-alpine

* fix build stage
pull/1696/head
Bernd Storath 5 months ago
committed by GitHub
parent
commit
4775c2c4b8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/FUNDING.yml
  2. 2
      .github/workflows/deploy-development.yml
  3. 2
      .github/workflows/deploy-nightly.yml
  4. 2
      .github/workflows/deploy-pr.yml
  5. 2
      .github/workflows/deploy.yml
  6. 9
      CHANGELOG.md
  7. 16
      Dockerfile
  8. 3
      src/app/app.vue
  9. 8
      src/app/components/Admin/CidrDialog.vue
  10. 0
      src/app/components/Base/Avatar.vue
  11. 0
      src/app/components/Base/Button.vue
  12. 0
      src/app/components/Base/Chart.vue
  13. 4
      src/app/components/Base/Dialog.vue
  14. 12
      src/app/components/Base/Input.vue
  15. 0
      src/app/components/Base/Switch.vue
  16. 46
      src/app/components/Base/Toast.vue
  17. 24
      src/app/components/Base/Tooltip.vue
  18. 2
      src/app/components/ClientCard/Config.vue
  19. 2
      src/app/components/ClientCard/ExpireDate.vue
  20. 2
      src/app/components/ClientCard/LastSeen.vue
  21. 2
      src/app/components/ClientCard/Name.vue
  22. 44
      src/app/components/ClientCard/OneTimeLink.vue
  23. 42
      src/app/components/ClientCard/OneTimeLinkBtn.vue
  24. 6
      src/app/components/ClientCard/QRCode.vue
  25. 44
      src/app/components/ClientCard/Switch.vue
  26. 52
      src/app/components/Clients/CreateDialog.vue
  27. 16
      src/app/components/Clients/DeleteDialog.vue
  28. 6
      src/app/components/Clients/Empty.vue
  29. 4
      src/app/components/Clients/New.vue
  30. 2
      src/app/components/Clients/QRCodeDialog.vue
  31. 47
      src/app/components/Clients/Sort.vue
  32. 2
      src/app/components/Form/ActionField.vue
  33. 2
      src/app/components/Form/ArrayField.vue
  34. 25
      src/app/components/Form/DateField.vue
  35. 0
      src/app/components/Form/Element.vue
  36. 0
      src/app/components/Form/Group.vue
  37. 12
      src/app/components/Form/Heading.vue
  38. 11
      src/app/components/Form/Label.vue
  39. 38
      src/app/components/Form/NullTextField.vue
  40. 17
      src/app/components/Form/NumberField.vue
  41. 18
      src/app/components/Form/PasswordField.vue
  42. 16
      src/app/components/Form/SwitchField.vue
  43. 28
      src/app/components/Form/TextField.vue
  44. 2
      src/app/components/Header/ChartToggle.vue
  45. 0
      src/app/components/Header/LangSelector.vue
  46. 0
      src/app/components/Header/Logo.vue
  47. 0
      src/app/components/Header/ThemeSwitch.vue
  48. 12
      src/app/components/Header/Update.vue
  49. 0
      src/app/components/Icons/ArrowDown.vue
  50. 0
      src/app/components/Icons/ArrowInf.vue
  51. 0
      src/app/components/Icons/ArrowLeftCircle.vue
  52. 0
      src/app/components/Icons/ArrowRightCircle.vue
  53. 0
      src/app/components/Icons/ArrowUp.vue
  54. 0
      src/app/components/Icons/Avatar.vue
  55. 0
      src/app/components/Icons/Chart.vue
  56. 0
      src/app/components/Icons/CheckCircle.vue
  57. 0
      src/app/components/Icons/Close.vue
  58. 0
      src/app/components/Icons/Delete.vue
  59. 0
      src/app/components/Icons/Download.vue
  60. 0
      src/app/components/Icons/Edit.vue
  61. 0
      src/app/components/Icons/HalfMoon.vue
  62. 15
      src/app/components/Icons/Info.vue
  63. 0
      src/app/components/Icons/Language.vue
  64. 15
      src/app/components/Icons/Link.vue
  65. 0
      src/app/components/Icons/Loading.vue
  66. 0
      src/app/components/Icons/Logout.vue
  67. 0
      src/app/components/Icons/Moon.vue
  68. 0
      src/app/components/Icons/Plus.vue
  69. 0
      src/app/components/Icons/QRCode.vue
  70. 0
      src/app/components/Icons/Stack.vue
  71. 0
      src/app/components/Icons/Sun.vue
  72. 0
      src/app/components/Icons/Warning.vue
  73. 0
      src/app/components/Panel/Body.vue
  74. 0
      src/app/components/Panel/Panel.vue
  75. 0
      src/app/components/Panel/head/Boat.vue
  76. 0
      src/app/components/Panel/head/Head.vue
  77. 12
      src/app/components/Panel/head/Title.vue
  78. 0
      src/app/components/Ui/Banner.vue
  79. 5
      src/app/components/Ui/Footer.vue
  80. 0
      src/app/components/Ui/StepProgress.vue
  81. 38
      src/app/components/Ui/UserMenu.vue
  82. 0
      src/app/components/base/Container.vue
  83. 45
      src/app/components/base/Toast.vue
  84. 26
      src/app/components/form/DateField.vue
  85. 5
      src/app/components/form/Heading.vue
  86. 26
      src/app/components/form/NullTextField.vue
  87. 18
      src/app/components/form/NumberField.vue
  88. 19
      src/app/components/form/PasswordField.vue
  89. 11
      src/app/components/form/SwitchField.vue
  90. 45
      src/app/components/ui/ChooseLang.vue
  91. 57
      src/app/composables/useSubmit.ts
  92. 12
      src/app/pages/admin.vue
  93. 74
      src/app/pages/admin/config.vue
  94. 40
      src/app/pages/admin/hooks.vue
  95. 49
      src/app/pages/admin/index.vue
  96. 101
      src/app/pages/admin/interface.vue
  97. 120
      src/app/pages/clients/[id].vue
  98. 1
      src/app/pages/index.vue
  99. 116
      src/app/pages/login.vue
  100. 101
      src/app/pages/me.vue

2
.github/FUNDING.yml

@ -1,3 +1,3 @@
# These are supported funding model platforms
github: weejewel
github: [weejewel, kaaax0815]

2
.github/workflows/deploy-development.yml

@ -32,7 +32,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ghcr.io/wg-easy/wg-easy:development
cache-from: type=gha
cache-to: type=gha,mode=min

2
.github/workflows/deploy-nightly.yml

@ -36,7 +36,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ghcr.io/wg-easy/wg-easy:nightly
cache-from: type=gha
cache-to: type=gha,mode=min

2
.github/workflows/deploy-pr.yml

@ -37,7 +37,7 @@ jobs:
with:
context: .
push: false
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ghcr.io/wg-easy/wg-easy:pr
cache-from: type=gha
cache-to: type=gha,mode=min

2
.github/workflows/deploy.yml

@ -48,7 +48,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha

9
CHANGELOG.md

@ -20,13 +20,10 @@ This update is an entire rewrite to make it even easier to set up your own VPN.
- CIDR Support
- IPv6 Support
- Changed API Structure
- Changed Database Structure
- SQLite Database
- Deprecated Dockerless Installations
- Added Docker Volume Mount
## Minor Changes
- Renamed Chinese Locales (cht -> zh-cht, chs -> zh-chs)
- Added Docker Volume Mount (`/lib/modules`)
- Removed ARMv6 and ARMv7 support
## [14.0.0] - 2024-09-04

16
Dockerfile

@ -1,5 +1,4 @@
# nodejs 20 hangs on build with armv6/armv7 (https://github.com/nodejs/docker-node/issues/2077)
FROM docker.io/library/node:18-alpine AS build
FROM docker.io/library/node:lts-alpine AS build
WORKDIR /app
# update corepack
@ -7,20 +6,14 @@ RUN npm install --global corepack@latest
# Install pnpm
RUN corepack enable pnpm
# add build tools for argon2
RUN apk add --no-cache make gcc g++ python3
# Copy Web UI
COPY src ./
COPY src/package.json src/pnpm-lock.yaml ./
RUN pnpm install
# Build UI
COPY src ./
RUN pnpm build
# Remove unnecessary node modules
RUN find ./node_modules/.pnpm -mindepth 1 -maxdepth 1 -type d ! -name '@libsql+linux*' -exec rm -r {} +
RUN find ./node_modules/@libsql -mindepth 1 -maxdepth 1 -type l ! -name 'linux*' -exec rm -r {} +
# Copy build result to a new image.
# This saves a lot of disk space.
FROM docker.io/library/node:lts-alpine
@ -33,8 +26,7 @@ COPY --from=build /app/.output /app
# Copy migrations
COPY --from=build /app/server/database/migrations /app/server/database/migrations
# libsql
COPY --from=build /app/node_modules/.pnpm/ /app/node_modules/.pnpm/
COPY --from=build /app/node_modules/@libsql /app/node_modules/@libsql
RUN npm install --no-save libsql
# Install Linux packages
RUN apk add --no-cache \

3
src/app/app.vue

@ -16,6 +16,9 @@ const toast = useToast();
const toastRef = useTemplateRef('toastRef');
toast.setToast(toastRef);
// make sure to fetch release early
useGlobalStore();
useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',

8
src/app/components/admin/CidrDialog.vue → src/app/components/Admin/CidrDialog.vue

@ -1,7 +1,7 @@
<template>
<BaseDialog>
<BaseDialog :trigger-class="triggerClass">
<template #trigger><slot /></template>
<template #title>Change CIDR</template>
<template #title>{{ $t('admin.interface.changeCidr') }}</template>
<template #description>
<FormGroup>
<FormTextField id="ipv4Cidr" v-model="ipv4Cidr" label="IPv4" />
@ -10,11 +10,11 @@
</template>
<template #actions>
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('change', ipv4Cidr, ipv6Cidr)">
Change
{{ $t('dialog.change') }}
</BaseButton>
</DialogClose>
</template>

0
src/app/components/base/Avatar.vue → src/app/components/Base/Avatar.vue

0
src/app/components/base/Button.vue → src/app/components/Base/Button.vue

0
src/app/components/base/Chart.vue → src/app/components/Base/Chart.vue

4
src/app/components/base/Dialog.vue → src/app/components/Base/Dialog.vue

@ -3,10 +3,10 @@
<DialogTrigger :class="triggerClass"><slot name="trigger" /></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"
class="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"
class="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"

12
src/app/components/form/TextField.vue → src/app/components/Base/Input.vue

@ -1,18 +1,10 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.trim="data"
:name="id"
type="text"
v-model="data"
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>();
const data = defineModel<unknown>();
</script>

0
src/app/components/base/Switch.vue → src/app/components/Base/Switch.vue

46
src/app/components/Base/Toast.vue

@ -0,0 +1,46 @@
<template>
<ToastRoot
v-for="(e, i) in count"
:key="i"
:class="[
`grid grid-cols-[auto_max-content] items-center gap-x-3 rounded-md p-3 text-neutral-200 shadow-lg [grid-template-areas:_'title_action'_'description_action'] data-[swipe=cancel]:translate-x-0 data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]`,
{
'bg-green-800': e.type === 'success',
'bg-red-800': e.type === 'error',
},
]"
>
<ToastTitle class="mb-1 text-sm font-medium [grid-area:_title]">
{{ e.title }}
</ToastTitle>
<ToastDescription class="m-0 text-sm [grid-area:_description]">{{
e.message
}}</ToastDescription>
<ToastAction as-child alt-text="toast" class="[grid-area:_action]">
<slot />
</ToastAction>
<ToastClose aria-label="Close">
<span aria-hidden>×</span>
</ToastClose>
</ToastRoot>
</template>
<script setup lang="ts">
import {
ToastAction,
ToastClose,
ToastDescription,
ToastRoot,
ToastTitle,
} from 'radix-vue';
defineExpose({
publish,
});
const count = reactive<ToastParams[]>([]);
function publish(e: ToastParams) {
count.push({ type: e.type, title: e.title, message: e.message });
}
</script>

24
src/app/components/Base/Tooltip.vue

@ -0,0 +1,24 @@
<template>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-400 outline-none focus:shadow-sm focus:shadow-black"
>
<slot />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
class="select-none rounded bg-gray-600 px-3 py-2 text-sm leading-none text-white shadow-lg will-change-[transform,opacity]"
:side-offset="5"
>
{{ text }}
<TooltipArrow class="fill-gray-600" :width="8" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</template>
<script lang="ts" setup>
defineProps<{ text: string }>();
</script>

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

@ -3,7 +3,7 @@
:href="'/api/client/' + client.id + '/configuration'"
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"
:title="$t('downloadConfig')"
:title="$t('client.downloadConfig')"
>
<IconsDownload class="w-5" />
</a>

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

@ -12,7 +12,7 @@ defineProps<{ client: LocalClient }>();
const { t, locale } = useI18n();
function expiredDateFormat(value: string | null) {
if (value === null) return t('Permanent');
if (value === null) return t('client.permanent');
const dateTime = new Date(value);
return dateTime.toLocaleDateString(locale.value, {
year: 'numeric',

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

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

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

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

44
src/app/components/ClientCard/OneTimeLink.vue

@ -7,11 +7,45 @@
<script setup lang="ts">
const props = defineProps<{ client: LocalClient }>();
const path = computed(() => {
if (import.meta.client) {
// TODO: show how long its still valid
return `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink?.oneTimeLink}`;
const path = ref('Loading...');
const timer = ref<NodeJS.Timeout | null>(null);
const { localeProperties } = useI18n();
onMounted(() => {
timer.value = setIntervalImmediately(() => {
if (props.client.oneTimeLink === null) {
return;
}
const timeLeft =
new Date(props.client.oneTimeLink.expiresAt).getTime() - Date.now();
if (timeLeft <= 0) {
path.value = `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink.oneTimeLink} (00:00)`;
return;
}
const formatter = new Intl.DateTimeFormat(localeProperties.value.language, {
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const minutes = Math.floor(timeLeft / 60000);
const seconds = Math.floor((timeLeft % 60000) / 1000);
const date = new Date(0);
date.setMinutes(minutes);
date.setSeconds(seconds);
path.value = `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink.oneTimeLink} (${formatter.format(date)})`;
}, 1000);
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
return 'Loading...';
});
</script>

42
src/app/components/ClientCard/OneTimeLinkBtn.vue

@ -1,38 +1,32 @@
<template>
<button
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('OneTimeLink')"
@click="showOneTimeLink(client)"
:title="$t('client.otlDesc')"
@click="showOneTimeLink"
>
<svg
class="w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"
/>
</svg>
<IconsLink class="w-5" />
</button>
</template>
<script setup lang="ts">
// TODO: improve
defineProps<{ client: LocalClient }>();
const props = defineProps<{ client: LocalClient }>();
const clientsStore = useClientsStore();
function showOneTimeLink(client: LocalClient) {
// TODO: improve
$fetch(`/api/client/${client.id}/generateOneTimeLink`, {
const _showOneTimeLink = useSubmit(
`/api/client/${props.client.id}/generateOneTimeLink`,
{
method: 'post',
})
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
},
{
revert: async () => {
await clientsStore.refresh();
},
noSuccessToast: true,
}
);
function showOneTimeLink() {
return _showOneTimeLink(undefined);
}
</script>

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

@ -1,11 +1,11 @@
<template>
<ClientsQRCodeDialog :qr-code="`./api/client/${client.id}/qrcode.svg`">
<button
<div
class="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('showQR')"
:title="$t('client.showQR')"
>
<IconsQRCode class="w-5" />
</button>
</div>
</ClientsQRCodeDialog>
</template>

44
src/app/components/ClientCard/Switch.vue

@ -1,7 +1,9 @@
<template>
<BaseSwitch
v-model="enabled"
:title="client.enabled ? $t('disableClient') : $t('enableClient')"
:title="
client.enabled ? $t('client.disableClient') : $t('client.enableClient')
"
@click="toggleClient"
/>
</template>
@ -15,21 +17,37 @@ const enabled = ref(props.client.enabled);
const clientsStore = useClientsStore();
async function toggleClient() {
try {
if (props.client.enabled) {
await $fetch(`/api/client/${props.client.id}/disable`, {
const _disableClient = useSubmit(
`/api/client/${props.client.id}/disable`,
{
method: 'post',
});
} else {
await $fetch(`/api/client/${props.client.id}/enable`, {
},
{
revert: async () => {
await clientsStore.refresh();
},
noSuccessToast: true,
}
);
const _enableClient = useSubmit(
`/api/client/${props.client.id}/enable`,
{
method: 'post',
});
},
{
revert: async () => {
await clientsStore.refresh();
},
noSuccessToast: true,
}
} catch (err) {
alert(err);
} finally {
clientsStore.refresh().catch(console.error);
);
async function toggleClient() {
if (props.client.enabled) {
await _disableClient(undefined);
} else {
await _enableClient(undefined);
}
}
</script>

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

@ -4,56 +4,50 @@
<slot />
</template>
<template #title>
{{ $t('newClient') }}
{{ $t('client.new') }}
</template>
<template #description>
<div class="flex flex-col">
<FormTextField id="name" v-model="name" label="Name" />
<FormDateField id="expiresAt" v-model="expiresAt" label="Expire Date" />
<FormTextField id="name" v-model="name" :label="$t('client.name')" />
<FormDateField
id="expiresAt"
v-model="expiresAt"
:label="$t('client.expireDate')"
/>
</div>
</template>
<template #actions>
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="createClient">{{ $t('create') }}</BaseButton>
<BaseButton @click="createClient">{{ $t('client.create') }}</BaseButton>
</DialogClose>
</template>
</BaseDialog>
</template>
<script lang="ts" setup>
import { FetchError } from 'ofetch';
const name = ref<string>('');
const expiresAt = ref<string | null>(null);
const toast = useToast();
const clientsStore = useClientsStore();
const { t } = useI18n();
defineProps<{ triggerClass?: string }>();
async function createClient() {
try {
await $fetch('/api/client', {
function createClient() {
return _createClient({ name: name.value, expiresAt: expiresAt.value });
}
const _createClient = useSubmit(
'/api/client',
{
method: 'post',
body: { name: name.value, expiresAt: expiresAt.value },
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Client created',
});
await clientsStore.refresh();
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.data.message,
});
}
// TODO: handle errors better
},
{
revert: () => clientsStore.refresh(),
successMsg: t('client.created'),
}
}
);
</script>

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

@ -1,19 +1,19 @@
<template>
<BaseDialog>
<BaseDialog :trigger-class="triggerClass">
<template #trigger><slot /></template>
<template #title>{{ $t('deleteClient') }}</template>
<template #title>{{ $t('client.deleteClient') }}</template>
<template #description>
{{ $t('deleteDialog1') }}
<strong>{{ 'test' }}</strong
>? {{ $t('deleteDialog2') }}
{{ $t('client.deleteDialog1') }}
<strong>{{ clientName }}</strong
>? {{ $t('client.deleteDialog2') }}
</template>
<template #actions>
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('delete')">{{
$t('deleteClient')
$t('client.deleteClient')
}}</BaseButton>
</DialogClose>
</template>
@ -22,5 +22,5 @@
<script lang="ts" setup>
defineEmits(['delete']);
defineProps<{ triggerClass?: string }>();
defineProps<{ triggerClass?: string; clientName: string }>();
</script>

6
src/app/components/Clients/Empty.vue

@ -1,10 +1,10 @@
<template>
<p class="m-10 text-center text-sm text-gray-400 dark:text-neutral-400">
{{ $t('noClients') }}<br /><br />
{{ $t('client.empty') }}<br /><br />
<ClientsCreateDialog>
<BaseButton>
<BaseButton as="span">
<IconsPlus class="w-4 md:mr-2" />
<span class="text-sm">{{ $t('new') }}</span>
<span class="text-sm">{{ $t('client.new') }}</span>
</BaseButton>
</ClientsCreateDialog>
</p>

4
src/app/components/Clients/New.vue

@ -1,8 +1,8 @@
<template>
<ClientsCreateDialog>
<BaseButton>
<BaseButton as="span">
<IconsPlus class="w-4 md:mr-2" />
<span class="text-sm max-md:hidden">{{ $t('new') }}</span>
<span class="text-sm max-md:hidden">{{ $t('client.newShort') }}</span>
</BaseButton>
</ClientsCreateDialog>
</template>

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

@ -8,7 +8,7 @@
</template>
<template #actions>
<DialogClose>
<BaseButton>{{ $t('cancel') }}</BaseButton>
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
</DialogClose>
</template>
</BaseDialog>

47
src/app/components/Clients/Sort.vue

@ -1,52 +1,15 @@
<template>
<button
class="inline-flex items-center border-2 border-gray-100 px-4 py-2 text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white max-md:border-x-0 md:rounded dark:border-neutral-600 dark:text-neutral-200"
@click="toggleSort"
>
<svg
<BaseButton @click="toggleSort">
<IconsArrowDown
v-if="globalStore.sortClient === true"
inline
class="w-4 md:mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
d="M12 19.75C11.9015 19.7504 11.8038 19.7312 11.7128 19.6934C11.6218 19.6557 11.5392 19.6001 11.47 19.53L5.47 13.53C5.33752 13.3878 5.2654 13.1997 5.26882 13.0054C5.27225 12.8111 5.35096 12.6258 5.48838 12.4883C5.62579 12.3509 5.81118 12.2722 6.00548 12.2688C6.19978 12.2654 6.38782 12.3375 6.53 12.47L12 17.94L17.47 12.47C17.6122 12.3375 17.8002 12.2654 17.9945 12.2688C18.1888 12.2722 18.3742 12.3509 18.5116 12.4883C18.649 12.6258 18.7277 12.8111 18.7312 13.0054C18.7346 13.1997 18.6625 13.3878 18.53 13.53L12.53 19.53C12.4608 19.6001 12.3782 19.6557 12.2872 19.6934C12.1962 19.7312 12.0985 19.7504 12 19.75Z"
fill="#000000"
/>
<path
d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z"
fill="#000000"
/>
</svg>
<svg
v-if="globalStore.sortClient === false"
inline
class="w-4 md:mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
d="M18 11.75C17.9015 11.7505 17.8038 11.7313 17.7128 11.6935C17.6218 11.6557 17.5392 11.6001 17.47 11.53L12 6.06001L6.53 11.53C6.38782 11.6625 6.19978 11.7346 6.00548 11.7312C5.81118 11.7278 5.62579 11.649 5.48838 11.5116C5.35096 11.3742 5.27225 11.1888 5.26882 10.9945C5.2654 10.8002 5.33752 10.6122 5.47 10.47L11.47 4.47001C11.6106 4.32956 11.8012 4.25067 12 4.25067C12.1987 4.25067 12.3894 4.32956 12.53 4.47001L18.53 10.47C18.6705 10.6106 18.7493 10.8013 18.7493 11C18.7493 11.1988 18.6705 11.3894 18.53 11.53C18.4608 11.6001 18.3782 11.6557 18.2872 11.6935C18.1962 11.7313 18.0985 11.7505 18 11.75Z"
fill="#000000"
/>
<path
d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z"
fill="#000000"
/>
</svg>
<span class="text-sm max-md:hidden">{{ $t('sort') }}</span>
</button>
<IconsArrowUp v-else class="w-4 md:mr-2" />
<span class="text-sm max-md:hidden"> {{ $t('client.sort') }}</span>
</BaseButton>
</template>
<script setup lang="ts">
// TODO: improve
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();

2
src/app/components/form/ActionField.vue → src/app/components/Form/ActionField.vue

@ -2,7 +2,7 @@
<input
:value="label"
: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 hover:border-red-800 hover:bg-red-800 hover:text-white 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>

2
src/app/components/form/ArrayField.vue → src/app/components/Form/ArrayField.vue

@ -18,6 +18,8 @@
</template>
<script lang="ts" setup>
// TODO: style
const data = defineModel<string[]>();
defineProps<{ emptyText?: string[]; name: string }>();

25
src/app/components/Form/DateField.vue

@ -0,0 +1,25 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseInput :id="id" v-model="data" :name="id" type="date" />
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<string | null>({
set(value) {
const temp = value?.trim() ?? null;
if (temp === '') {
return null;
}
return temp;
},
});
</script>

0
src/app/components/form/Element.vue → src/app/components/Form/Element.vue

0
src/app/components/form/Group.vue → src/app/components/Form/Group.vue

12
src/app/components/Form/Heading.vue

@ -0,0 +1,12 @@
<template>
<h4 class="col-span-full flex items-center py-6 text-2xl">
<slot />
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</h4>
</template>
<script lang="ts" setup>
defineProps<{ description?: string }>();
</script>

11
src/app/components/Form/Label.vue

@ -0,0 +1,11 @@
<template>
<RLabel :for="props.for" class="md:align-middle md:leading-10"
><slot
/></RLabel>
</template>
<script lang="ts" setup>
import { Label as RLabel } from 'radix-vue';
const props = defineProps<{ for: string }>();
</script>

38
src/app/components/Form/NullTextField.vue

@ -0,0 +1,38 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseInput
:id="id"
v-model.trim="data"
:name="id"
type="text"
:autcomplete="autocomplete"
:placeholder="placeholder"
/>
</template>
<script lang="ts" setup>
defineProps<{
id: string;
label: string;
description?: string;
autocomplete?: string;
placeholder?: string;
}>();
const data = defineModel<string | null>({
set(value) {
const temp = value?.trim() ?? null;
if (temp === '') {
return null;
}
return temp;
},
});
</script>

17
src/app/components/Form/NumberField.vue

@ -0,0 +1,17 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseInput :id="id" v-model.number="data" :name="id" type="number" />
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<number>();
</script>

18
src/app/components/Form/PasswordField.vue

@ -0,0 +1,18 @@
<template>
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseInput
:id="id"
v-model.trim="data"
:name="id"
type="password"
:autocomplete="autocomplete"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; autocomplete: string }>();
const data = defineModel<string>();
</script>

16
src/app/components/Form/SwitchField.vue

@ -0,0 +1,16 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseSwitch :id="id" v-model="data" />
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<boolean>();
</script>

28
src/app/components/Form/TextField.vue

@ -0,0 +1,28 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseInput
:id="id"
v-model.trim="data"
:name="id"
type="text"
:autcomplete="autocomplete"
/>
</template>
<script lang="ts" setup>
defineProps<{
id: string;
label: string;
description?: string;
autocomplete?: string;
}>();
const data = defineModel<string>();
</script>

2
src/app/components/header/ChartToggle.vue → src/app/components/Header/ChartToggle.vue

@ -2,7 +2,7 @@
<Toggle
v-model:pressed="globalStore.uiShowCharts"
class="group inline-flex h-8 w-8 cursor-pointer items-center justify-center whitespace-nowrap rounded-full bg-gray-200 transition hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600"
:title="$t('toggleCharts')"
:title="$t('layout.toggleCharts')"
@update:pressed="globalStore.toggleCharts"
>
<IconsChart

0
src/app/components/header/LangSelector.vue → src/app/components/Header/LangSelector.vue

0
src/app/components/header/Logo.vue → src/app/components/Header/Logo.vue

0
src/app/components/header/ThemeSwitch.vue → src/app/components/Header/ThemeSwitch.vue

12
src/app/components/header/Update.vue → src/app/components/Header/Update.vue

@ -1,21 +1,21 @@
<template>
<div
v-if="globalStore.updateAvailable && globalStore.latestRelease"
v-if="globalStore.release?.updateAvailable"
class="font-small mb-10 rounded-md bg-red-800 p-4 text-sm text-white shadow-lg dark:bg-red-100 dark:text-red-600"
:title="`v${globalStore.currentRelease} → v${globalStore.latestRelease.version}`"
:title="`v${globalStore.release.currentRelease} → v${globalStore.release.latestRelease.version}`"
>
<div class="container mx-auto flex flex-auto flex-row items-center">
<div class="flex-grow">
<p class="font-bold">{{ $t('updateAvailable') }}</p>
<p>{{ globalStore.latestRelease.changelog }}</p>
<p class="font-bold">{{ $t('update.updateAvailable') }}</p>
<p>{{ globalStore.release.latestRelease.changelog }}</p>
</div>
<a
:href="`https://github.com/wg-easy/wg-easy/releases/tag/${globalStore.latestRelease.version}`"
:href="`https://github.com/wg-easy/wg-easy/releases/tag/${globalStore.release.latestRelease.version}`"
target="_blank"
class="font-sm float-right flex-shrink-0 rounded-md border-2 border-red-800 bg-white p-3 font-semibold text-red-800 transition-all hover:border-white hover:bg-red-800 hover:text-white dark:border-red-600 dark:bg-red-100 dark:text-red-600 dark:hover:border-red-600 dark:hover:bg-red-600 dark:hover:text-red-100"
>
{{ $t('update') }}
{{ $t('update.update') }}
</a>
</div>
</div>

0
src/app/components/icons/ArrowDown.vue → src/app/components/Icons/ArrowDown.vue

0
src/app/components/icons/ArrowInf.vue → src/app/components/Icons/ArrowInf.vue

0
src/app/components/icons/ArrowLeftCircle.vue → src/app/components/Icons/ArrowLeftCircle.vue

0
src/app/components/icons/ArrowRightCircle.vue → src/app/components/Icons/ArrowRightCircle.vue

0
src/app/components/icons/ArrowUp.vue → src/app/components/Icons/ArrowUp.vue

0
src/app/components/icons/Avatar.vue → src/app/components/Icons/Avatar.vue

0
src/app/components/icons/Chart.vue → src/app/components/Icons/Chart.vue

0
src/app/components/icons/CheckCircle.vue → src/app/components/Icons/CheckCircle.vue

0
src/app/components/icons/Close.vue → src/app/components/Icons/Close.vue

0
src/app/components/icons/Delete.vue → src/app/components/Icons/Delete.vue

0
src/app/components/icons/Download.vue → src/app/components/Icons/Download.vue

0
src/app/components/icons/Edit.vue → src/app/components/Icons/Edit.vue

0
src/app/components/icons/HalfMoon.vue → src/app/components/Icons/HalfMoon.vue

15
src/app/components/Icons/Info.vue

@ -0,0 +1,15 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</template>

0
src/app/components/icons/Language.vue → src/app/components/Icons/Language.vue

15
src/app/components/Icons/Link.vue

@ -0,0 +1,15 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"
/>
</svg>
</template>

0
src/app/components/icons/Loading.vue → src/app/components/Icons/Loading.vue

0
src/app/components/icons/Logout.vue → src/app/components/Icons/Logout.vue

0
src/app/components/icons/Moon.vue → src/app/components/Icons/Moon.vue

0
src/app/components/icons/Plus.vue → src/app/components/Icons/Plus.vue

0
src/app/components/icons/QRCode.vue → src/app/components/Icons/QRCode.vue

0
src/app/components/icons/Stack.vue → src/app/components/Icons/Stack.vue

0
src/app/components/icons/Sun.vue → src/app/components/Icons/Sun.vue

0
src/app/components/icons/Warning.vue → src/app/components/Icons/Warning.vue

0
src/app/components/panel/Body.vue → src/app/components/Panel/Body.vue

0
src/app/components/panel/Panel.vue → src/app/components/Panel/Panel.vue

0
src/app/components/panel/head/Boat.vue → src/app/components/Panel/head/Boat.vue

0
src/app/components/panel/head/Head.vue → src/app/components/Panel/head/Head.vue

12
src/app/components/panel/head/Title.vue → src/app/components/Panel/head/Title.vue

@ -1,11 +1,11 @@
<script setup lang="ts">
const { text } = defineProps<{
text: string;
}>();
</script>
<template>
<h2 class="flex-1 text-2xl font-medium">
{{ text }}
</h2>
</template>
<script setup lang="ts">
const { text } = defineProps<{
text: string;
}>();
</script>

0
src/app/components/ui/Banner.vue → src/app/components/Ui/Banner.vue

5
src/app/components/ui/Footer.vue → src/app/components/Ui/Footer.vue

@ -7,7 +7,7 @@
href="https://github.com/wg-easy/wg-easy"
>WireGuard Easy</a
>
({{ globalStore.currentRelease }}) © 2021-2025 by
({{ globalStore.release?.currentRelease }}) © 2021-2025 by
<a
class="hover:underline"
target="_blank"
@ -26,7 +26,7 @@
class="hover:underline"
href="https://github.com/sponsors/WeeJeWel"
target="_blank"
>{{ $t('donate') }}</a
>{{ $t('layout.donate') }}</a
>
</p>
</footer>
@ -34,5 +34,4 @@
<script lang="ts" setup>
const globalStore = useGlobalStore();
globalStore.fetchRelease();
</script>

0
src/app/components/ui/StepProgress.vue → src/app/components/Ui/StepProgress.vue

38
src/app/components/ui/UserMenu.vue → src/app/components/Ui/UserMenu.vue

@ -1,15 +1,14 @@
<template>
<DropdownMenuRoot v-model:open="toggleState">
<DropdownMenuTrigger>
<button
<span
class="flex items-center rounded-full pe-1 text-sm font-medium text-gray-400 hover:text-red-800 focus:ring-4 focus:ring-gray-100 md:me-0 dark:text-neutral-400 dark:hover:text-red-800 dark:focus:ring-gray-700"
type="button"
>
<BaseAvatar class="h-8 w-8">
{{ fallbackName }}
</BaseAvatar>
{{ authStore.userData?.name }}
</button>
</span>
</DropdownMenuTrigger>
<DropdownMenuPortal>
@ -26,7 +25,7 @@
to="/"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Clients
{{ $t('pages.clients') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
@ -34,7 +33,7 @@
to="/me"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Account
{{ $t('pages.me') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem
@ -47,16 +46,16 @@
to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Admin Panel
{{ $t('pages.admin.panel') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<button
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white"
@click.prevent="logout"
@click.prevent="submit"
>
<IconsLogout class="h-5" />
{{ $t('logout') }}
{{ $t('general.logout') }}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
@ -68,16 +67,21 @@
const authStore = useAuthStore();
const toggleState = ref(false);
async function logout() {
try {
await authStore.logout();
navigateTo('/login');
} catch (err) {
if (err instanceof Error) {
// TODO: better ui
alert(err.message || err.toString());
}
const _submit = useSubmit(
'/api/session',
{
method: 'delete',
},
{
revert: async () => {
await navigateTo('/login');
},
noSuccessToast: true,
}
);
function submit() {
return _submit(undefined);
}
const fallbackName = computed(() => {

0
src/app/components/base/Container.vue

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

@ -1,45 +0,0 @@
<script setup lang="ts">
import {
ToastAction,
ToastClose,
ToastDescription,
ToastRoot,
ToastTitle,
} from 'radix-vue';
defineExpose({
publish,
});
// TODO: support multiple types (info, success, error, warning)
const count = reactive<{ title: string; message: string }[]>([]);
function publish(e: { title: string; message: string }) {
count.push({ title: e.title, message: e.message });
}
</script>
<template>
<ToastRoot
v-for="(e, i) in count"
:key="i"
class="data-[state=open]:animate-slideIn data-[state=closed]:animate-hide data-[swipe=end]:animate-swipeOut grid grid-cols-[auto_max-content] items-center gap-x-[15px] rounded-md bg-red-800 p-[15px] text-neutral-200 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] [grid-template-areas:_'title_action'_'description_action'] data-[swipe=cancel]:translate-x-0 data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:transition-[transform_200ms_ease-out]"
>
<ToastTitle
class="text-slate12 mb-[5px] text-[15px] font-medium [grid-area:_title]"
>
{{ e.title }}
</ToastTitle>
<ToastDescription
class="text-slate11 m-0 text-[13px] leading-[1.3] [grid-area:_description]"
>{{ e.message }}</ToastDescription
>
<ToastAction as-child alt-text="toast" class="[grid-area:_action]">
<slot />
</ToastAction>
<ToastClose aria-label="Close">
<span aria-hidden>×</span>
</ToastClose>
</ToastRoot>
</template>

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

@ -1,26 +0,0 @@
<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/Heading.vue

@ -1,5 +0,0 @@
<template>
<h4 class="col-span-full py-6 text-2xl">
<slot />
</h4>
</template>

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

@ -1,26 +0,0 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.trim="data"
:name="id"
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"
/>
</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>

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

@ -1,18 +0,0 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.number="data"
:name="id"
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"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
const data = defineModel<number>();
</script>

19
src/app/components/form/PasswordField.vue

@ -1,19 +0,0 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.trim="data"
:name="id"
type="password"
:autocomplete="autocomplete"
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; autocomplete: string }>();
const data = defineModel<string>();
</script>

11
src/app/components/form/SwitchField.vue

@ -1,11 +0,0 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<BaseSwitch :id="id" v-model="data" />
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
const data = defineModel<boolean>();
</script>

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

@ -1,45 +0,0 @@
<template>
<SelectRoot v-model="langProxy" :default-value="locale">
<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"
aria-label="Customize language"
>
<SelectValue :placeholder="$t('setup.chooseLang')" />
<IconsArrowDown class="size-4" />
</SelectTrigger>
<SelectPortal>
<SelectContent
class="min-w-[160px] rounded bg-white dark:bg-neutral-500"
:side-offset="5"
>
<SelectViewport class="p-[5px]">
<SelectItem
v-for="(option, index) in langs"
:key="index"
:value="option.code"
class="text-grass11 relative flex h-[25px] items-center rounded-[3px] pl-[25px] pr-[35px] text-[13px] leading-none hover:bg-red-800 hover:text-white dark:text-white"
>
<SelectItemText>
{{ option.name }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
<script setup lang="ts">
// TODO: improve
const { locales, locale, setLocale } = useI18n();
const langProxy = ref(locale);
watchEffect(() => {
setLocale(langProxy.value);
});
const langs = locales.value.sort((a, b) => a.code.localeCompare(b.code));
</script>

57
src/app/composables/useSubmit.ts

@ -0,0 +1,57 @@
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types';
import { FetchError } from 'ofetch';
type RevertFn = (success: boolean) => Promise<void>;
type SubmitOpts = {
revert: RevertFn;
successMsg?: string;
errorMsg?: string;
noSuccessToast?: boolean;
};
export function useSubmit<
R extends NitroFetchRequest,
O extends NitroFetchOptions<R> & { body?: never },
>(url: R, options: O, opts: SubmitOpts) {
const toast = useToast();
const { t: $t } = useI18n();
return async (data: unknown) => {
try {
const res = await $fetch(url, {
...options,
body: data,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(res as any).success) {
throw new Error(opts.errorMsg || $t('toast.errored'));
}
if (!opts.noSuccessToast) {
toast.showToast({
type: 'success',
message: opts.successMsg,
});
}
await opts.revert(true);
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
message: e.data.message,
});
} else if (e instanceof Error) {
toast.showToast({
type: 'error',
message: e.message,
});
} else {
console.error(e);
}
await opts.revert(false);
}
};
}

12
src/app/pages/admin.vue

@ -5,7 +5,7 @@
<div class="mr-4 w-64 rounded-lg bg-white p-4 dark:bg-neutral-700">
<NuxtLink to="/admin">
<h2 class="mb-4 text-xl font-bold dark:text-neutral-200">
Admin Panel
{{ t('pages.admin.panel') }}
</h2>
</NuxtLink>
<div class="flex flex-col space-y-2">
@ -38,13 +38,15 @@
const authStore = useAuthStore();
authStore.update();
const { t } = useI18n();
const route = useRoute();
const menuItems = [
{ id: '', name: 'General' },
{ id: 'config', name: 'Config' },
{ id: 'interface', name: 'Interface' },
{ id: 'hooks', name: 'Hooks' },
{ id: '', name: t('pages.admin.general') },
{ id: 'config', name: t('pages.admin.config') },
{ id: 'interface', name: t('pages.admin.interface') },
{ id: 'hooks', name: t('pages.admin.hooks') },
];
const activeMenuItem = computed(() => {

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

@ -2,78 +2,76 @@
<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" />
<FormHeading>{{ $t('admin.config.connection') }}</FormHeading>
<FormTextField
id="host"
v-model="data.host"
:label="$t('general.host')"
:description="$t('admin.config.hostDesc')"
/>
<FormNumberField
id="port"
v-model="data.port"
:label="$t('general.port')"
:description="$t('admin.config.portDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormHeading :description="$t('admin.config.allowedIpsDesc')">{{
$t('general.allowedIps')
}}</FormHeading>
<FormArrayField
v-model="data.defaultAllowedIps"
name="defaultAllowedIps"
/>
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormHeading :description="$t('admin.config.dnsDesc')">{{
$t('admin.config.dns')
}}</FormHeading>
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormHeading>{{ $t('form.sectionAdvanced') }}</FormHeading>
<FormNumberField
id="defaultMtu"
v-model="data.defaultMtu"
label="MTU"
:label="$t('general.mtu')"
:description="$t('admin.config.mtuDesc')"
/>
<FormNumberField
id="defaultPersistentKeepalive"
v-model="data.defaultPersistentKeepalive"
label="Persistent Keepalive"
:label="$t('general.persistentKeepalive')"
:description="$t('admin.config.persistentKeepaliveDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
</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`, {
const _submit = useSubmit(
`/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');
}
// TODO: avoid refreshNuxtData
await revert();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
await revert();
}
},
{ revert }
);
function submit() {
return _submit(data.value);
}
async function revert() {

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

@ -8,47 +8,31 @@
<FormTextField id="PostDown" v-model="data.postDown" label="PostDown" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.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`, {
const _submit = useSubmit(
`/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,
});
}
}
},
{ revert }
);
async function submit() {
return _submit(data.value);
}
async function revert() {

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

@ -5,25 +5,29 @@
<FormNumberField
id="session"
v-model="data.sessionTimeout"
:label="$t('general.sessionTimeout')"
:label="$t('admin.general.sessionTimeout')"
:description="$t('admin.general.sessionTimeoutDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('general.metrics') }}</FormHeading>
<FormHeading>{{ $t('admin.general.metrics') }}</FormHeading>
<FormNullTextField
id="password"
v-model="data.metricsPassword"
:label="$t('passsword')"
:label="$t('admin.general.metricsPassword')"
:description="$t('admin.general.metricsPasswordDesc')"
/>
<FormSwitchField
id="prometheus"
v-model="data.metricsPrometheus"
:label="$t('general.prometheus')"
:label="$t('admin.general.prometheus')"
:description="$t('admin.general.prometheusDesc')"
/>
<FormSwitchField
id="json"
v-model="data.metricsJson"
:label="$t('general.json')"
:label="$t('admin.general.json')"
:description="$t('admin.general.jsonDesc')"
/>
</FormGroup>
<FormGroup>
@ -36,38 +40,21 @@
</template>
<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`, {
const _submit = useSubmit(
`/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,
});
}
}
},
{ revert }
);
function submit() {
return _submit(data.value);
}
async function revert() {

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

@ -2,22 +2,39 @@
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Interface Settings</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField id="port" v-model="data.port" label="Port" />
<FormTextField id="device" v-model="data.device" label="Device" />
<FormNumberField
id="mtu"
v-model="data.mtu"
:label="$t('general.mtu')"
:description="$t('admin.interface.mtuDesc')"
/>
<FormNumberField
id="port"
v-model="data.port"
:label="$t('general.port')"
:description="$t('admin.interface.portDesc')"
/>
<FormTextField
id="device"
v-model="data.device"
:label="$t('admin.interface.device')"
:description="$t('admin.interface.deviceDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
<AdminCidrDialog
trigger-class="col-span-2"
:ipv4-cidr="data.ipv4Cidr"
:ipv6-cidr="data.ipv6Cidr"
@change="changeCidr"
>
<FormActionField label="Change CIDR" class="w-full" />
<FormActionField
:label="$t('admin.interface.changeCidr')"
class="w-full"
/>
</AdminCidrDialog>
</FormGroup>
</FormElement>
@ -25,7 +42,7 @@
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
method: 'get',
@ -33,30 +50,16 @@ const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/interface`, {
const _submit = useSubmit(
`/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,
});
}
}
},
{ revert }
);
function submit() {
return _submit(data.value);
}
async function revert() {
@ -64,29 +67,19 @@ async function revert() {
data.value = toRef(_data.value).value;
}
async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
try {
const res = await $fetch(`/api/admin/interface/cidr`, {
const _changeCidr = useSubmit(
`/api/admin/interface/cidr`,
{
method: 'post',
body: { ipv4Cidr, ipv6Cidr },
});
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,
});
}
},
{
revert,
successMsg: t('admin.interface.cidrSuccess'),
errorMsg: t('admin.interface.cidrError'),
}
);
async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
await _changeCidr({ ipv4Cidr, ipv6Cidr });
}
</script>

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

@ -8,22 +8,27 @@
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
{{ $t('form.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model="data.name" label="Name" />
<FormTextField
id="name"
v-model="data.name"
:label="$t('general.name')"
/>
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
:label="$t('client.enabled')"
/>
<FormDateField
id="expiresAt"
v-model="data.expiresAt"
label="Expire Date"
:description="$t('client.expireDateDesc')"
:label="$t('client.expireDate')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormHeading>{{ $t('client.address') }}</FormHeading>
<FormTextField
id="ipv4Address"
v-model="data.ipv4Address"
@ -36,11 +41,15 @@
/>
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormHeading :description="$t('client.allowedIpsDesc')">{{
$t('general.allowedIps')
}}</FormHeading>
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormHeading :description="$t('client.serverAllowedIpsDesc')">{{
$t('client.serverAllowedIps')
}}</FormHeading>
<FormArrayField
v-model="data.serverAllowedIps"
name="serverAllowedIps"
@ -48,23 +57,36 @@
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormHeading>{{ $t('form.sectionAdvanced') }}</FormHeading>
<FormNumberField
id="mtu"
v-model="data.mtu"
:description="$t('client.mtuDesc')"
:label="$t('general.mtu')"
/>
<FormNumberField
id="persistentKeepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
:description="$t('client.persistentKeepaliveDesc')"
:label="$t('general.persistentKeepalive')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
<ClientsDeleteDialog
trigger-class="col-span-2"
:client-name="data.name"
@delete="deleteClient"
>
<FormActionField label="Delete" class="w-full" />
<FormActionField
label="Delete"
class="w-full"
type="button"
tabindex="-1"
as="span"
/>
</ClientsDeleteDialog>
</FormGroup>
</FormElement>
@ -76,9 +98,8 @@
<script lang="ts" setup>
const authStore = useAuthStore();
authStore.update();
const router = useRouter();
const route = useRoute();
const toast = useToast();
const id = route.params.id as string;
const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
@ -86,30 +107,20 @@ const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/client/${id}`, {
const _submit = useSubmit(
`/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,
});
}
},
{
revert: async () => {
await navigateTo('/');
},
}
);
function submit() {
return _submit(data.value);
}
async function revert() {
@ -117,28 +128,19 @@ async function revert() {
data.value = toRef(_data.value).value;
}
async function deleteClient() {
try {
const res = await $fetch(`/api/client/${id}`, {
const _deleteClient = useSubmit(
`/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,
});
}
},
{
revert: async () => {
await navigateTo('/');
},
}
);
function deleteClient() {
return _deleteClient(undefined);
}
</script>

1
src/app/pages/index.vue

@ -30,6 +30,7 @@
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();

116
src/app/pages/login.vue

@ -2,118 +2,90 @@
<main>
<UiBanner />
<form
class="mx-auto mt-10 w-64 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
@submit="login"
class="mx-auto mt-10 flex w-64 flex-col gap-5 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
@submit.prevent="submit"
>
<!-- Avatar -->
<div
class="relative mx-auto mb-10 mt-5 h-20 w-20 overflow-hidden rounded-full bg-red-800 dark:bg-red-800"
class="mx-auto mb-5 mt-5 h-20 w-20 overflow-hidden rounded-full bg-red-800 dark:bg-red-800"
>
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<input
<BaseInput
v-model="username"
type="text"
name="username"
:placeholder="$t('username')"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
class="mb-5 w-full rounded-lg border-2 border-gray-100 px-3 py-2 text-sm text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-gray-500 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
name="username"
/>
<input
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('password')"
:placeholder="$t('general.password')"
autocomplete="current-password"
class="mb-5 w-full rounded-lg border-2 border-gray-100 px-3 py-2 text-sm text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-gray-500 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
/>
<label
class="mb-5 inline-block cursor-pointer whitespace-nowrap"
:title="$t('titleRememberMe')"
>
<input v-model="remember" type="checkbox" class="sr-only" />
<div
v-if="remember"
class="mr-1 inline-block h-6 w-10 cursor-pointer rounded-full bg-red-800 align-middle transition-all hover:bg-red-700"
>
<div class="m-1 ml-5 h-4 w-4 rounded-full bg-white"></div>
</div>
<div
v-if="!remember"
class="mr-1 inline-block h-6 w-10 cursor-pointer rounded-full bg-gray-200 align-middle transition-all hover:bg-gray-300 dark:bg-neutral-400 dark:hover:bg-neutral-500"
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<div class="m-1 h-4 w-4 rounded-full bg-white"></div>
</div>
<span class="text-sm">{{ $t('rememberMe') }}</span>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
v-if="authenticating"
class="w-full cursor-not-allowed rounded bg-red-800 py-2 text-sm text-white shadow dark:bg-red-800 dark:text-white"
class="rounded py-2 text-sm text-white shadow transition dark:text-white"
:class="{
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password && username,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
!password || !username,
}"
>
<IconsLoading class="mx-auto w-5 animate-spin" />
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
<input
v-else
type="submit"
:class="[
{
'cursor-pointer bg-red-800 transition hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800': !password,
},
'w-full rounded py-2 text-sm text-white shadow dark:text-white',
]"
:value="$t('signIn')"
/>
</form>
</main>
</template>
<script setup lang="ts">
import { FetchError } from 'ofetch';
const { t } = useI18n();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const toast = useToast();
async function login(e: Event) {
e.preventDefault();
const _submit = useSubmit(
'/api/session',
{
method: 'post',
},
{
revert: async (success) => {
authenticating.value = false;
password.value = null;
if (success) {
await navigateTo('/');
}
},
noSuccessToast: true,
}
);
async function submit() {
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
try {
const res = await authStore.login(
username.value,
password.value,
remember.value
);
if (res) {
await navigateTo('/');
}
} catch (error) {
if (error instanceof FetchError) {
toast.showToast({
type: 'error',
title: t('error.login'),
message: error.data.message,
return _submit({
username: username.value,
password: password.value,
remember: remember.value,
});
}
}
authenticating.value = false;
password.value = null;
}
</script>

101
src/app/pages/me.vue

@ -7,38 +7,45 @@
<PanelBody class="dark:text-neutral-200">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>{{ $t('me.sectionGeneral') }}</FormHeading>
<FormTextField id="name" v-model="name" :label="$t('name')" />
<FormHeading>{{ $t('form.sectionGeneral') }}</FormHeading>
<FormTextField
id="name"
v-model="name"
:label="$t('general.name')"
/>
<FormNullTextField
id="email"
v-model="email"
:label="$t('email')"
:label="$t('user.email')"
/>
<FormActionField type="submit" :label="$t('save')" />
<FormActionField type="submit" :label="$t('form.save')" />
</FormGroup>
</FormElement>
<FormElement @submit.prevent="updatePassword">
<FormGroup>
<FormHeading>{{ $t('me.sectionPassword') }}</FormHeading>
<FormHeading>{{ $t('general.password') }}</FormHeading>
<FormPasswordField
id="current-password"
v-model="currentPassword"
autocomplete="current-password"
:label="$t('currentPassword')"
:label="$t('me.currentPassword')"
/>
<FormPasswordField
id="new-password"
v-model="newPassword"
autocomplete="new-password"
:label="$t('setup.newPassword')"
:label="$t('general.newPassword')"
/>
<FormPasswordField
id="confirm-password"
v-model="confirmPassword"
autocomplete="new-password"
:label="$t('confirmPassword')"
:label="$t('me.confirmPassword')"
/>
<FormActionField
type="submit"
:label="$t('general.updatePassword')"
/>
<FormActionField type="submit" :label="$t('updatePassword')" />
</FormGroup>
</FormElement>
</PanelBody>
@ -47,75 +54,51 @@
</template>
<script setup lang="ts">
import { FetchError } from 'ofetch';
const authStore = useAuthStore();
authStore.update();
const toast = useToast();
const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
async function submit() {
try {
const res = await $fetch(`/api/me`, {
const _submit = useSubmit(
`/api/me`,
{
method: 'post',
body: {
name: name.value,
email: email.value,
},
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to update general');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.data.message,
});
}
{
revert: () => {
return authStore.update();
},
}
);
function submit() {
return _submit({ name: name.value, email: email.value });
}
// TODO: handle update password
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
async function updatePassword() {
try {
const res = await $fetch(`/api/me/password`, {
const _updatePassword = useSubmit(
`/api/me/password`,
{
method: 'post',
body: {
},
{
revert: async () => {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
},
}
);
function updatePassword() {
return _updatePassword({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
},
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to update password');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.data.message,
});
}
}
}
</script>

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

Loading…
Cancel
Save