Browse Source

WIP: Feat: UI, General Improvements (#1397)

* update: setup ui page

* remove script addition

* add admin panel

* basic user menu and admin page

* make usable admin panel

* add radix vue, improve ui

* fix features, add toast

* rewrite middleware logic, support basic auth

* add todo marker

* active tailwind forms

* remove some console.logs

* check if user is enabled

frontend doesn't handle this state yet, nothing will work as api routes will fail

* add email to user, basic account page

* better group database

* group even more

* basic statistics page

* update: admin ui

- add: common panel components to get same UI
- i18n: french

* update: setup page error handle

- use fetch error data to provide error message
- use translation to provider error message

* update: me page

* fix: :text props

* update: login page

* update: i18n french support

* fix: use radix toast duration

* update: reduce templates

- remake: setup page to add others step configuration (host/port/migration)

* udpate: setup page use wizard form step

* update: ui

* update: step page

- first step to choose a language
- use red color in light mode
- validate step before move toward

* update: setup page

- use radix select component to reduce boilerplate

* update: setup page

- add: database langugage method
- update: api lang & export supported languages

* update: setup page

- update ui select language
- change lang on selection

* fix: use global store

* fix: initial value

- update: sort langs by value

* fix: ui center paragraph

* fix: remove file extension & some revert

- add: script to run checks script

* update: setup page

- add: host/port section
- i18n: french
- fix: fallback translation

* refactor: split setup into files

* update: setup page

- redirect to login when the setup is done
- allow user to return to previous steps
- prompt error message
- i18n french

* add: migration UI step

- rename: components
- fix: label for & form child id
- i18n french sup

* add: migration server

* fix: use string instead of File

* improve: with zod validation

* restore: clients

* rework setup

* add client page, move api routes

* improve setup

* switch to agpl

* add step back

* update licensed under texts

cc -> agpl

* make db results readonly

avoid weird side effects, when modifying the db object as its only allowed inside e.g. lowdb.ts

* update footer links

* improve client edit page, add mtu

* reorder tailwind classes

* update packages

* update comments

* better toast, better avatar

* delete feature toggle

* remove chart, statistics from server

let user decide what he wants to display

* move into own components

* switch from AGPL-3.0-or-later to AGPL-3.0-only

AGPL-3.0-or-later is not OSI approved

* fix building source

fixes https://github.com/wg-easy/wg-easy/issues/1563

* update packages

---------

Co-authored-by: tetuaoro <[email protected]>
pull/1618/head
Bernd Storath 6 months ago
committed by Bernd Storath
parent
commit
22a084e830
  1. 2
      .vscode/settings.json
  2. 9
      CHANGELOG.md
  3. 4
      Dockerfile
  4. 4
      Dockerfile.dev
  5. 1098
      LICENSE
  6. 5
      README.md
  7. 2
      docs/mkdocs.yml
  8. 2
      package.json
  9. 3
      src/.prettierrc.json
  10. 13
      src/app/app.vue
  11. 60
      src/app/components/Client/Address4.vue
  12. 31
      src/app/components/Client/Avatar.vue
  13. 58
      src/app/components/Client/Client.vue
  14. 27
      src/app/components/Client/Config.vue
  15. 17
      src/app/components/Client/Delete.vue
  16. 91
      src/app/components/Client/ExpireDate.vue
  17. 29
      src/app/components/Client/InlineTransfer.vue
  18. 65
      src/app/components/Client/Name.vue
  19. 24
      src/app/components/Client/OneTimeLink.vue
  20. 25
      src/app/components/Client/QRCode.vue
  21. 40
      src/app/components/Client/Switch.vue
  22. 11
      src/app/components/ClientCard/Address.vue
  23. 30
      src/app/components/ClientCard/Avatar.vue
  24. 22
      src/app/components/ClientCard/Charts.vue
  25. 47
      src/app/components/ClientCard/ClientCard.vue
  26. 16
      src/app/components/ClientCard/Config.vue
  27. 14
      src/app/components/ClientCard/Edit.vue
  28. 23
      src/app/components/ClientCard/ExpireDate.vue
  29. 7
      src/app/components/ClientCard/LastSeen.vue
  30. 16
      src/app/components/ClientCard/Name.vue
  31. 17
      src/app/components/ClientCard/OneTimeLink.vue
  32. 18
      src/app/components/ClientCard/OneTimeLinkBtn.vue
  33. 17
      src/app/components/ClientCard/QRCode.vue
  34. 35
      src/app/components/ClientCard/Switch.vue
  35. 4
      src/app/components/ClientCard/Transfer.vue
  36. 2
      src/app/components/Clients/BackupConfig.vue
  37. 38
      src/app/components/Clients/CreateDialog.vue
  38. 24
      src/app/components/Clients/DeleteDialog.vue
  39. 6
      src/app/components/Clients/Empty.vue
  40. 4
      src/app/components/Clients/List.vue
  41. 2
      src/app/components/Clients/New.vue
  42. 6
      src/app/components/Clients/QRCodeDialog.vue
  43. 2
      src/app/components/Clients/RestoreConfig.vue
  44. 13
      src/app/components/Clients/Sort.vue
  45. 21
      src/app/components/base/Avatar.vue
  46. 2
      src/app/components/base/Button.vue
  47. 0
      src/app/components/base/Chart.vue
  48. 16
      src/app/components/base/Switch.vue
  49. 44
      src/app/components/base/Toast.vue
  50. 11
      src/app/components/form/ActionField.vue
  51. 38
      src/app/components/form/ArrayField.vue
  52. 9
      src/app/components/form/Group.vue
  53. 5
      src/app/components/form/Heading.vue
  54. 17
      src/app/components/form/NumberField.vue
  55. 11
      src/app/components/form/SwitchField.vue
  56. 17
      src/app/components/form/TextField.vue
  57. 16
      src/app/components/header/ChartToggle.vue
  58. 11
      src/app/components/header/Logo.vue
  59. 28
      src/app/components/header/ThemeSwitch.vue
  60. 27
      src/app/components/header/Update.vue
  61. 15
      src/app/components/icons/ArrowLeftCircle.vue
  62. 15
      src/app/components/icons/ArrowRightCircle.vue
  63. 15
      src/app/components/icons/CheckCircle.vue
  64. 5
      src/app/components/panel/Body.vue
  65. 7
      src/app/components/panel/Panel.vue
  66. 5
      src/app/components/panel/head/Boat.vue
  67. 7
      src/app/components/panel/head/Head.vue
  68. 11
      src/app/components/panel/head/Title.vue
  69. 8
      src/app/components/ui/Banner.vue
  70. 46
      src/app/components/ui/ChooseLang.vue
  71. 0
      src/app/components/ui/Modal.vue
  72. 0
      src/app/components/ui/NavBar.vue
  73. 23
      src/app/components/ui/StepProgress.vue
  74. 85
      src/app/components/ui/UserMenu.vue
  75. 37
      src/app/layouts/Footer.vue
  76. 124
      src/app/layouts/Header.vue
  77. 68
      src/app/layouts/default.vue
  78. 29
      src/app/layouts/setup.vue
  79. 53
      src/app/pages/admin.vue
  80. 24
      src/app/pages/admin/defaults.vue
  81. 9
      src/app/pages/admin/index.vue
  82. 19
      src/app/pages/admin/interface.vue
  83. 3
      src/app/pages/admin/metrics.vue
  84. 66
      src/app/pages/clients/[id].vue
  85. 36
      src/app/pages/index.vue
  86. 60
      src/app/pages/login.vue
  87. 120
      src/app/pages/me.vue
  88. 106
      src/app/pages/setup.vue
  89. 42
      src/app/pages/setup/1.vue
  90. 19
      src/app/pages/setup/2.vue
  91. 16
      src/app/pages/setup/3.vue
  92. 79
      src/app/pages/setup/4.vue
  93. 67
      src/app/pages/setup/5.vue
  94. 78
      src/app/pages/setup/migrate.vue
  95. 14
      src/app/pages/setup/success.vue
  96. 28
      src/app/stores/auth.ts
  97. 5
      src/app/stores/clients.ts
  98. 67
      src/app/stores/global.ts
  99. 84
      src/app/stores/setup.ts
  100. 82
      src/app/utils/api.ts

2
.vscode/settings.json

@ -17,7 +17,7 @@
"vue" "vue"
], ],
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/locales" "src/i18n/locales"
], ],
"i18n-ally.sortKeys": false, "i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": false, "i18n-ally.keepFulfilled": false,

9
CHANGELOG.md

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

4
Dockerfile

@ -36,8 +36,8 @@ RUN apk add --no-cache \
wireguard-tools wireguard-tools
# Use iptables-legacy # Use iptables-legacy
RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy 10 --slave /sbin/iptables-restore iptables-restore /sbin/iptables-legacy-restore --slave /sbin/iptables-save iptables-save /sbin/iptables-legacy-save RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables-legacy 10 --slave /usr/sbin/iptables-restore iptables-restore /usr/sbin/iptables-legacy-restore --slave /usr/sbin/iptables-save iptables-save /usr/sbin/iptables-legacy-save
RUN update-alternatives --install /sbin/ip6tables ip6tables /sbin/ip6tables-legacy 10 --slave /sbin/ip6tables-restore ip6tables-restore /sbin/ip6tables-legacy-restore --slave /sbin/ip6tables-save ip6tables-save /sbin/ip6tables-legacy-save RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment # Set Environment
ENV DEBUG=Server,WireGuard,LowDB ENV DEBUG=Server,WireGuard,LowDB

4
Dockerfile.dev

@ -23,8 +23,8 @@ RUN apk add --no-cache \
wireguard-tools wireguard-tools
# Use iptables-legacy # Use iptables-legacy
RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy 10 --slave /sbin/iptables-restore iptables-restore /sbin/iptables-legacy-restore --slave /sbin/iptables-save iptables-save /sbin/iptables-legacy-save RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables-legacy 10 --slave /usr/sbin/iptables-restore iptables-restore /usr/sbin/iptables-legacy-restore --slave /usr/sbin/iptables-save iptables-save /usr/sbin/iptables-legacy-save
RUN update-alternatives --install /sbin/ip6tables ip6tables /sbin/ip6tables-legacy 10 --slave /sbin/ip6tables-restore ip6tables-restore /sbin/ip6tables-legacy-restore --slave /sbin/ip6tables-save ip6tables-save /sbin/ip6tables-legacy-save RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment # Set Environment
ENV DEBUG=Server,WireGuard,LowDB ENV DEBUG=Server,WireGuard,LowDB

1098
LICENSE

File diff suppressed because it is too large

5
README.md

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

2
docs/mkdocs.yml

@ -1,7 +1,7 @@
site_name: "wg-easy" site_name: "wg-easy"
site_description: "The easiest way to run WireGuard VPN + Web-based Admin UI." site_description: "The easiest way to run WireGuard VPN + Web-based Admin UI."
site_author: "wg-easy (Github Organization)" site_author: "wg-easy (Github Organization)"
copyright: '<p>&copy <a href="https://github.com/wg-easy"><em>Wireguard Easy Organization</em></a><br/><span>This project is licensed under the CC BY-NC-SA 4.0 license.</span></p>' copyright: '<p>&copy <a href="https://github.com/wg-easy"><em>Wireguard Easy Organization</em></a><br/><span>This project is licensed under the GNU Affero General Public License v3.0 or later.</span></p>'
repo_url: https://github.com/wg-easy/wg-easy repo_url: https://github.com/wg-easy/wg-easy
repo_name: wg-easy repo_name: wg-easy

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.0" "packageManager": "[email protected]5.0"
} }

3
src/.prettierrc.json

@ -2,5 +2,6 @@
"trailingComma": "es5", "trailingComma": "es5",
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
"singleQuote": true "singleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"]
} }

13
src/app/app.vue

@ -1,15 +1,16 @@
<template> <template>
<ToastProvider>
<NuxtLayout> <NuxtLayout>
<NuxtLayout name="header" />
<NuxtPage /> <NuxtPage />
<NuxtLayout name="footer" /> <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]"
/>
</NuxtLayout> </NuxtLayout>
</ToastProvider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
globalStore.fetchFeatures();
globalStore.fetchRelease();
globalStore.setLanguage(); globalStore.setLanguage();
useHead({ useHead({
bodyAttrs: { bodyAttrs: {
@ -31,6 +32,10 @@ useHead({
}, },
], ],
meta: [ meta: [
{
name: 'mobile-web-app-capable',
content: 'yes',
},
{ {
name: 'apple-mobile-web-app-capable', name: 'apple-mobile-web-app-capable',
content: 'yes', content: 'yes',

60
src/app/components/Client/Address4.vue

@ -1,60 +0,0 @@
<template>
<span class="group">
<!-- Show -->
<input
v-show="clientEditAddress4Id === client.id"
ref="clientAddress4Input"
v-model="clientEditAddress4"
class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500"
@keyup.enter="
updateClientAddress4(client, clientEditAddress4);
clientEditAddress4 = null;
clientEditAddress4Id = null;
"
@keyup.escape="
clientEditAddress4 = null;
clientEditAddress4Id = null;
"
/>
<span v-show="clientEditAddress4Id !== client.id" class="inline-block">{{
client.address4
}}</span>
<!-- Edit -->
<span
v-show="clientEditAddress4Id !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditAddress4 = client.address4;
clientEditAddress4Id = client.id;
nextTick(() => clientAddress4Input?.select());
"
>
<IconsEdit
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100"
/>
</span>
</span>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const clientsStore = useClientsStore();
const clientAddress4Input = ref<HTMLInputElement | null>(null);
const clientEditAddress4 = ref<null | string>(null);
const clientEditAddress4Id = ref<null | string>(null);
function updateClientAddress4(client: WGClient, address4: string | null) {
if (address4 === null) {
return;
}
api
.updateClientAddress4({ clientId: client.id, address4 })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

31
src/app/components/Client/Avatar.vue

@ -1,31 +0,0 @@
<template>
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative">
<IconsAvatar class="w-6 m-2 text-gray-300" />
<img
v-if="client.avatar"
:src="client.avatar"
class="w-10 rounded-full absolute top-0 left-0"
/>
<div
v-if="
client.latestHandshakeAt &&
new Date().getTime() - new Date(client.latestHandshakeAt).getTime() <
1000 * 60 * 10
"
>
<div
class="animate-ping w-4 h-4 p-1 bg-red-100 dark:bg-red-100 rounded-full absolute -bottom-1 -right-1"
/>
<div
class="w-2 h-2 bg-red-800 dark:bg-red-600 rounded-full absolute bottom-0 right-0"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

58
src/app/components/Client/Client.vue

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

27
src/app/components/Client/Config.vue

@ -1,27 +0,0 @@
<template>
<a
:disabled="!client.downloadableConfig"
:href="'./api/wireguard/client/' + client.id + '/configuration'"
:download="client.downloadableConfig ? 'configuration' : null"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white':
client.downloadableConfig,
'is-disabled': !client.downloadableConfig,
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('downloadConfig')"
@click="
if (!client.downloadableConfig) {
$event.preventDefault();
}
"
>
<IconsDownload class="w-5" />
</a>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

17
src/app/components/Client/Delete.vue

@ -1,17 +0,0 @@
<template>
<button
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition"
:title="$t('deleteClient')"
@click="modalStore.clientDelete = client"
>
<IconsDelete class="w-5" />
</button>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const modalStore = useModalStore();
</script>

91
src/app/components/Client/ExpireDate.vue

@ -1,91 +0,0 @@
<template>
<div
v-show="globalStore.features.clientExpiration.enabled"
class="block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs"
>
<span class="group">
<!-- Show -->
<input
v-show="clientEditExpireDateId === client.id"
ref="clientExpireDateInput"
v-model="clientEditExpireDate"
type="text"
class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-70 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500 text-xs p-0"
@keyup.enter="
updateClientExpireDate(client, clientEditExpireDate);
clientEditExpireDate = null;
clientEditExpireDateId = null;
"
@keyup.escape="
clientEditExpireDate = null;
clientEditExpireDateId = null;
"
/>
<span
v-show="clientEditExpireDateId !== client.id"
class="inline-block"
>{{ expiredDateFormat(client.expiresAt) }}</span
>
<!-- Edit -->
<span
v-show="clientEditExpireDateId !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditExpireDate = client.expiresAt
? client.expiresAt.slice(0, 10)
: 'yyyy-mm-dd';
clientEditExpireDateId = client.id;
nextTick(() => clientExpireDateInput?.select());
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</span>
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ client: LocalClient }>();
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
const clientEditExpireDate = ref<string | null>(null);
const clientEditExpireDateId = ref<string | null>(null);
const { t, locale } = useI18n();
const clientExpireDateInput = ref<HTMLInputElement | null>(null);
function updateClientExpireDate(
client: LocalClient,
expireDate: string | null
) {
api
.updateClientExpireDate({ clientId: client.id, expireDate })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
function expiredDateFormat(value: string | null) {
if (value === null) return t('Permanent');
const dateTime = new Date(value);
return dateTime.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>

29
src/app/components/Client/InlineTransfer.vue

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

65
src/app/components/Client/Name.vue

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

24
src/app/components/Client/OneTimeLink.vue

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

25
src/app/components/Client/QRCode.vue

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

40
src/app/components/Client/Switch.vue

@ -1,40 +0,0 @@
<template>
<div
v-if="client.enabled === true"
:title="$t('disableClient')"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all"
@click="disableClient(client)"
>
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white" />
</div>
<div
v-if="client.enabled === false"
:title="$t('enableClient')"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all"
@click="enableClient(client)"
>
<div class="rounded-full w-4 h-4 m-1 bg-white" />
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const clientsStore = useClientsStore();
function enableClient(client: WGClient) {
api
.enableClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
function disableClient(client: WGClient) {
api
.disableClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
</script>

11
src/app/components/ClientCard/Address.vue

@ -0,0 +1,11 @@
<template>
<span class="inline-block">
{{ client.address4 }}, {{ client.address6 }}
</span>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

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

@ -0,0 +1,30 @@
<template>
<div class="relative mt-2 h-10 w-10 self-start rounded-full bg-gray-50">
<BaseAvatar :img="client.avatar" class="h-10 w-10">
<IconsAvatar class="h-6 w-6 text-gray-300" />
</BaseAvatar>
<div
v-if="
client.latestHandshakeAt &&
new Date().getTime() - new Date(client.latestHandshakeAt).getTime() <
1000 * 60 * 10
"
>
<div
class="absolute -bottom-1 -right-1 h-4 w-4 animate-ping rounded-full bg-red-100 p-1 dark:bg-red-100"
/>
<div
class="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-red-800 dark:bg-red-600"
/>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
client: LocalClient;
}>();
console.log(props.client.avatar);
</script>

22
src/app/components/Client/Charts.vue → src/app/components/ClientCard/Charts.vue

@ -1,15 +1,13 @@
<template> <template>
<div <div
v-if="globalStore.features.trafficStats.type" :class="`absolute bottom-0 left-0 right-0 z-0 h-6 ${globalStore.uiChartType === 'line' && 'line-chart'}`"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
> >
<UiChart :options="chartOptionsTX" :series="client.transferTxSeries" /> <BaseChart :options="chartOptionsTX" :series="client.transferTxSeries" />
</div> </div>
<div <div
v-if="globalStore.features.trafficStats.type" :class="`absolute left-0 right-0 top-0 z-0 h-6 ${globalStore.uiChartType === 'line' && 'line-chart'}`"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
> >
<UiChart <BaseChart
:options="chartOptionsRX" :options="chartOptionsRX"
:series="client.transferRxSeries" :series="client.transferRxSeries"
style="transform: scaleY(-1)" style="transform: scaleY(-1)"
@ -32,10 +30,8 @@ const chartOptionsTX = computed(() => {
...chartOptions, ...chartOptions,
colors: [CHART_COLORS.tx[theme.value]], colors: [CHART_COLORS.tx[theme.value]],
}; };
opts.chart.type = opts.chart.type = globalStore.uiChartType;
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined; opts.stroke.width = UI_CHART_PROPS[globalStore.uiChartType].strokeWidth;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
return opts; return opts;
}); });
@ -44,10 +40,8 @@ const chartOptionsRX = computed(() => {
...chartOptions, ...chartOptions,
colors: [CHART_COLORS.rx[theme.value]], colors: [CHART_COLORS.rx[theme.value]],
}; };
opts.chart.type = opts.chart.type = globalStore.uiChartType;
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined; opts.stroke.width = UI_CHART_PROPS[globalStore.uiChartType].strokeWidth;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
return opts; return opts;
}); });

47
src/app/components/ClientCard/ClientCard.vue

@ -0,0 +1,47 @@
<template>
<ClientCardCharts :client="client" />
<div
class="relative z-10 flex flex-col justify-between gap-3 px-3 py-3 sm:flex-row md:py-5"
>
<div class="flex w-full items-center gap-3 md:gap-4">
<ClientCardAvatar :client="client" />
<div class="flex w-full flex-col gap-2 xxs:flex-row">
<div class="flex flex-grow flex-col gap-1">
<ClientCardName :client="client" />
<div
class="block pb-1 text-xs text-gray-500 md:inline-block md:pb-0 dark:text-neutral-400"
>
<ClientCardAddress :client="client" />
<ClientCardLastSeen :client="client" />
</div>
<ClientCardOneTimeLink :client="client" />
<ClientCardExpireDate :client="client" />
</div>
<div
class="mt-px flex shrink-0 items-center justify-end gap-2 text-xs text-gray-400 dark:text-neutral-400"
>
<ClientCardTransfer :client="client" />
</div>
</div>
</div>
<div class="flex items-center justify-end">
<div
class="flex items-center justify-between gap-1 text-gray-400 dark:text-neutral-400"
>
<ClientCardSwitch :client="client" />
<ClientCardEdit :client="client" />
<ClientCardQRCode :client="client" />
<ClientCardConfig :client="client" />
<ClientCardOneTimeLinkBtn :client="client" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

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

@ -0,0 +1,16 @@
<template>
<NuxtLink
:to="'/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')"
>
<IconsDownload class="w-5" />
</NuxtLink>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

14
src/app/components/ClientCard/Edit.vue

@ -0,0 +1,14 @@
<template>
<NuxtLink
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"
:to="`/clients/${client.id}`"
>
<IconsEdit class="w-5" />
</NuxtLink>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

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

@ -0,0 +1,23 @@
<template>
<div
class="block pb-1 text-xs text-gray-500 md:inline-block md:pb-0 dark:text-neutral-400"
>
<span class="inline-block">{{ expiredDateFormat(client.expiresAt) }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ client: LocalClient }>();
const { t, locale } = useI18n();
function expiredDateFormat(value: string | null) {
if (value === null) return t('Permanent');
const dateTime = new Date(value);
return dateTime.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>

7
src/app/components/Client/LastSeen.vue → src/app/components/ClientCard/LastSeen.vue

@ -1,11 +1,10 @@
<template> <template>
<span <span
v-if="client.latestHandshakeAt" v-if="client.latestHandshakeAt"
class="text-gray-400 dark:text-neutral-500 whitespace-nowrap" class="whitespace-nowrap text-gray-400 dark:text-neutral-500"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))" :title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"
> >
{{ !globalStore.features.trafficStats.enabled ? ' · ' : '' · {{ timeago(new Date(client.latestHandshakeAt)) }}
}}{{ timeago(new Date(client.latestHandshakeAt)) }}
</span> </span>
</template> </template>
@ -15,6 +14,4 @@ import { format as timeago } from 'timeago.js';
defineProps<{ defineProps<{
client: LocalClient; client: LocalClient;
}>(); }>();
const globalStore = useGlobalStore();
</script> </script>

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

@ -0,0 +1,16 @@
<template>
<div
class="text-sm text-gray-700 md:text-base dark:text-neutral-200"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))"
>
<span class="border-b-2 border-t-2 border-transparent">
{{ client.name }}
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
</script>

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

@ -0,0 +1,17 @@
<template>
<div v-if="client.oneTimeLink !== null" class="text-xs text-gray-400">
<a :href="'./cnf/' + client.oneTimeLink.oneTimeLink">{{ path }}</a>
</div>
</template>
<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}`;
}
return 'Loading...';
});
</script>

18
src/app/components/Client/OneTimeLinkBtn.vue → src/app/components/ClientCard/OneTimeLinkBtn.vue

@ -1,19 +1,8 @@
<template> <template>
<button <button
v-if="globalStore.features.oneTimeLinks.enabled" 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"
:disabled="!client.downloadableConfig" :title="$t('OneTimeLink')"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition" @click="showOneTimeLink(client)"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white':
client.downloadableConfig,
'is-disabled': !client.downloadableConfig,
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('OneTimeLink')"
@click="
if (client.downloadableConfig) {
showOneTimeLink(client);
}
"
> >
<svg <svg
class="w-5" class="w-5"
@ -36,7 +25,6 @@
defineProps<{ client: LocalClient }>(); defineProps<{ client: LocalClient }>();
const clientsStore = useClientsStore(); const clientsStore = useClientsStore();
const globalStore = useGlobalStore();
function showOneTimeLink(client: LocalClient) { function showOneTimeLink(client: LocalClient) {
api api

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

@ -0,0 +1,17 @@
<template>
<button
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')"
@click="modalStore.qrcode = `./api/client/${client.id}/qrcode.svg`"
>
<IconsQRCode class="w-5" />
</button>
</template>
<script setup lang="ts">
defineProps<{
client: LocalClient;
}>();
const modalStore = useModalStore();
</script>

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

@ -0,0 +1,35 @@
<template>
<BaseSwitch
v-model="enabled"
:title="client.enabled ? $t('disableClient') : $t('enableClient')"
@click="toggleClient"
/>
</template>
<script setup lang="ts">
const props = defineProps<{
client: LocalClient;
}>();
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`, {
method: 'post',
});
} else {
await $fetch(`/api/client/${props.client.id}/enable`, {
method: 'post',
});
}
} catch (err) {
alert(err);
} finally {
clientsStore.refresh().catch(console.error);
}
}
</script>

4
src/app/components/Client/Transfer.vue → src/app/components/ClientCard/Transfer.vue

@ -5,7 +5,7 @@
class="flex gap-1" class="flex gap-1"
:title="$t('totalDownload') + bytes(client.transferTx)" :title="$t('totalDownload') + bytes(client.transferTx)"
> >
<IconsArrowDown class="align-middle h-3 inline mt-0.5" /> <IconsArrowDown class="mt-0.5 inline h-3 align-middle" />
<div> <div>
<span class="text-gray-700 dark:text-neutral-200" <span class="text-gray-700 dark:text-neutral-200"
>{{ bytes(client.transferTxCurrent) }}/s</span >{{ bytes(client.transferTxCurrent) }}/s</span
@ -24,7 +24,7 @@
class="flex gap-1" class="flex gap-1"
:title="$t('totalUpload') + bytes(client.transferRx)" :title="$t('totalUpload') + bytes(client.transferRx)"
> >
<IconsArrowUp class="align-middle h-3 inline mt-0.5" /> <IconsArrowUp class="mt-0.5 inline h-3 align-middle" />
<div> <div>
<span class="text-gray-700 dark:text-neutral-200" <span class="text-gray-700 dark:text-neutral-200"
>{{ bytes(client.transferRxCurrent) }}/s</span >{{ bytes(client.transferRxCurrent) }}/s</span

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

@ -5,6 +5,6 @@
:title="$t('titleBackupConfig')" :title="$t('titleBackupConfig')"
> >
<IconsStack class="w-4 md:mr-2" /> <IconsStack class="w-4 md:mr-2" />
<span class="max-md:hidden text-sm">{{ $t('backup') }}</span> <span class="text-sm max-md:hidden">{{ $t('backup') }}</span>
</BaseButton> </BaseButton>
</template> </template>

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

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

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

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

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

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

4
src/app/components/Clients/Clients.vue → src/app/components/Clients/List.vue

@ -2,9 +2,9 @@
<div <div
v-for="client in clientsStore.clients" v-for="client in clientsStore.clients"
:key="client.id" :key="client.id"
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid" class="relative overflow-hidden border-b border-solid border-gray-100 last:border-b-0 dark:border-neutral-600"
> >
<Client :client="client" /> <ClientCard :client="client" />
</div> </div>
</template> </template>

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

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

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

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

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

@ -1,7 +1,7 @@
<template> <template>
<BaseButton as="label" for="inputRC" :title="$t('titleRestoreConfig')"> <BaseButton as="label" for="inputRC" :title="$t('titleRestoreConfig')">
<IconsArrowInf class="w-4 md:mr-2" /> <IconsArrowInf class="w-4 md:mr-2" />
<span class="max-md:hidden text-sm">{{ $t('restore') }}</span> <span class="text-sm max-md:hidden">{{ $t('restore') }}</span>
<input <input
id="inputRC" id="inputRC"
type="file" type="file"

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

@ -1,8 +1,7 @@
<template> <template>
<button <button
v-if="globalStore.features.sortClients.enabled" 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"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition" @click="toggleSort"
@click="globalStore.sortClient = !globalStore.sortClient"
> >
<svg <svg
v-if="globalStore.sortClient === true" v-if="globalStore.sortClient === true"
@ -42,10 +41,16 @@
fill="#000000" fill="#000000"
/> />
</svg> </svg>
<span class="max-md:hidden text-sm">{{ $t('sort') }}</span> <span class="text-sm max-md:hidden">{{ $t('sort') }}</span>
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
function toggleSort() {
globalStore.sortClient = !globalStore.sortClient;
clientsStore.refresh().catch(console.error);
}
</script> </script>

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

@ -0,0 +1,21 @@
<template>
<AvatarRoot
class="mr-2 inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle"
>
<AvatarImage
v-if="img"
class="h-full w-full rounded-[inherit] object-cover"
:src="img"
/>
<AvatarFallback
class="leading-1 flex h-full w-full items-center justify-center bg-white text-sm font-medium"
:delay-ms="600"
>
<slot />
</AvatarFallback>
</AvatarRoot>
</template>
<script lang="ts" setup>
defineProps<{ img?: string }>();
</script>

2
src/app/components/base/Button.vue

@ -2,7 +2,7 @@
<component <component
:is="elementType" :is="elementType"
role="button" role="button"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 md:px-4 rounded max-md:rounded-full inline-flex items-center transition" class="inline-flex items-center rounded border-2 border-gray-100 py-2 text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white max-md:rounded-full max-md:border-x-0 md:px-4 dark:border-neutral-600 dark:text-neutral-200"
v-bind="attrs" v-bind="attrs"
> >
<slot /> <slot />

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

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

@ -0,0 +1,16 @@
<template>
<SwitchRoot
:id="id"
v-model:checked="data"
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
class="my-auto block h-4 w-4 translate-x-1 rounded-full bg-white shadow-sm transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[20px]"
/>
</SwitchRoot>
</template>
<script lang="ts" setup>
defineProps<{ id?: string }>();
const data = defineModel<boolean>();
</script>

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

@ -0,0 +1,44 @@
<script setup lang="ts">
import {
ToastAction,
ToastClose,
ToastDescription,
ToastRoot,
ToastTitle,
} from 'radix-vue';
defineExpose({
publish,
});
const count = reactive<{ title: string; message: string }[]>([]);
function publish(e: { title: string; message: string }) {
count.push({ title: e.title, message: e.message });
console.log(count.length);
}
</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>

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

@ -0,0 +1,11 @@
<template>
<input
:value="label"
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"
/>
</template>
<script lang="ts" setup>
defineProps<{ label: string }>();
</script>

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

@ -0,0 +1,38 @@
<template>
<div class="flex flex-col">
<div v-for="(item, i) in data" :key="item">
<input
:value="item"
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"
@input="update(i)"
/>
<input type="button" value="-" @click="del(i)" />
</div>
<input type="button" value="Add" @click="add" />
</div>
</template>
<script lang="ts" setup>
const data = defineModel<string[] | null>();
function update(i: number) {
return (v: string) => {
if (!data.value) {
return;
}
data.value[i] = v;
};
}
function add() {
data.value?.push('');
}
function del(i: number) {
if (!data.value) {
return;
}
data.value.splice(i, 1);
}
</script>

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

@ -0,0 +1,9 @@
<template>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
<slot />
<Separator
decorative
class="col-span-2 h-px w-full bg-gray-100 dark:bg-neutral-600"
/>
</section>
</template>

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

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

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

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

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

@ -0,0 +1,11 @@
<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>

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

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

16
src/app/components/header/ChartToggle.vue

@ -0,0 +1,16 @@
<template>
<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')"
@update:pressed="globalStore.toggleCharts"
>
<IconsChart
class="h-5 w-5 fill-gray-400 transition group-data-[state=on]:fill-gray-600 dark:fill-neutral-600 dark:group-data-[state=on]:fill-neutral-400"
/>
</Toggle>
</template>
<script lang="ts" setup>
const globalStore = useGlobalStore();
</script>

11
src/app/components/header/Logo.vue

@ -0,0 +1,11 @@
<template>
<NuxtLink to="/" class="mb-4 flex-grow self-start">
<h1 class="text-4xl font-medium dark:text-neutral-200">
<img
src="/logo.png"
width="32"
class="dark:bg mr-2 inline align-middle"
/><span class="align-middle">WireGuard</span>
</h1>
</NuxtLink>
</template>

28
src/app/components/header/ThemeSwitch.vue

@ -0,0 +1,28 @@
<template>
<button
class="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 transition hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600"
:title="$t(`theme.${theme.preference}`)"
@click="toggleTheme"
>
<IconsSun v-if="theme.preference === 'light'" class="h-5 w-5" />
<IconsMoon
v-else-if="theme.preference === 'dark'"
class="h-5 w-5 text-neutral-400"
/>
<IconsHalfMoon v-else class="h-5 w-5 fill-gray-600 dark:fill-neutral-400" />
</button>
</template>
<script lang="ts" setup>
const theme = useTheme();
function toggleTheme() {
const themeCycle = {
system: 'light',
light: 'dark',
dark: 'system',
} as const;
theme.preference = themeCycle[theme.preference];
}
</script>

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

@ -0,0 +1,27 @@
<template>
<div
v-if="globalStore.updateAvailable && globalStore.latestRelease"
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}`"
>
<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>
</div>
<a
:href="`https://github.com/wg-easy/wg-easy/releases/tag/${globalStore.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') }}
</a>
</div>
</div>
</template>
<script lang="ts" setup>
const globalStore = useGlobalStore();
globalStore.fetchRelease();
</script>

15
src/app/components/icons/ArrowLeftCircle.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 9-3 3m0 0 3 3m-3-3h7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</template>

15
src/app/components/icons/ArrowRightCircle.vue

@ -0,0 +1,15 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m12.75 15 3-3m0 0-3-3m3 3h-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</template>

15
src/app/components/icons/CheckCircle.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="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</template>

5
src/app/components/panel/Body.vue

@ -0,0 +1,5 @@
<template>
<div class="m-4">
<slot />
</div>
</template>

7
src/app/components/panel/Panel.vue

@ -0,0 +1,7 @@
<template>
<div
class="container mx-auto max-w-3xl overflow-hidden rounded-lg bg-white px-3 text-gray-700 shadow-md md:px-0 dark:bg-neutral-700 dark:text-neutral-200"
>
<slot />
</div>
</template>

5
src/app/components/panel/head/Boat.vue

@ -0,0 +1,5 @@
<template>
<div class="flex flex-shrink-0 space-x-1 md:block">
<slot />
</div>
</template>

7
src/app/components/panel/head/Head.vue

@ -0,0 +1,7 @@
<template>
<div
class="flex flex-auto flex-grow flex-row items-center border-b-2 border-gray-100 p-3 px-5 dark:border-neutral-600"
>
<slot />
</div>
</template>

11
src/app/components/panel/head/Title.vue

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

8
src/app/components/ui/Banner.vue

@ -0,0 +1,8 @@
<template>
<h1
class="my-16 text-center text-4xl font-medium text-gray-700 dark:text-neutral-200"
>
<img src="/logo.png" width="32" class="dark:bg inline align-middle" />
<span class="align-middle">WireGuard</span>
</h1>
</template>

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

@ -0,0 +1,46 @@
<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="Customise 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">
import { LOCALES } from '#shared/locales';
const { locale } = useI18n();
const emit = defineEmits(['update:lang']);
const langProxy = ref(locale);
watch(langProxy, (newVal) => {
emit('update:lang', newVal);
});
const langs = LOCALES.sort((a, b) => a.code.localeCompare(b.code));
</script>

0
src/app/components/ui/Modal.vue

0
src/app/components/ui/NavBar.vue

23
src/app/components/ui/StepProgress.vue

@ -0,0 +1,23 @@
<template>
<div
v-for="n in totalSteps"
:key="n"
:class="[
'step mx-3 h-[3px] grow',
step >= n ? 'bg-red-800 dark:bg-white' : 'bg-gray-500',
]"
></div>
</template>
<script setup lang="ts">
defineProps({
step: {
type: Number,
required: true,
},
totalSteps: {
type: Number,
default: 5,
},
});
</script>

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

@ -0,0 +1,85 @@
<template>
<DropdownMenuRoot v-model:open="toggleState">
<DropdownMenuTrigger>
<button
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>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="5"
class="z-10 w-44 divide-y divide-gray-100 rounded-lg bg-white text-gray-700 shadow dark:divide-neutral-800 dark:bg-neutral-700 dark:text-gray-200"
>
<DropdownMenuItem>
<div class="truncate">{{ authStore.userData?.name }}</div>
<div class="truncate">@{{ authStore.userData?.username }}</div>
</DropdownMenuItem>
<DropdownMenuItem>
<NuxtLink
to="/"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Clients
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<NuxtLink
to="/me"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Account
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem v-if="authStore.userData?.role === 'ADMIN'">
<NuxtLink
to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Admin Panel
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
<button
class="flex 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"
>
<IconsLogout class="h-5" />
{{ $t('logout') }}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const toggleState = ref(false);
async function logout() {
try {
await authStore.logout();
navigateTo('/login');
} catch (err) {
if (err instanceof Error) {
// TODO: better ui
alert(err.message || err.toString());
}
}
}
const fallbackName = computed(() => {
return authStore.userData?.name
.split(' ')
.map((word) => word.charAt(0).toUpperCase())
.slice(0, 2)
.join('');
});
</script>

37
src/app/layouts/Footer.vue

@ -1,37 +0,0 @@
<template>
<footer>
<p class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs">
<a
class="hover:underline"
target="_blank"
href="https://github.com/wg-easy/wg-easy"
>WireGuard Easy</a
>
({{ globalStore.currentRelease }}) © 2021-2024 by
<a
class="hover:underline"
target="_blank"
href="https://emilenijssen.nl/?ref=wg-easy"
>Emile Nijssen</a
>
is licensed under
<a
class="hover:underline"
target="_blank"
href="http://creativecommons.org/licenses/by-nc-sa/4.0/"
>CC BY-NC-SA 4.0</a
>
·
<a
class="hover:underline"
href="https://github.com/sponsors/WeeJeWel"
target="_blank"
>{{ $t('donate') }}</a
>
</p>
</footer>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
</script>

124
src/app/layouts/Header.vue

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

68
src/app/layouts/default.vue

@ -0,0 +1,68 @@
<template>
<div>
<header class="container mx-auto mt-4 max-w-3xl px-3 xs:mt-6 md:px-0">
<div
class="mb-5"
:class="
hasOwnLogo
? 'flex justify-end'
: 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
"
>
<HeaderLogo v-if="!hasOwnLogo" />
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
<HeaderThemeSwitch />
<HeaderChartToggle />
<UiUserMenu v-if="loggedIn" />
</div>
</div>
<HeaderUpdate class="mt-5" />
</header>
<slot />
<footer>
<p class="m-10 text-center text-xs text-gray-300 dark:text-neutral-600">
<a
class="hover:underline"
target="_blank"
href="https://github.com/wg-easy/wg-easy"
>WireGuard Easy</a
>
({{ globalStore.currentRelease }}) © 2021-2024 by
<a
class="hover:underline"
target="_blank"
href="https://emile.nl/?ref=wg-easy"
>Emile Nijssen</a
>
is licensed under
<a
class="hover:underline"
target="_blank"
href="https://spdx.org/licenses/AGPL-3.0-only.html"
>AGPL-3.0-only</a
>
·
<a
class="hover:underline"
href="https://github.com/sponsors/WeeJeWel"
target="_blank"
>{{ $t('donate') }}</a
>
</p>
</footer>
</div>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
const route = useRoute();
const hasOwnLogo = computed(
() => route.path === '/login' || route.path === '/setup'
);
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/setup'
);
</script>

29
src/app/layouts/setup.vue

@ -0,0 +1,29 @@
<template>
<main class="container mx-auto px-4">
<UiBanner />
<Panel>
<PanelBody class="mx-auto mt-10 p-4 md:w-[70%] lg:w-[60%]">
<h2 class="mb-16 mt-8 text-3xl font-medium">
{{ $t('setup.welcome') }}
</h2>
<slot />
<div class="mt-12 flex">
<UiStepProgress
:step="setupStore.step"
:total-steps="setupStore.totalSteps"
/>
</div>
</PanelBody>
</Panel>
<BaseToast ref="toast" />
</main>
</template>
<script lang="ts" setup>
const setupStore = useSetupStore();
const savedRef = useTemplateRef('toast');
setupStore.setErrorRef(savedRef);
</script>

53
src/app/pages/admin.vue

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

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

@ -0,0 +1,24 @@
<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>

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

@ -0,0 +1,9 @@
<template>
<div>
<FormGroup>
<FormNumberField id="session" label="Session Timeout" />
</FormGroup>
</div>
</template>
<script setup lang="ts"></script>

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

@ -0,0 +1,19 @@
<template>
<div>
<FormGroup>
<FormHeading>Interface Settings</FormHeading>
<FormNumberField id="mtu" label="MTU" />
<FormNumberField id="port" label="Port" />
<FormTextField id="device" label="Device" />
</FormGroup>
<FormGroup>
<FormHeading>Scripts</FormHeading>
<FormTextField id="mtu" label="PreUp" />
<FormTextField id="port" label="PostUp" />
<FormTextField id="device" label="PreDown" />
<FormTextField id="device" label="PostDown" />
</FormGroup>
</div>
</template>
<script setup lang="ts"></script>

3
src/app/pages/admin/metrics.vue

@ -0,0 +1,3 @@
<template><div></div></template>
<script lang="ts" setup></script>

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

@ -0,0 +1,66 @@
<template>
<main v-if="data">
<Panel>
<PanelHead>
<PanelHeadTitle :text="data.name" />
</PanelHead>
<PanelBody>
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" />
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField id="ipv4" v-model.trim="data.address4" label="IPv4" />
<FormTextField id="ipv6" v-model.trim="data.address6" label="IPv6" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIPs" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField v-model="data.serverAllowedIPs" />
</FormGroup>
<FormGroup></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 label="Delete!" />
<FormActionField label="Revert!" @click="revert" />
</FormGroup>
</PanelBody>
</Panel>
</main>
</template>
<script lang="ts" setup>
const authStore = useAuthStore();
authStore.update();
const route = useRoute();
const id = route.params.id as string;
const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
method: 'get',
});
const data = toRef(_data.value);
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

36
src/app/pages/index.vue

@ -1,27 +1,16 @@
<template> <template>
<main> <main>
<div class="container mx-auto max-w-3xl px-3 md:px-0"> <Panel>
<div <PanelHead>
class="shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden" <PanelHeadTitle :text="$t('pages.clients')" />
> <PanelHeadBoat>
<div
class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-gray-100 dark:border-neutral-600"
>
<div class="flex-grow">
<p class="text-2xl font-medium dark:text-neutral-200">
{{ $t('clients') }}
</p>
</div>
<div class="flex md:block md:flex-shrink-0 space-x-1">
<ClientsRestoreConfig />
<ClientsBackupConfig />
<ClientsSort /> <ClientsSort />
<ClientsNew /> <ClientsNew />
</div> </PanelHeadBoat>
</div> </PanelHead>
<div> <div>
<Clients <ClientsList
v-if="clientsStore.clients && clientsStore.clients.length > 0" v-if="clientsStore.clients && clientsStore.clients.length > 0"
/> />
</div> </div>
@ -30,12 +19,11 @@
/> />
<div <div
v-if="clientsStore.clients === null" v-if="clientsStore.clients === null"
class="text-gray-200 dark:text-red-300 p-5" class="p-5 text-gray-200 dark:text-red-300"
> >
<IconsLoading class="w-5 animate-spin mx-auto" /> <IconsLoading class="mx-auto w-5 animate-spin" />
</div>
</div>
</div> </div>
</Panel>
<ClientsQRCodeDialog /> <ClientsQRCodeDialog />
<ClientsCreateDialog /> <ClientsCreateDialog />
@ -54,11 +42,13 @@ const intervalId = ref<NodeJS.Timeout | null>(null);
clientsStore.refresh(); clientsStore.refresh();
onMounted(() => { onMounted(() => {
// TODO: remove (to avoid console spam)
return;
// TODO?: replace with websocket or similar // TODO?: replace with websocket or similar
intervalId.value = setInterval(() => { intervalId.value = setInterval(() => {
clientsStore clientsStore
.refresh({ .refresh({
updateCharts: globalStore.updateCharts, updateCharts: globalStore.uiShowCharts,
}) })
.catch(console.error); .catch(console.error);
}, 1000); }, 1000);

60
src/app/pages/login.vue

@ -1,21 +1,15 @@
<template> <template>
<section> <main>
<h1 <UiBanner />
class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center"
>
<img src="/logo.png" width="32" class="inline align-middle dark:bg" />
<span class="align-middle">WireGuard</span>
</h1>
<form <form
class="shadow rounded-md bg-white dark:bg-neutral-700 mx-auto w-64 p-5 overflow-hidden mt-10" 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" @submit="login"
> >
<!-- Avatar --> <!-- Avatar -->
<div <div
class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 dark:bg-red-800 relative overflow-hidden" class="relative mx-auto mb-10 mt-5 h-20 w-20 overflow-hidden rounded-full bg-red-800 dark:bg-red-800"
> >
<IconsAvatar class="w-10 h-10 m-5 text-white dark:text-white" /> <IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div> </div>
<input <input
@ -24,7 +18,8 @@
name="username" name="username"
:placeholder="$t('username')" :placeholder="$t('username')"
autocomplete="username" autocomplete="username"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none" 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"
/> />
<input <input
@ -33,27 +28,27 @@
name="password" name="password"
:placeholder="$t('password')" :placeholder="$t('password')"
autocomplete="current-password" autocomplete="current-password"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none" 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 <label
class="inline-block mb-5 cursor-pointer whitespace-nowrap" class="mb-5 inline-block cursor-pointer whitespace-nowrap"
:title="$t('titleRememberMe')" :title="$t('titleRememberMe')"
> >
<input v-model="remember" type="checkbox" class="sr-only" /> <input v-model="remember" type="checkbox" class="sr-only" />
<div <div
v-if="remember" v-if="remember"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all" 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="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div> <div class="m-1 ml-5 h-4 w-4 rounded-full bg-white"></div>
</div> </div>
<div <div
v-if="!remember" v-if="!remember"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all" 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"
> >
<div class="rounded-full w-4 h-4 m-1 bg-white"></div> <div class="m-1 h-4 w-4 rounded-full bg-white"></div>
</div> </div>
<span class="text-sm">{{ $t('rememberMe') }}</span> <span class="text-sm">{{ $t('rememberMe') }}</span>
@ -61,33 +56,40 @@
<button <button
v-if="authenticating" v-if="authenticating"
class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed" class="w-full cursor-not-allowed rounded bg-red-800 py-2 text-sm text-white shadow dark:bg-red-800 dark:text-white"
> >
<IconsLoading class="w-5 animate-spin mx-auto" /> <IconsLoading class="mx-auto w-5 animate-spin" />
</button> </button>
<input <input
v-else v-else
type="submit" type="submit"
:class="[ :class="[
{ {
'bg-red-800 dark:bg-red-800 hover:bg-red-700 dark:hover:bg-red-700 transition cursor-pointer': 'cursor-pointer bg-red-800 transition hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password, password,
'bg-gray-200 dark:bg-neutral-800 cursor-not-allowed': !password, 'cursor-not-allowed bg-gray-200 dark:bg-neutral-800': !password,
}, },
'w-full rounded shadow py-2 text-sm text-white dark:text-white', 'w-full rounded py-2 text-sm text-white shadow dark:text-white',
]" ]"
:value="$t('signIn')" :value="$t('signIn')"
/> />
</form> </form>
</section>
<BaseToast ref="toast" />
</main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FetchError } from 'ofetch';
const { t } = useI18n();
const authenticating = ref(false); const authenticating = ref(false);
const remember = ref(false); 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');
async function login(e: Event) { async function login(e: Event) {
e.preventDefault(); e.preventDefault();
@ -104,10 +106,12 @@ async function login(e: Event) {
if (res) { if (res) {
await navigateTo('/'); await navigateTo('/');
} }
} catch (err) { } catch (error) {
if (err instanceof Error) { if (error instanceof FetchError) {
// TODO: replace alert with actual ui error message toast.value?.publish({
alert(err.message || err.toString()); title: t('error.login'),
message: error.data.message,
});
} }
} }
authenticating.value = false; authenticating.value = false;

120
src/app/pages/me.vue

@ -0,0 +1,120 @@
<template>
<main>
<Panel>
<PanelHead>
<PanelHeadTitle :text="$t('pages.me')" />
</PanelHead>
<PanelBody class="dark:text-neutral-200">
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
<h4 class="col-span-full py-6 text-2xl">
{{ $t('me.sectionGeneral') }}
</h4>
<Label
for="username"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('username') }}
</Label>
<input
id="username"
v-model.trim="username"
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"
/>
<Label for="name" class="font-semibold md:align-middle md:leading-10">
{{ $t('name') }}
</Label>
<input
id="name"
v-model.trim="name"
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"
/>
<Label
for="email"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('email') }}
</Label>
<input
id="email"
v-model.trim="email"
type="email"
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"
/>
<div class="col-span-full">
<BaseButton @click="submit">{{ $t('save') }}</BaseButton>
</div>
</section>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
<h4 class="col-span-full py-6 text-2xl">
{{ $t('me.sectionPassword') }}
</h4>
<Label
for="current-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('currentPassword') }}
</Label>
<input
id="current-password"
v-model.trim="currentPassword"
type="password"
autocomplete="current-password"
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"
/>
<Label
for="new-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('setup.newPassword') }}
</Label>
<input
id="new-password"
v-model.trim="newPassword"
type="password"
autocomplete="new-password"
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"
/>
<Label
for="confirm-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('confirmPassword') }}
</Label>
<input
id="confirm-password"
v-model.trim="confirmPassword"
type="password"
autocomplete="new-password"
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"
/>
<div class="col-span-full">
<BaseButton @click="updatePassword">{{
$t('updatePassword')
}}</BaseButton>
</div>
</section>
</PanelBody>
</Panel>
</main>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const username = ref(authStore.userData?.username);
const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
// TODO: handle update password
const currentPassword = ref(authStore.userData?.email);
const newPassword = ref(authStore.userData?.email);
const confirmPassword = ref(authStore.userData?.email);
function submit() {}
function updatePassword() {}
</script>

106
src/app/pages/setup.vue

@ -1,106 +0,0 @@
<template>
<main class="container mx-auto px-4">
<h1
class="text-4xl font-medium my-16 text-gray-700 dark:text-neutral-200 text-center"
>
<img src="/logo.png" width="32" class="inline align-middle dark:bg" />
<span class="align-middle">WireGuard</span>
</h1>
<div
class="flex flex-col items-center lg:w-[60%] mx-auto shadow rounded-md bg-white dark:bg-neutral-700 p-5 overflow-hidden mt-10 text-gray-700 dark:text-neutral-200"
>
<h2
class="mt-8 mb-16 text-3xl font-medium text-gray-700 dark:text-neutral-200"
>
{{ $t('setup.welcome') }}
</h2>
<p class="text-lg p-8">{{ $t('setup.msg') }}</p>
<form class="mb-8" @submit="newAccount">
<div>
<label for="username" class="inline-block py-2">{{
$t('username')
}}</label>
<input
id="username"
v-model="username"
type="text"
name="username"
autocomplete="username"
:placeholder="$t('setup.usernamePlaceholder')"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none"
required="true"
/>
<small v-if="errorCU" class="text-danger">{{
$t('setup.usernameCondition')
}}</small>
</div>
<div>
<label for="password" class="inline-block py-2">{{
$t('setup.newPassword')
}}</label>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="new-password"
:placeholder="$t('setup.passwordPlaceholder')"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none"
required="true"
/>
<small v-if="errorPWD" class="text-danger">{{
$t('setup.passwordCondition')
}}</small>
</div>
<div>
<label for="accept" class="inline-block my-4 mr-4">{{
$t('setup.accept')
}}</label>
<input id="accept" type="checkbox" name="accept" required="true" />
</div>
<button
type="submit"
:class="[
{
'bg-red-800 dark:bg-red-800 hover:bg-red-700 dark:hover:bg-red-700 transition cursor-pointer':
password && username,
'bg-gray-200 dark:bg-neutral-800 cursor-not-allowed':
!password && !username,
},
'w-max px-4 rounded shadow py-2 text-sm text-white dark:text-white',
]"
>
{{ $t('setup.submitBtn') }}
</button>
</form>
</div>
</main>
</template>
<script setup lang="ts">
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const errorCU = ref(false);
const errorPWD = ref(false);
async function newAccount(e: Event) {
e.preventDefault();
if (!username.value || !password.value) return;
try {
const res = await authStore.signup(username.value, password.value);
if (res) {
navigateTo('/login');
}
} catch (error) {
if (error instanceof Error) {
// TODO: replace alert with actual ui error message
// TODO: also use errorCU & errorPWD to show prompt error
alert(error.message || error.toString());
}
}
}
</script>

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

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

19
src/app/pages/setup/2.vue

@ -0,0 +1,19 @@
<template>
<div>
<p class="p-8 text-center text-lg">
{{ 'Do you have a existing Setup?' }}
</p>
<div class="mb-8 flex justify-center">
<NuxtLink to="/setup/3"><BaseButton>No</BaseButton></NuxtLink>
<NuxtLink to="/setup/migrate"><BaseButton>Yes</BaseButton></NuxtLink>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'setup',
});
const setupStore = useSetupStore();
setupStore.setStep(2);
</script>

16
src/app/pages/setup/3.vue

@ -0,0 +1,16 @@
<template>
<div>
<p class="px-8 pt-8 text-center text-2xl">
{{ $t('setup.messageWelcome.whatIs') }}
</p>
<NuxtLink to="/setup/4"><BaseButton>Continue</BaseButton></NuxtLink>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'setup',
});
const setupStore = useSetupStore();
setupStore.setStep(3);
</script>

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

@ -0,0 +1,79 @@
<template>
<div>
<p class="p-8 text-center text-lg">
{{ $t('setup.messageSetupCreateAdminUser') }}
</p>
<form id="newAccount"></form>
<div>
<Label for="username">{{ $t('username') }}</Label>
<input
id="username"
v-model="username"
form="newAccount"
type="text"
autocomplete="username"
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-200 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
/>
</div>
<div>
<Label for="password">{{ $t('setup.newPassword') }}</Label>
<input
id="password"
v-model="password"
form="newAccount"
type="password"
autocomplete="new-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-200 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
/>
</div>
<div>
<Label for="accept">{{ $t('setup.accept') }}</Label>
<input
id="accept"
v-model="accept"
form="newAccount"
type="checkbox"
class="ml-2"
/>
</div>
<BaseButton @click="newAccount">Create Account</BaseButton>
</div>
</template>
<script lang="ts" setup>
import { FetchError } from 'ofetch';
const { t } = useI18n();
definePageMeta({
layout: 'setup',
});
const setupStore = useSetupStore();
setupStore.setStep(4);
const router = useRouter();
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const accept = ref<boolean>(true);
async function newAccount() {
try {
if (!username.value || !password.value) {
setupStore.handleError({
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
return;
}
await setupStore.step4(username.value, password.value, accept.value);
await router.push('/setup/5');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
title: t('setup.requirements'),
message: error.data.message,
});
}
}
}
</script>

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

@ -0,0 +1,67 @@
<template>
<div>
<p class="p-8 text-center text-lg">
{{ $t('setup.messageSetupHostPort') }}
</p>
<div>
<Label for="host">{{ $t('setup.host') }}</Label>
<input
id="host"
v-model="host"
type="text"
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-200 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
placeholder="vpn.example.com"
/>
</div>
<div>
<Label for="port">{{ $t('setup.port') }}</Label>
<input
id="port"
v-model="port"
type="number"
:min="1"
:max="65535"
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-200 dark:placeholder:text-neutral-400 dark:focus:border-red-800"
/>
</div>
<BaseButton @click="updateHostPort">Continue</BaseButton>
</div>
</template>
<script setup lang="ts">
import { FetchError } from 'ofetch';
definePageMeta({
layout: 'setup',
});
const { t } = useI18n();
const setupStore = useSetupStore();
setupStore.setStep(5);
const router = useRouter();
const host = ref<null | string>(null);
const port = ref<number>(51820);
async function updateHostPort() {
if (!host.value || !port.value) {
setupStore.handleError({
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
return;
}
try {
await setupStore.step5(host.value, port.value);
await router.push('/setup/success');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
title: t('setup.requirements'),
message: error.data.message,
});
}
}
}
</script>

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

@ -0,0 +1,78 @@
<template>
<div>
<p class="p-8 text-center text-lg">
{{ $t('setup.messageSetupMigration') }}
</p>
<div>
<Label for="migration">{{ $t('setup.migration') }}</Label>
<input id="migration" type="file" @change="onChangeFile" />
</div>
<BaseButton @click="sendFile">Upload</BaseButton>
</div>
</template>
<script lang="ts" setup>
import { FetchError } from 'ofetch';
definePageMeta({
layout: 'setup',
});
const { t } = useI18n();
const setupStore = useSetupStore();
setupStore.setStep(5);
const backupFile = ref<null | File>(null);
function onChangeFile(evt: Event) {
const target = evt.target as HTMLInputElement;
const file = target.files?.[0];
console.log('file', file);
if (file) {
backupFile.value = file;
console.log('backupFile.value', backupFile.value);
}
}
const router = useRouter();
async function sendFile() {
if (!backupFile.value) {
setupStore.handleError({
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
return;
}
try {
const content = await readFileContent(backupFile.value);
await setupStore.runMigration(content);
await router.push('/setup/success');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
title: t('setup.requirements'),
message: error.data.message,
});
}
}
}
async function readFileContent(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsText(file);
});
}
</script>

14
src/app/pages/setup/success.vue

@ -0,0 +1,14 @@
<template>
<div>
<p>Setup successfully</p>
<NuxtLink to="/login"><BaseButton>Login</BaseButton></NuxtLink>
</div>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'setup',
});
const setupStore = useSetupStore();
setupStore.setStep(6);
</script>

28
src/app/stores/auth.ts

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

5
src/app/stores/clients.ts

@ -108,10 +108,7 @@ export const useClientsStore = defineStore('Clients', () => {
}; };
}); });
if ( if (transformedClients !== undefined) {
globalStore.features.sortClients.enabled &&
transformedClients !== undefined
) {
transformedClients = sortByProperty( transformedClients = sortByProperty(
transformedClients, transformedClients,
'name', 'name',

67
src/app/stores/global.ts

@ -1,33 +1,14 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('Global', () => { export const useGlobalStore = defineStore('Global', () => {
const uiShowCharts = ref(getItem('uiShowCharts') === '1');
const currentRelease = ref<null | string>(null);
const latestRelease = ref<null | { version: string; changelog: string }>(
null
);
const updateAvailable = ref(false);
const features = ref({
trafficStats: {
enabled: false,
type: 0,
},
sortClients: {
enabled: false,
},
clientExpiration: {
enabled: false,
},
oneTimeLinks: {
enabled: false,
},
});
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(); const { availableLocales, locale } = useI18n();
async function setLanguage() { async function setLanguage() {
const { data: lang } = await api.getLang(); const { data: lang } = await useFetch('/api/lang', {
method: 'get',
});
if ( if (
lang.value !== getItem('lang') && lang.value !== getItem('lang') &&
availableLocales.includes(lang.value!) availableLocales.includes(lang.value!)
@ -37,8 +18,16 @@ export const useGlobalStore = defineStore('Global', () => {
} }
} }
const currentRelease = ref<null | string>(null);
const latestRelease = ref<null | { version: string; changelog: string }>(
null
);
const updateAvailable = ref(false);
async function fetchRelease() { async function fetchRelease() {
const { data: release } = await api.getRelease(); const { data: release } = await useFetch('/api/release', {
method: 'get',
});
if (!release.value) { if (!release.value) {
return; return;
@ -49,27 +38,35 @@ export const useGlobalStore = defineStore('Global', () => {
updateAvailable.value = release.value.updateAvailable; updateAvailable.value = release.value.updateAvailable;
} }
async function fetchFeatures() { const uiShowCharts = ref(getItem('uiShowCharts') === '1');
const { data: apiFeatures } = await api.getFeatures();
if (apiFeatures.value) { function toggleCharts() {
features.value = apiFeatures.value; setItem('uiShowCharts', uiShowCharts.value ? '1' : '0');
}
} }
const updateCharts = computed(() => { const uiChartType = ref(getItem('uiChartType') ?? 'area');
return features.value.trafficStats.type > 0 && uiShowCharts.value;
/**
* @throws if unsuccessful
*/
async function updateLang(lang: string) {
const response = await $fetch('/api/admin/lang', {
method: 'post',
body: { lang },
}); });
return response.success;
}
return { return {
uiShowCharts,
updateCharts,
sortClient, sortClient,
features, setLanguage,
currentRelease, currentRelease,
latestRelease, latestRelease,
updateAvailable, updateAvailable,
fetchRelease, fetchRelease,
fetchFeatures, uiShowCharts,
setLanguage, toggleCharts,
uiChartType,
updateLang,
}; };
}); });

84
src/app/stores/setup.ts

@ -0,0 +1,84 @@
import { defineStore } from 'pinia';
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
*/
async function step4(username: string, password: string, accept: boolean) {
const response = await $fetch('/api/setup/4', {
method: 'post',
body: { username, password, accept },
});
return response.success;
}
/**
* @throws if unsuccessful
*/
async function step5(host: string, port: number) {
const response = await $fetch('/api/setup/5', {
method: 'post',
body: { host, port },
});
return response.success;
}
/**
* @throws if unsuccessful
*/
async function runMigration(file: string) {
const response = await $fetch('/api/setup/migrate', {
method: 'post',
body: { file },
});
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 totalSteps = ref(6);
function setStep(i: number) {
step.value = i;
}
return {
step1,
step4,
step5,
runMigration,
setErrorRef,
handleError,
step,
totalSteps,
setStep,
};
});

82
src/app/utils/api.ts

@ -1,19 +1,6 @@
class API { class API {
async getRelease() {
return useFetch('/api/release', {
method: 'get',
});
}
async getLang() {
return useFetch('/api/lang', {
method: 'get',
});
}
async getSession() { async getSession() {
// TODO?: use useFetch return useFetch('/api/session', {
return $fetch('/api/session', {
method: 'get', method: 'get',
}); });
} }
@ -40,7 +27,7 @@ class API {
} }
async getClients() { async getClients() {
return useFetch('/api/wireguard/client', { return useFetch('/api/client', {
method: 'get', method: 'get',
}); });
} }
@ -52,62 +39,24 @@ class API {
name: string; name: string;
expireDate: string | null; expireDate: string | null;
}) { }) {
return $fetch('/api/wireguard/client', { return $fetch('/api/client', {
method: 'post', method: 'post',
body: { name, expireDate }, body: { name, expireDate },
}); });
} }
async deleteClient({ clientId }: { clientId: string }) { async deleteClient({ clientId }: { clientId: string }) {
return $fetch(`/api/wireguard/client/${clientId}`, { return $fetch(`/api/client/${clientId}`, {
method: 'delete', method: 'delete',
}); });
} }
async showOneTimeLink({ clientId }: { clientId: string }) { async showOneTimeLink({ clientId }: { clientId: string }) {
return $fetch(`/api/wireguard/client/${clientId}/generateOneTimeLink`, { return $fetch(`/api/client/${clientId}/generateOneTimeLink`, {
method: 'post',
});
}
async enableClient({ clientId }: { clientId: string }) {
return $fetch(`/api/wireguard/client/${clientId}/enable`, {
method: 'post',
});
}
async disableClient({ clientId }: { clientId: string }) {
return $fetch(`/api/wireguard/client/${clientId}/disable`, {
method: 'post', method: 'post',
}); });
} }
async updateClientName({
clientId,
name,
}: {
clientId: string;
name: string;
}) {
return $fetch(`/api/wireguard/client/${clientId}/name`, {
method: 'put',
body: { name },
});
}
async updateClientAddress4({
clientId,
address4,
}: {
clientId: string;
address4: string;
}) {
return $fetch(`/api/wireguard/client/${clientId}/address4`, {
method: 'put',
body: { address4 },
});
}
async updateClientExpireDate({ async updateClientExpireDate({
clientId, clientId,
expireDate, expireDate,
@ -115,7 +64,7 @@ class API {
clientId: string; clientId: string;
expireDate: string | null; expireDate: string | null;
}) { }) {
return $fetch(`/api/wireguard/client/${clientId}/expireDate`, { return $fetch(`/api/client/${clientId}/expireDate`, {
method: 'put', method: 'put',
body: { expireDate }, body: { expireDate },
}); });
@ -127,25 +76,6 @@ class API {
body: { file }, body: { file },
}); });
} }
async setupAccount({
username,
password,
}: {
username: string;
password: string;
}) {
return $fetch('/api/account/setup', {
method: 'post',
body: { username, password },
});
}
async getFeatures() {
return useFetch('/api/features', {
method: 'get',
});
}
} }
type WGClientReturn = Awaited< type WGClientReturn = Awaited<

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

Loading…
Cancel
Save