Version 15.0.0-beta.1: Rewrite in Nuxt and Typescript, Move to UI (#1333)
* Add Nuxt, ESM, Typescript (#1244)
* wip: add nuxt
* basic implementation
* add changes from c9ff248
* update workflow, add eslint
* add types, fix wrong error message
* install correct bcrypt, move eslint to dev modules
* add docker dev script
* fix styling
* add wireguard routes
* typescript, vendors
* fix lint workflow
* lint fixes
* add prettier, format
* fix lint, add vscode settings
* better typescript
* use auto imports
* add prettier eslint config
* cache config
* fix styling issue, fix formatting
* fix tailwind problems
* fix logout not showing
* fix lint action
* Fix session middleware
* split files into correct methods
* use type safe api, fix typescript errors
* better return types
not tested
* change default working directory
* update workflows
* fix error
* correct session middleware, type safe session
* convert undefined to boolean
* correct key for api errors
* use zod to validate input
* add more jobs to check for good code
* add pinia
Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>
* use color mode plugin
* !! use better storage key name
Breaking as if old key exists it breaks as "auto" is not compatible with new "system"
* better local dev while dev container is running
use `docker compose -f docker-compose.dev.yml up`
or after changing dockerfile
`docker compose -f docker-compose.dev.yml up --build`
* update translation to match new theme mode
* improve dx
new devs get extensions recommended to catch errors, etc directly in vscode
* reduce errors, improve typing
* Split components (#1)
* update: introduce pages & components
fix lint
* update: starting split components
* use auto imports
* Improve workflows and docker
workflow fix step naming
simplify docker dev
simplify docker prod
revert to node 18
dockerfile naming scheme
* Split components (#2)
* update: starting split components
* upd: rebase & continue splitting components
- layouts: header & footer
- components: basic buttton
- pages: login page
* update: login page
* package.json: remove dev:pass script
* Split into Components, migrate to nuxt
fixup
shutdown wireguard properly
fix styling, fix store
split even more
clear interval
split even more
split even more
handle auth middleware on server
avoid flicker of login page
* fix: buttons spaces & move layouts to components (#3)
* update: icons into components
- fix: header login page
* fix: tailwind handle btn class
* Split into icons
fix avatar
move class to view not icon itself
fix icon
format
* invalidate cache to make restoreConfig work
* fix apexchart
* use different color mode module
other one resulted in hydration mismatch
* fix dialog
* fix bad i18n merge
* use nuxt 4
* fix typing, fix redirect, latest release on server
* start wireguard on start
* wait for shutdown
* improve zod errors, consistent server errors
* migrate to useFetch
this makes sure that there is no double fetching
* fix hydration issues, remove unnecessary state, rename function
* fetch globalstore globally
otherwise this will load on login to homepage
* migrate to useFetch
no javascript support
TODO: not properly tested
* update backend
* wip: frontend
* update frontend
* update pnpm lock
---------
Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>
Co-authored-by: tetuaoro <65575727+tetuaoro@users.noreply.github.com>
* Fix various issues
fix router param
fix max age
unit is seconds not ms
fix regressions
fix missing expire date in client create dialog
fix wrong type rules
fix wrong api endpoint
properly catch error running cron job
fix type issues
* add database (#1330)
* add: database abstraction
* update: get lang from database
* udpate: with repositories
* add: interfaces to connect a database provider
- easy swapping between database provider
* add: setup page
- add: in-memory database provider
- create a new account (signup)
- login with username and password
- first setup page to create an account
- PASSWORD_HASH was removed from environment and files was updated/removed due to that change
* update: Dockerfile
* fix: review done
- remove: REQUIRES_PASSWORD & RELEASE environment variables
* fix: i18n translation
- rename directories
* update: use database
* fix: typecheck
* fix: review
* rebase & add: persistent lowdb provider
* update: french translation
* revert: due to rebase
* remove & document
* Refactor New UI (#1342)
* refactor code
* refactor code
* add some todos
* update pnpm, start migrating to database
* add missing i18n key
* add todo
* basic setup styling
* nuxt 4 folder structure, update packages
* Feat: Migrations (#1344)
* add migrations
* improve migration runner
* improve migration runner
* document what each migration does
* Feat: Rewrite Wireguard to use Database (#1345)
* update wireguard
* update
* update
* remove all config
* move all features into one route
* improve code
* fix some issues
add wg_path, update documentation
* Feat: Cidr Support (#1347)
* cidr support
* add cidr
* fix some errors
fix server config
missing cidr block in server config
* Fix: Database Date type (#1349)
* Feat: IPv6 (#1354)
* start supporting ipv6
* add ipv6 support
* build server with es2020
es2019 doesn't support bigint
* fix issues, better naming
* Fix: Security (#1355)
* separate route for onboarding
* zse zod for validation
* use argon2id
* add build tools
* Feat: Server AllowedIPs, MTU (#1356)
* add wireguard helpers
* improve wireguard helpers
* add server mtu
* fix wg0.conf formatting
* add ipv6 support to docker compose and readme
* Feat: Docs (#1361)
* basic docs
* use semver versioning
* Feat: Migration (#1363)
* start migration
* improve migration
* remove endpoint from client
* improve docker
* Chore: Deprecate Dockerless (#1377)
* deprecate dockerless
* Feat: Improve Repository pattern (#1380)
* improve repository pattern
* fix errors
* Feat: Improve Database Handling (#1383)
* improve docker build
* build doc workflow
* Feat: Changelog, Release Notes (#1385)
* add changelog, use semver for update message
* use first line of release for short changelog
* load ipv6 iptables module
* Feat: Show Version in Footer (#1389)
update ui logic, always store release in global store.
new release logic uses rate limited github api, avoid using cache
* use i18n ally (#1391)
* improve gh actions
* Setup UI (#1392)
* update: setup ui page
* rebase
* remove script addition
* Fixed usage of Ukrainian instead of Russian in ru.json (#1414)
* Added translations for the Belarusian language (#1472)
* Install kmod from alpine repository (#1553)
Because the busybox modprobe utility is unable to load zstd compressed modules.
Co-authored-by: Matt <mmoore2012@users.noreply.github.com>
* 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 <65575727+tetuaoro@users.noreply.github.com>
* update readme
* Feat: Settings, UI, General Improvements (#1572)
* deprecate other languages
new ui has too many new strings
* fix wrong license in readme
* properly fetch release
* order safe data structure for migrations
* empty server allowed ips by default
* show userconfig in admin panel
* remove routes, fix config
* add ability to update clients
* handle form submit using js
avoid weird behavior with FormData
* global toast, be able to update client
* update packages
* fix date field
* delete client using radix dialog
* remove lang from backend, let users decide
* be able to change interface and general
* be able to update user config
* consistent allowedips
* fix array field
* improve avatar, code cleanup
* basic metrics support
* remove dateTime helper
* be able to change hooks
* start cidr update
* be able to update cidr
* Feat: SQLite (#1619)
* start drizzle migration
* split schema
* improve schema
* improve schema, cascade, unique
* improve structure, start migration
* migrate to sqlite
* work in prod docker
* start adding a better permission handler
* permission matrix, permission handler
* update packages
* move session timeout to session config, use new permission handler
* improve docker dev
only install dependencies if changed
* implement setup
* migrate to sqlite
* improve debug, fix custom migration
* migrate to sqlite
* regenerate migrations
* ignore autogenerated migrations from prettier
* migrate to sqlite
* migrate to sqlite
* Migrate to sqlite
* fix prod error
* move nuxt middleware from server to nuxt
* update corepack in prod dockerfile
* use correct branch for workflow
* make docker file build on armv6/v7
* fix client update
* update zod locales
* cancel pr workflow if new commit
* test concurrency
* Feat: Account (#1645)
* be able to change name, email
* be able to change password
* consistent naming
zod is a schema not a type
* use transaction instance
* add zod strings
* Feat: Prometheus (#1655)
* check metrics password
* rewrite prometheus and json metric endpoints
* move metrics to general
metrics is not per interface
* change metrics settings in admin panel
* add i18n keys
* Chore: Remove multi interface support (#1657)
* streamline references to wg0
database wg0 name makes no sense anymore
wg0 only in database, could be easily replaced, or support for custom name added
* fix default key gen
* Feat: Permission System (#1660)
* wip: add abac
* wip: add admin abac
* add me abac
* fix type issue
* move from role check
avoid authStore.userData?.role === roles.ADMIN
* Feat: Zod Generic String (#1661)
* start improving zod translations
* update zod translations
* Feat: Migration (#1663)
* show error for old env vars
* reorder setup, be able to migrate
* fix type issue
* footer and header in setup, remove lang setup step
* remove backup / restore
* refactor dialog (#1665)
* fixed Dockerfile HEALTHCHECK syntax (#1686)
HEALTHCHECK options should always come before the CMD instruction
* Feat: Info (#1666)
* add tooltip info, extract strings
* multi type toast
* improve useSubmit, i18n
* better login screen
* improve
* consistent folder casing
* consistent casing
* fix even more stuff
* temp
* fix type errors
* remove armv6/7 support for now
* add information to client page
* optimize dockerfile
* update base image in Dockerfile to use node:lts-alpine
* fix build stage
* Chore: TODOs (#1695)
* verify setup step
* improve readme
* format todos
* move id
* remove objectMessage
* style array field
* Chore: TODOs (#1696)
* fix chart
* replace localstorage with cookies
* Chore: Improvments (#1697)
* update packages
* fix tab issues
* consistent imports
* use eslint module
* update date
* improve docs
* update docs
* format
* fix docs, fix cookie
* recognize timing attack potential
* improve gh actions, issue templates (#1700)
* Feat improv (#1702)
* add insecure option, link readme to docs
* improve docs
* update version
* add warning to readme
---------
Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>
Co-authored-by: tetuaoro <65575727+tetuaoro@users.noreply.github.com>
Co-authored-by: laperuz92 <31198184+laperuz92@users.noreply.github.com>
Co-authored-by: Siomkin Alexander <siomkin.alexander@gmail.com>
Co-authored-by: Matt <102529127+mmoore2012@users.noreply.github.com>
Co-authored-by: Matt <mmoore2012@users.noreply.github.com>
Co-authored-by: Denis Kazimirov <rokiden@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "athom",
|
||||
"ignorePatterns": [
|
||||
"**/vendor/*.js"
|
||||
],
|
||||
"rules": {
|
||||
"consistent-return": "off",
|
||||
"no-shadow": "off",
|
||||
"max-len": "off"
|
||||
}
|
||||
}
|
||||
26
src/.gitignore
vendored
Normal file
26
src/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
wg-easy.db
|
||||
1
src/.npmrc
Normal file
1
src/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
public-hoist-pattern[]=@libsql/linux*
|
||||
2
src/.prettierignore
Normal file
2
src/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
pnpm-lock.yaml
|
||||
server/database/migrations/meta
|
||||
7
src/.prettierrc.json
Normal file
7
src/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
57
src/app/app.vue
Normal file
57
src/app/app.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
<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]"
|
||||
>
|
||||
<BaseToast ref="toastRef" />
|
||||
</ToastViewport>
|
||||
</NuxtLayout>
|
||||
</ToastProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const toast = useToast();
|
||||
const toastRef = useTemplateRef('toastRef');
|
||||
toast.setToast(toastRef);
|
||||
|
||||
// make sure to fetch release early
|
||||
useGlobalStore();
|
||||
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: 'bg-gray-50 dark:bg-neutral-800',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'manifest',
|
||||
href: '/manifest.json',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon.png',
|
||||
},
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/apple-touch-icon.png',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'mobile-web-app-capable',
|
||||
content: 'yes',
|
||||
},
|
||||
{
|
||||
name: 'apple-mobile-web-app-capable',
|
||||
content: 'yes',
|
||||
},
|
||||
{
|
||||
name: 'apple-mobile-web-app-status-bar-style',
|
||||
content: 'black-translucent',
|
||||
},
|
||||
],
|
||||
title: 'WireGuard',
|
||||
});
|
||||
</script>
|
||||
34
src/app/components/Admin/CidrDialog.vue
Normal file
34
src/app/components/Admin/CidrDialog.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<BaseDialog :trigger-class="triggerClass">
|
||||
<template #trigger><slot /></template>
|
||||
<template #title>{{ $t('admin.interface.changeCidr') }}</template>
|
||||
<template #description>
|
||||
<FormGroup>
|
||||
<FormTextField id="ipv4Cidr" v-model="ipv4Cidr" label="IPv4" />
|
||||
<FormTextField id="ipv6Cidr" v-model="ipv6Cidr" label="IPv6" />
|
||||
</FormGroup>
|
||||
</template>
|
||||
<template #actions>
|
||||
<DialogClose as-child>
|
||||
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
|
||||
</DialogClose>
|
||||
<DialogClose as-child>
|
||||
<BaseButton @click="$emit('change', ipv4Cidr, ipv6Cidr)">
|
||||
{{ $t('dialog.change') }}
|
||||
</BaseButton>
|
||||
</DialogClose>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineEmits(['change']);
|
||||
const props = defineProps<{
|
||||
triggerClass?: string;
|
||||
ipv4Cidr: string;
|
||||
ipv6Cidr: string;
|
||||
}>();
|
||||
|
||||
const ipv4Cidr = ref(props.ipv4Cidr);
|
||||
const ipv6Cidr = ref(props.ipv6Cidr);
|
||||
</script>
|
||||
20
src/app/components/Base/Avatar.vue
Normal file
20
src/app/components/Base/Avatar.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<AvatarRoot
|
||||
class="mr-2 inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle"
|
||||
>
|
||||
<AvatarImage
|
||||
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>
|
||||
26
src/app/components/Base/Button.vue
Normal file
26
src/app/components/Base/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<component
|
||||
:is="elementType"
|
||||
role="button"
|
||||
class="inline-flex items-center rounded border-2 border-gray-100 px-4 py-2 text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white dark:border-neutral-600 dark:text-neutral-200"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
as: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
});
|
||||
|
||||
const elementType = computed(() => props.as);
|
||||
|
||||
const attrs = computed(() => {
|
||||
const { as, ...attrs } = props;
|
||||
return attrs;
|
||||
});
|
||||
</script>
|
||||
20
src/app/components/Base/Chart.vue
Normal file
20
src/app/components/Base/Chart.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<apexchart
|
||||
width="100%"
|
||||
height="100%"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
:series="series"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VueApexChartsComponent } from 'vue3-apexcharts';
|
||||
|
||||
defineProps<{
|
||||
options: VueApexChartsComponent['options'];
|
||||
series: VueApexChartsComponent['series'];
|
||||
}>();
|
||||
</script>
|
||||
31
src/app/components/Base/Dialog.vue
Normal file
31
src/app/components/Base/Dialog.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<DialogRoot :modal="true">
|
||||
<DialogTrigger :class="triggerClass"><slot name="trigger" /></DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
|
||||
/>
|
||||
<DialogContent
|
||||
class="fixed left-1/2 top-1/2 z-[100] max-h-[85vh] w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md p-6 shadow-2xl focus:outline-none dark:bg-neutral-700"
|
||||
>
|
||||
<DialogTitle
|
||||
class="m-0 text-lg font-semibold text-gray-900 dark:text-neutral-200"
|
||||
>
|
||||
<slot name="title" />
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
class="mb-5 mt-2 text-sm leading-normal text-gray-500 dark:text-neutral-300"
|
||||
>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ triggerClass?: string }>();
|
||||
</script>
|
||||
10
src/app/components/Base/Input.vue
Normal file
10
src/app/components/Base/Input.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<input
|
||||
v-model="data"
|
||||
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const data = defineModel<unknown>();
|
||||
</script>
|
||||
17
src/app/components/Base/Switch.vue
Normal file
17
src/app/components/Base/Switch.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
:id="id"
|
||||
v-model:checked="data"
|
||||
:name="id"
|
||||
class="relative flex h-6 w-10 cursor-default rounded-full bg-gray-200 shadow-sm focus-within:outline focus-within:outline-red-700 data-[state=checked]:bg-red-800 dark:bg-neutral-400"
|
||||
>
|
||||
<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>
|
||||
46
src/app/components/Base/Toast.vue
Normal file
46
src/app/components/Base/Toast.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ToastRoot
|
||||
v-for="(e, i) in count"
|
||||
:key="i"
|
||||
:class="[
|
||||
`grid grid-cols-[auto_max-content] items-center gap-x-3 rounded-md p-3 text-neutral-200 shadow-lg [grid-template-areas:_'title_action'_'description_action'] data-[swipe=cancel]:translate-x-0 data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]`,
|
||||
{
|
||||
'bg-green-800': e.type === 'success',
|
||||
'bg-red-800': e.type === 'error',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ToastTitle class="mb-1 text-sm font-medium [grid-area:_title]">
|
||||
{{ e.title }}
|
||||
</ToastTitle>
|
||||
<ToastDescription class="m-0 text-sm [grid-area:_description]">{{
|
||||
e.message
|
||||
}}</ToastDescription>
|
||||
<ToastAction as-child alt-text="toast" class="[grid-area:_action]">
|
||||
<slot />
|
||||
</ToastAction>
|
||||
<ToastClose aria-label="Close">
|
||||
<span aria-hidden>×</span>
|
||||
</ToastClose>
|
||||
</ToastRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastRoot,
|
||||
ToastTitle,
|
||||
} from 'radix-vue';
|
||||
|
||||
defineExpose({
|
||||
publish,
|
||||
});
|
||||
|
||||
const count = reactive<ToastParams[]>([]);
|
||||
|
||||
function publish(e: ToastParams) {
|
||||
count.push({ type: e.type, title: e.title, message: e.message });
|
||||
}
|
||||
</script>
|
||||
24
src/app/components/Base/Tooltip.vue
Normal file
24
src/app/components/Base/Tooltip.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-400 outline-none focus:shadow-sm focus:shadow-black"
|
||||
>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
class="select-none rounded bg-gray-600 px-3 py-2 text-sm leading-none text-white shadow-lg will-change-[transform,opacity]"
|
||||
:side-offset="5"
|
||||
>
|
||||
{{ text }}
|
||||
<TooltipArrow class="fill-gray-600" :width="8" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ text: string }>();
|
||||
</script>
|
||||
11
src/app/components/ClientCard/Address.vue
Normal file
11
src/app/components/ClientCard/Address.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<span class="inline-block">
|
||||
{{ client.ipv4Address }}, {{ client.ipv6Address }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
30
src/app/components/ClientCard/Avatar.vue
Normal file
30
src/app/components/ClientCard/Avatar.vue
Normal file
@@ -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="
|
||||
isPeerConnected({
|
||||
latestHandshakeAt: client.latestHandshakeAt
|
||||
? new Date(client.latestHandshakeAt)
|
||||
: null,
|
||||
})
|
||||
"
|
||||
>
|
||||
<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">
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
136
src/app/components/ClientCard/Charts.vue
Normal file
136
src/app/components/ClientCard/Charts.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
:class="`absolute bottom-0 left-0 right-0 z-0 h-6 ${globalStore.uiChartType === 'line' && 'line-chart'}`"
|
||||
>
|
||||
<BaseChart :options="chartOptionsTX" :series="client.transferTxSeries" />
|
||||
</div>
|
||||
<div
|
||||
:class="`absolute left-0 right-0 top-0 z-0 h-6 ${globalStore.uiChartType === 'line' && 'line-chart'}`"
|
||||
>
|
||||
<BaseChart
|
||||
:options="chartOptionsRX"
|
||||
:series="client.transferRxSeries"
|
||||
style="transform: scaleY(-1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApexOptions } from 'apexcharts';
|
||||
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
|
||||
const globalStore = useGlobalStore();
|
||||
const theme = useTheme();
|
||||
|
||||
const chartOptionsTX = computed(() => {
|
||||
const opts = {
|
||||
...chartOptions,
|
||||
colors: [CHART_COLORS.tx[theme.value]],
|
||||
};
|
||||
opts.chart.type = globalStore.uiChartType;
|
||||
opts.stroke.width = UI_CHART_PROPS[globalStore.uiChartType].strokeWidth;
|
||||
return opts;
|
||||
});
|
||||
|
||||
const chartOptionsRX = computed(() => {
|
||||
const opts = {
|
||||
...chartOptions,
|
||||
colors: [CHART_COLORS.rx[theme.value]],
|
||||
};
|
||||
opts.chart.type = globalStore.uiChartType;
|
||||
opts.stroke.width = UI_CHART_PROPS[globalStore.uiChartType].strokeWidth;
|
||||
return opts;
|
||||
});
|
||||
|
||||
const chartOptions = {
|
||||
chart: {
|
||||
type: undefined as ApexChart['type'],
|
||||
background: 'transparent',
|
||||
stacked: false,
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
animations: {
|
||||
enabled: false,
|
||||
},
|
||||
parentHeightOffset: 0,
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
colors: [],
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 0,
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shade: 'dark',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: CHART_COLORS.gradient[theme.value],
|
||||
inverseColors: false,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
min: 0,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
padding: {
|
||||
left: -10,
|
||||
right: 0,
|
||||
bottom: -15,
|
||||
top: -15,
|
||||
},
|
||||
column: {
|
||||
opacity: 0,
|
||||
},
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ApexOptions;
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
.line-chart .apexcharts-svg {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
</style>
|
||||
51
src/app/components/ClientCard/ClientCard.vue
Normal file
51
src/app/components/ClientCard/ClientCard.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<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="flex flex-col pb-1 text-xs text-gray-500 md:inline-block md:pb-0 dark:text-neutral-400"
|
||||
>
|
||||
<div>
|
||||
<ClientCardAddress :client="client" />
|
||||
</div>
|
||||
<div>
|
||||
<ClientCardLastSeen :client="client" />
|
||||
</div>
|
||||
</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
Normal file
16
src/app/components/ClientCard/Config.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<a
|
||||
:href="'/api/client/' + client.id + '/configuration'"
|
||||
download
|
||||
class="inline-block rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white"
|
||||
:title="$t('client.downloadConfig')"
|
||||
>
|
||||
<IconsDownload class="w-5" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
14
src/app/components/ClientCard/Edit.vue
Normal file
14
src/app/components/ClientCard/Edit.vue
Normal file
@@ -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
Normal file
23
src/app/components/ClientCard/ExpireDate.vue
Normal file
@@ -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('client.permanent');
|
||||
const dateTime = new Date(value);
|
||||
return dateTime.toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
16
src/app/components/ClientCard/LastSeen.vue
Normal file
16
src/app/components/ClientCard/LastSeen.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="client.latestHandshakeAt"
|
||||
:title="$t('client.lastSeen') + $d(new Date(client.latestHandshakeAt))"
|
||||
>
|
||||
{{ timeago(new Date(client.latestHandshakeAt)) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format as timeago } from 'timeago.js';
|
||||
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
16
src/app/components/ClientCard/Name.vue
Normal file
16
src/app/components/ClientCard/Name.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-sm text-gray-700 md:text-base dark:text-neutral-200"
|
||||
:title="$t('client.createdOn') + $d(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>
|
||||
51
src/app/components/ClientCard/OneTimeLink.vue
Normal file
51
src/app/components/ClientCard/OneTimeLink.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<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 = ref('Loading...');
|
||||
const timer = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { localeProperties } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
timer.value = setIntervalImmediately(() => {
|
||||
if (props.client.oneTimeLink === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeLeft =
|
||||
new Date(props.client.oneTimeLink.expiresAt).getTime() - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
path.value = `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink.oneTimeLink} (00:00)`;
|
||||
return;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(localeProperties.value.language, {
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
const minutes = Math.floor(timeLeft / 60000);
|
||||
const seconds = Math.floor((timeLeft % 60000) / 1000);
|
||||
|
||||
const date = new Date(0);
|
||||
date.setMinutes(minutes);
|
||||
date.setSeconds(seconds);
|
||||
|
||||
path.value = `${document.location.protocol}//${document.location.host}/cnf/${props.client.oneTimeLink.oneTimeLink} (${formatter.format(date)})`;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
32
src/app/components/ClientCard/OneTimeLinkBtn.vue
Normal file
32
src/app/components/ClientCard/OneTimeLinkBtn.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-block rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white"
|
||||
:title="$t('client.otlDesc')"
|
||||
@click="showOneTimeLink"
|
||||
>
|
||||
<IconsLink class="w-5" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ client: LocalClient }>();
|
||||
|
||||
const clientsStore = useClientsStore();
|
||||
|
||||
const _showOneTimeLink = useSubmit(
|
||||
`/api/client/${props.client.id}/generateOneTimeLink`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await clientsStore.refresh();
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
function showOneTimeLink() {
|
||||
return _showOneTimeLink(undefined);
|
||||
}
|
||||
</script>
|
||||
16
src/app/components/ClientCard/QRCode.vue
Normal file
16
src/app/components/ClientCard/QRCode.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ClientsQRCodeDialog :qr-code="`./api/client/${client.id}/qrcode.svg`">
|
||||
<div
|
||||
class="rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white"
|
||||
:title="$t('client.showQR')"
|
||||
>
|
||||
<IconsQRCode class="w-5" />
|
||||
</div>
|
||||
</ClientsQRCodeDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
53
src/app/components/ClientCard/Switch.vue
Normal file
53
src/app/components/ClientCard/Switch.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
:title="
|
||||
client.enabled ? $t('client.disableClient') : $t('client.enableClient')
|
||||
"
|
||||
@click="toggleClient"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
|
||||
const enabled = ref(props.client.enabled);
|
||||
|
||||
const clientsStore = useClientsStore();
|
||||
|
||||
const _disableClient = useSubmit(
|
||||
`/api/client/${props.client.id}/disable`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await clientsStore.refresh();
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
const _enableClient = useSubmit(
|
||||
`/api/client/${props.client.id}/enable`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await clientsStore.refresh();
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
async function toggleClient() {
|
||||
if (props.client.enabled) {
|
||||
await _disableClient(undefined);
|
||||
} else {
|
||||
await _enableClient(undefined);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
45
src/app/components/ClientCard/Transfer.vue
Normal file
45
src/app/components/ClientCard/Transfer.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<!-- Transfer TX -->
|
||||
<div v-if="client.transferTx" class="min-w-20 md:min-w-24">
|
||||
<span
|
||||
class="flex gap-1"
|
||||
:title="$t('client.totalDownload') + bytes(client.transferTx)"
|
||||
>
|
||||
<IconsArrowDown class="mt-0.5 inline h-3 align-middle" />
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-neutral-200"
|
||||
>{{ bytes(client.transferTxCurrent) }}/s</span
|
||||
>
|
||||
<!-- Total TX -->
|
||||
<br /><span class="font-regular" style="font-size: 0.85em">{{
|
||||
bytes(client.transferTx)
|
||||
}}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Transfer RX -->
|
||||
<div v-if="client.transferRx" class="min-w-20 md:min-w-24">
|
||||
<span
|
||||
class="flex gap-1"
|
||||
:title="$t('client.totalUpload') + bytes(client.transferRx)"
|
||||
>
|
||||
<IconsArrowUp class="mt-0.5 inline h-3 align-middle" />
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-neutral-200"
|
||||
>{{ bytes(client.transferRxCurrent) }}/s</span
|
||||
>
|
||||
<!-- Total RX -->
|
||||
<br /><span class="font-regular" style="font-size: 0.85em">{{
|
||||
bytes(client.transferRx)
|
||||
}}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
client: LocalClient;
|
||||
}>();
|
||||
</script>
|
||||
53
src/app/components/Clients/CreateDialog.vue
Normal file
53
src/app/components/Clients/CreateDialog.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<BaseDialog :trigger-class="triggerClass">
|
||||
<template #trigger>
|
||||
<slot />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ $t('client.new') }}
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="flex flex-col">
|
||||
<FormTextField id="name" v-model="name" :label="$t('client.name')" />
|
||||
<FormDateField
|
||||
id="expiresAt"
|
||||
v-model="expiresAt"
|
||||
:label="$t('client.expireDate')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<DialogClose as-child>
|
||||
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
|
||||
</DialogClose>
|
||||
<DialogClose as-child>
|
||||
<BaseButton @click="createClient">{{ $t('client.create') }}</BaseButton>
|
||||
</DialogClose>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const name = ref<string>('');
|
||||
const expiresAt = ref<string | null>(null);
|
||||
const clientsStore = useClientsStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{ triggerClass?: string }>();
|
||||
|
||||
function createClient() {
|
||||
return _createClient({ name: name.value, expiresAt: expiresAt.value });
|
||||
}
|
||||
|
||||
const _createClient = useSubmit(
|
||||
'/api/client',
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: () => clientsStore.refresh(),
|
||||
successMsg: t('client.created'),
|
||||
}
|
||||
);
|
||||
</script>
|
||||
26
src/app/components/Clients/DeleteDialog.vue
Normal file
26
src/app/components/Clients/DeleteDialog.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<BaseDialog :trigger-class="triggerClass">
|
||||
<template #trigger><slot /></template>
|
||||
<template #title>{{ $t('client.deleteClient') }}</template>
|
||||
<template #description>
|
||||
{{ $t('client.deleteDialog1') }}
|
||||
<strong>{{ clientName }}</strong
|
||||
>? {{ $t('client.deleteDialog2') }}
|
||||
</template>
|
||||
<template #actions>
|
||||
<DialogClose as-child>
|
||||
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
|
||||
</DialogClose>
|
||||
<DialogClose as-child>
|
||||
<BaseButton @click="$emit('delete')">{{
|
||||
$t('client.deleteClient')
|
||||
}}</BaseButton>
|
||||
</DialogClose>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineEmits(['delete']);
|
||||
defineProps<{ triggerClass?: string; clientName: string }>();
|
||||
</script>
|
||||
11
src/app/components/Clients/Empty.vue
Normal file
11
src/app/components/Clients/Empty.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<p class="m-10 text-center text-sm text-gray-400 dark:text-neutral-400">
|
||||
{{ $t('client.empty') }}<br /><br />
|
||||
<ClientsCreateDialog>
|
||||
<BaseButton as="span">
|
||||
<IconsPlus class="w-4 md:mr-2" />
|
||||
<span class="text-sm">{{ $t('client.new') }}</span>
|
||||
</BaseButton>
|
||||
</ClientsCreateDialog>
|
||||
</p>
|
||||
</template>
|
||||
13
src/app/components/Clients/List.vue
Normal file
13
src/app/components/Clients/List.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="client in clientsStore.clients"
|
||||
:key="client.id"
|
||||
class="relative overflow-hidden border-b border-solid border-gray-100 last:border-b-0 dark:border-neutral-600"
|
||||
>
|
||||
<ClientCard :client="client" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const clientsStore = useClientsStore();
|
||||
</script>
|
||||
8
src/app/components/Clients/New.vue
Normal file
8
src/app/components/Clients/New.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ClientsCreateDialog>
|
||||
<BaseButton as="span">
|
||||
<IconsPlus class="w-4 md:mr-2" />
|
||||
<span class="text-sm max-md:hidden">{{ $t('client.newShort') }}</span>
|
||||
</BaseButton>
|
||||
</ClientsCreateDialog>
|
||||
</template>
|
||||
19
src/app/components/Clients/QRCodeDialog.vue
Normal file
19
src/app/components/Clients/QRCodeDialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<BaseDialog>
|
||||
<template #trigger>
|
||||
<slot />
|
||||
</template>
|
||||
<template #description>
|
||||
<img :src="qrCode" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<DialogClose>
|
||||
<BaseButton>{{ $t('dialog.cancel') }}</BaseButton>
|
||||
</DialogClose>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ qrCode: string }>();
|
||||
</script>
|
||||
20
src/app/components/Clients/Sort.vue
Normal file
20
src/app/components/Clients/Sort.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<BaseButton @click="toggleSort">
|
||||
<IconsArrowDown
|
||||
v-if="globalStore.sortClient === true"
|
||||
class="w-4 md:mr-2"
|
||||
/>
|
||||
<IconsArrowUp v-else class="w-4 md:mr-2" />
|
||||
<span class="text-sm max-md:hidden"> {{ $t('client.sort') }}</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const globalStore = useGlobalStore();
|
||||
const clientsStore = useClientsStore();
|
||||
|
||||
function toggleSort() {
|
||||
globalStore.sortClient = !globalStore.sortClient;
|
||||
clientsStore.refresh().catch(console.error);
|
||||
}
|
||||
</script>
|
||||
16
src/app/components/Form/ActionField.vue
Normal file
16
src/app/components/Form/ActionField.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<input
|
||||
:value="label"
|
||||
:type="type ?? 'button'"
|
||||
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 hover:border-red-800 hover:bg-red-800 hover:text-white focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { InputTypeHTMLAttribute } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
type?: InputTypeHTMLAttribute;
|
||||
}>();
|
||||
</script>
|
||||
51
src/app/components/Form/ArrayField.vue
Normal file
51
src/app/components/Form/ArrayField.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div v-if="data?.length === 0">
|
||||
{{ emptyText || $t('form.noItems') }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="(item, i) in data" :key="i">
|
||||
<div class="flex flex-row gap-1">
|
||||
<input
|
||||
:value="item"
|
||||
:name="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"
|
||||
@input="update($event, i)"
|
||||
/>
|
||||
<BaseButton as="input" type="button" value="-" @click="del(i)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<BaseButton
|
||||
as="input"
|
||||
type="button"
|
||||
:value="$t('form.add')"
|
||||
@click="add"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const data = defineModel<string[]>();
|
||||
defineProps<{ emptyText?: string[]; name: string }>();
|
||||
|
||||
function update(e: Event, i: number) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
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>
|
||||
25
src/app/components/Form/DateField.vue
Normal file
25
src/app/components/Form/DateField.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</div>
|
||||
<BaseInput :id="id" v-model="data" :name="id" type="date" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ id: string; label: string; description?: string }>();
|
||||
|
||||
const data = defineModel<string | null>({
|
||||
set(value) {
|
||||
const temp = value?.trim() ?? null;
|
||||
if (temp === '') {
|
||||
return null;
|
||||
}
|
||||
return temp;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
5
src/app/components/Form/Element.vue
Normal file
5
src/app/components/Form/Element.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<form>
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
9
src/app/components/Form/Group.vue
Normal file
9
src/app/components/Form/Group.vue
Normal file
@@ -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>
|
||||
12
src/app/components/Form/Heading.vue
Normal file
12
src/app/components/Form/Heading.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<h4 class="col-span-full flex items-center py-6 text-2xl">
|
||||
<slot />
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</h4>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ description?: string }>();
|
||||
</script>
|
||||
11
src/app/components/Form/Label.vue
Normal file
11
src/app/components/Form/Label.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<RLabel :for="props.for" class="md:align-middle md:leading-10"
|
||||
><slot
|
||||
/></RLabel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Label as RLabel } from 'radix-vue';
|
||||
|
||||
const props = defineProps<{ for: string }>();
|
||||
</script>
|
||||
38
src/app/components/Form/NullTextField.vue
Normal file
38
src/app/components/Form/NullTextField.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</div>
|
||||
<BaseInput
|
||||
:id="id"
|
||||
v-model.trim="data"
|
||||
:name="id"
|
||||
type="text"
|
||||
:autcomplete="autocomplete"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
autocomplete?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const data = defineModel<string | null>({
|
||||
set(value) {
|
||||
const temp = value?.trim() ?? null;
|
||||
if (temp === '') {
|
||||
return null;
|
||||
}
|
||||
return temp;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
17
src/app/components/Form/NumberField.vue
Normal file
17
src/app/components/Form/NumberField.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</div>
|
||||
<BaseInput :id="id" v-model.number="data" :name="id" type="number" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ id: string; label: string; description?: string }>();
|
||||
|
||||
const data = defineModel<number>();
|
||||
</script>
|
||||
18
src/app/components/Form/PasswordField.vue
Normal file
18
src/app/components/Form/PasswordField.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseInput
|
||||
:id="id"
|
||||
v-model.trim="data"
|
||||
:name="id"
|
||||
type="password"
|
||||
:autocomplete="autocomplete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ id: string; label: string; autocomplete: string }>();
|
||||
|
||||
const data = defineModel<string>();
|
||||
</script>
|
||||
16
src/app/components/Form/SwitchField.vue
Normal file
16
src/app/components/Form/SwitchField.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</div>
|
||||
<BaseSwitch :id="id" v-model="data" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ id: string; label: string; description?: string }>();
|
||||
const data = defineModel<boolean>();
|
||||
</script>
|
||||
28
src/app/components/Form/TextField.vue
Normal file
28
src/app/components/Form/TextField.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<FormLabel :for="id">
|
||||
{{ label }}
|
||||
</FormLabel>
|
||||
<BaseTooltip v-if="description" :text="description">
|
||||
<IconsInfo class="size-4" />
|
||||
</BaseTooltip>
|
||||
</div>
|
||||
<BaseInput
|
||||
:id="id"
|
||||
v-model.trim="data"
|
||||
:name="id"
|
||||
type="text"
|
||||
:autcomplete="autocomplete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
autocomplete?: string;
|
||||
}>();
|
||||
|
||||
const data = defineModel<string>();
|
||||
</script>
|
||||
16
src/app/components/Header/ChartToggle.vue
Normal file
16
src/app/components/Header/ChartToggle.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<Toggle
|
||||
: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('layout.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>
|
||||
44
src/app/components/Header/LangSelector.vue
Normal file
44
src/app/components/Header/LangSelector.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<SelectRoot v-model="langProxy" :default-value="locale">
|
||||
<SelectTrigger
|
||||
class="inline-flex h-8 items-center justify-around gap-2 rounded bg-gray-200 px-3 text-sm leading-none dark:bg-neutral-700 dark:text-neutral-400"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<IconsLanguage class="size-3" />
|
||||
<SelectValue />
|
||||
<IconsArrowDown class="size-3" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
class="min-w-28 rounded bg-gray-300 dark:bg-neutral-500"
|
||||
position="popper"
|
||||
>
|
||||
<SelectViewport class="p-2">
|
||||
<SelectItem
|
||||
v-for="(option, index) in langs"
|
||||
:key="index"
|
||||
:value="option.code"
|
||||
class="relative flex h-6 items-center rounded px-3 text-sm leading-none outline-none hover:bg-red-800 hover:text-white data-[state=checked]:underline dark:text-white"
|
||||
>
|
||||
<SelectItemText>
|
||||
{{ option.name }}
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { locales, locale, setLocale } = useI18n();
|
||||
|
||||
const langProxy = ref(locale);
|
||||
|
||||
watchEffect(() => {
|
||||
setLocale(langProxy.value);
|
||||
});
|
||||
|
||||
const langs = locales.value.sort((a, b) => a.code.localeCompare(b.code));
|
||||
</script>
|
||||
11
src/app/components/Header/Logo.vue
Normal file
11
src/app/components/Header/Logo.vue
Normal file
@@ -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
Normal file
28
src/app/components/Header/ThemeSwitch.vue
Normal file
@@ -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>
|
||||
28
src/app/components/Header/Update.vue
Normal file
28
src/app/components/Header/Update.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="globalStore.release?.updateAvailable"
|
||||
class="font-small mb-10 rounded-md bg-red-800 p-4 text-sm text-white shadow-lg dark:bg-red-100 dark:text-red-600"
|
||||
:title="`v${globalStore.release.currentRelease} → v${globalStore.release.latestRelease.version}`"
|
||||
>
|
||||
<div class="container mx-auto flex flex-auto flex-row items-center">
|
||||
<div class="flex-grow">
|
||||
<p class="font-bold">{{ $t('update.updateAvailable') }}</p>
|
||||
<p>{{ globalStore.release.latestRelease.changelog }}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="`https://github.com/wg-easy/wg-easy/releases/tag/${globalStore.release.latestRelease.version}`"
|
||||
target="_blank"
|
||||
class="font-sm float-right flex-shrink-0 rounded-md border-2 border-red-800 bg-white p-3 font-semibold text-red-800 transition-all hover:border-white hover:bg-red-800 hover:text-white dark:border-red-600 dark:bg-red-100 dark:text-red-600 dark:hover:border-red-600 dark:hover:bg-red-600 dark:hover:text-red-100"
|
||||
>
|
||||
{{ $t('update.update') }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const globalStore = useGlobalStore();
|
||||
|
||||
// TODO: only show this to admins
|
||||
</script>
|
||||
13
src/app/components/Icons/ArrowDown.vue
Normal file
13
src/app/components/Icons/ArrowDown.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
16
src/app/components/Icons/ArrowInf.vue
Normal file
16
src/app/components/Icons/ArrowInf.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
inline
|
||||
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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/ArrowLeftCircle.vue
Normal file
15
src/app/components/Icons/ArrowLeftCircle.vue
Normal file
@@ -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
Normal file
15
src/app/components/Icons/ArrowRightCircle.vue
Normal file
@@ -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>
|
||||
13
src/app/components/Icons/ArrowUp.vue
Normal file
13
src/app/components/Icons/ArrowUp.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
13
src/app/components/Icons/Avatar.vue
Normal file
13
src/app/components/Icons/Avatar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
12
src/app/components/Icons/Chart.vue
Normal file
12
src/app/components/Icons/Chart.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M18.375 2.25c-1.035 0-1.875.84-1.875 1.875v15.75c0 1.035.84 1.875 1.875 1.875h.75c1.035 0 1.875-.84 1.875-1.875V4.125c0-1.036-.84-1.875-1.875-1.875h-.75ZM9.75 8.625c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-.75a1.875 1.875 0 0 1-1.875-1.875V8.625ZM3 13.125c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v6.75c0 1.035-.84 1.875-1.875 1.875h-.75A1.875 1.875 0 0 1 3 19.875v-6.75Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/CheckCircle.vue
Normal file
15
src/app/components/Icons/CheckCircle.vue
Normal file
@@ -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>
|
||||
15
src/app/components/Icons/Close.vue
Normal file
15
src/app/components/Icons/Close.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
13
src/app/components/Icons/Delete.vue
Normal file
13
src/app/components/Icons/Delete.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Download.vue
Normal file
15
src/app/components/Icons/Download.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Edit.vue
Normal file
15
src/app/components/Icons/Edit.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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>
|
||||
</template>
|
||||
11
src/app/components/Icons/HalfMoon.vue
Normal file
11
src/app/components/Icons/HalfMoon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12,2.2c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S17.4,2.2,12,2.2z M3.8,12c0-4.5,3.7-8.2,8.2-8.2v16.5C7.5,20.2,3.8,16.5,3.8,12z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Info.vue
Normal file
15
src/app/components/Icons/Info.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Language.vue
Normal file
15
src/app/components/Icons/Language.vue
Normal file
@@ -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="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Link.vue
Normal file
15
src/app/components/Icons/Link.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
21
src/app/components/Icons/Loading.vue
Normal file
21
src/app/components/Icons/Loading.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Logout.vue
Normal file
15
src/app/components/Icons/Logout.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Moon.vue
Normal file
15
src/app/components/Icons/Moon.vue
Normal file
@@ -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="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
16
src/app/components/Icons/Plus.vue
Normal file
16
src/app/components/Icons/Plus.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
inline
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/QRCode.vue
Normal file
15
src/app/components/Icons/QRCode.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
16
src/app/components/Icons/Stack.vue
Normal file
16
src/app/components/Icons/Stack.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
inline
|
||||
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="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
src/app/components/Icons/Sun.vue
Normal file
15
src/app/components/Icons/Sun.vue
Normal file
@@ -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 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
17
src/app/components/Icons/Warning.vue
Normal file
17
src/app/components/Icons/Warning.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<!-- Heroicon name: outline/exclamation -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
5
src/app/components/Panel/Body.vue
Normal file
5
src/app/components/Panel/Body.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
src/app/components/Panel/Panel.vue
Normal file
7
src/app/components/Panel/Panel.vue
Normal file
@@ -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
Normal file
5
src/app/components/Panel/head/Boat.vue
Normal file
@@ -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
Normal file
7
src/app/components/Panel/head/Head.vue
Normal file
@@ -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
Normal file
11
src/app/components/Panel/head/Title.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<h2 class="flex-1 text-2xl font-medium">
|
||||
{{ text }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { text } = defineProps<{
|
||||
text: string;
|
||||
}>();
|
||||
</script>
|
||||
8
src/app/components/Ui/Banner.vue
Normal file
8
src/app/components/Ui/Banner.vue
Normal file
@@ -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>
|
||||
37
src/app/components/Ui/Footer.vue
Normal file
37
src/app/components/Ui/Footer.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<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.release?.currentRelease }}) © 2021-2025 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://opensource.org/license/agpl-v3"
|
||||
>AGPL-3.0-only</a
|
||||
>
|
||||
·
|
||||
<a
|
||||
class="hover:underline"
|
||||
href="https://github.com/sponsors/WeeJeWel"
|
||||
target="_blank"
|
||||
>{{ $t('layout.donate') }}</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const globalStore = useGlobalStore();
|
||||
</script>
|
||||
23
src/app/components/Ui/StepProgress.vue
Normal file
23
src/app/components/Ui/StepProgress.vue
Normal file
@@ -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>
|
||||
96
src/app/components/Ui/UserMenu.vue
Normal file
96
src/app/components/Ui/UserMenu.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="toggleState">
|
||||
<DropdownMenuTrigger>
|
||||
<span
|
||||
class="flex items-center rounded-full pe-1 text-sm font-medium text-gray-400 hover:text-red-800 focus:ring-4 focus:ring-gray-100 md:me-0 dark:text-neutral-400 dark:hover:text-red-800 dark:focus:ring-gray-700"
|
||||
>
|
||||
<BaseAvatar class="h-8 w-8">
|
||||
{{ fallbackName }}
|
||||
</BaseAvatar>
|
||||
{{ authStore.userData?.name }}
|
||||
</span>
|
||||
</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="px-4 py-2">
|
||||
<div class="truncate">{{ authStore.userData?.name }}</div>
|
||||
<div class="truncate">@{{ authStore.userData?.username }}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
{{ $t('pages.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"
|
||||
>
|
||||
{{ $t('pages.me') }}
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="
|
||||
authStore.userData &&
|
||||
hasPermissions(authStore.userData, 'admin', 'any')
|
||||
"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/admin"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
{{ $t('pages.admin.panel') }}
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
@click.prevent="submit"
|
||||
>
|
||||
<IconsLogout class="h-5" />
|
||||
{{ $t('general.logout') }}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore();
|
||||
const toggleState = ref(false);
|
||||
|
||||
const _submit = useSubmit(
|
||||
'/api/session',
|
||||
{
|
||||
method: 'delete',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await navigateTo('/login');
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit(undefined);
|
||||
}
|
||||
|
||||
const fallbackName = computed(() => {
|
||||
return authStore.userData?.name
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
});
|
||||
</script>
|
||||
6
src/app/composables/useColorMode.ts
Normal file
6
src/app/composables/useColorMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const useTheme = useColorMode as () => ThemeInstance;
|
||||
|
||||
type ThemeInstance = ReturnType<typeof useColorMode> & {
|
||||
preference: 'system' | 'dark' | 'light';
|
||||
value: 'dark' | 'light';
|
||||
};
|
||||
57
src/app/composables/useSubmit.ts
Normal file
57
src/app/composables/useSubmit.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types';
|
||||
import { FetchError } from 'ofetch';
|
||||
|
||||
type RevertFn = (success: boolean) => Promise<void>;
|
||||
|
||||
type SubmitOpts = {
|
||||
revert: RevertFn;
|
||||
successMsg?: string;
|
||||
errorMsg?: string;
|
||||
noSuccessToast?: boolean;
|
||||
};
|
||||
|
||||
export function useSubmit<
|
||||
R extends NitroFetchRequest,
|
||||
O extends NitroFetchOptions<R> & { body?: never },
|
||||
>(url: R, options: O, opts: SubmitOpts) {
|
||||
const toast = useToast();
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
return async (data: unknown) => {
|
||||
try {
|
||||
const res = await $fetch(url, {
|
||||
...options,
|
||||
body: data,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(res as any).success) {
|
||||
throw new Error(opts.errorMsg || $t('toast.errored'));
|
||||
}
|
||||
|
||||
if (!opts.noSuccessToast) {
|
||||
toast.showToast({
|
||||
type: 'success',
|
||||
message: opts.successMsg,
|
||||
});
|
||||
}
|
||||
|
||||
await opts.revert(true);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
toast.showToast({
|
||||
type: 'error',
|
||||
message: e.data.message,
|
||||
});
|
||||
} else if (e instanceof Error) {
|
||||
toast.showToast({
|
||||
type: 'error',
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
await opts.revert(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
31
src/app/layouts/default.vue
Normal file
31
src/app/layouts/default.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<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="
|
||||
loggedIn
|
||||
? 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
|
||||
: 'flex justify-end'
|
||||
"
|
||||
>
|
||||
<HeaderLogo v-if="loggedIn" />
|
||||
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
|
||||
<HeaderLangSelector />
|
||||
<HeaderThemeSwitch />
|
||||
<HeaderChartToggle v-if="loggedIn" />
|
||||
<UiUserMenu v-if="loggedIn" />
|
||||
</div>
|
||||
</div>
|
||||
<HeaderUpdate class="mt-5" />
|
||||
</header>
|
||||
<slot />
|
||||
<UiFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
|
||||
const loggedIn = computed(() => route.path !== '/login');
|
||||
</script>
|
||||
36
src/app/layouts/setup.vue
Normal file
36
src/app/layouts/setup.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<header class="container mx-auto mt-4 max-w-3xl px-3 xs:mt-6 md:px-0">
|
||||
<div class="mb-5 flex justify-end">
|
||||
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
|
||||
<HeaderLangSelector />
|
||||
<HeaderThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<UiBanner />
|
||||
</header>
|
||||
<main>
|
||||
<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>
|
||||
</main>
|
||||
<UiFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const setupStore = useSetupStore();
|
||||
</script>
|
||||
29
src/app/middleware/auth.global.ts
Normal file
29
src/app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// api & setup handled server side
|
||||
if (to.path.startsWith('/api/') || to.path.startsWith('/setup')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const userData = await authStore.getSession();
|
||||
|
||||
// skip login if already logged in
|
||||
if (to.path === '/login') {
|
||||
if (userData?.username) {
|
||||
return navigateTo('/', { redirectCode: 302 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Require auth for every page other than Login
|
||||
if (!userData?.username) {
|
||||
return navigateTo('/login', { redirectCode: 302 });
|
||||
}
|
||||
|
||||
// Check for admin access
|
||||
if (to.path.startsWith('/admin')) {
|
||||
if (!hasPermissions(userData, 'admin', 'any')) {
|
||||
return abortNavigation('Not allowed to access Admin Panel');
|
||||
}
|
||||
}
|
||||
});
|
||||
56
src/app/pages/admin.vue
Normal file
56
src/app/pages/admin.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<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">
|
||||
{{ t('pages.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
|
||||
as="span"
|
||||
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 { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const menuItems = [
|
||||
{ id: '', name: t('pages.admin.general') },
|
||||
{ id: 'config', name: t('pages.admin.config') },
|
||||
{ id: 'interface', name: t('pages.admin.interface') },
|
||||
{ id: 'hooks', name: t('pages.admin.hooks') },
|
||||
];
|
||||
|
||||
const activeMenuItem = computed(() => {
|
||||
return menuItems.find((item) => route.path === `/admin/${item.id}`);
|
||||
});
|
||||
</script>
|
||||
81
src/app/pages/admin/config.vue
Normal file
81
src/app/pages/admin/config.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<main v-if="data">
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('admin.config.connection') }}</FormHeading>
|
||||
<FormTextField
|
||||
id="host"
|
||||
v-model="data.host"
|
||||
:label="$t('general.host')"
|
||||
:description="$t('admin.config.hostDesc')"
|
||||
/>
|
||||
<FormNumberField
|
||||
id="port"
|
||||
v-model="data.port"
|
||||
:label="$t('general.port')"
|
||||
:description="$t('admin.config.portDesc')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading :description="$t('admin.config.allowedIpsDesc')">{{
|
||||
$t('general.allowedIps')
|
||||
}}</FormHeading>
|
||||
<FormArrayField
|
||||
v-model="data.defaultAllowedIps"
|
||||
name="defaultAllowedIps"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading :description="$t('admin.config.dnsDesc')">{{
|
||||
$t('admin.config.dns')
|
||||
}}</FormHeading>
|
||||
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.sectionAdvanced') }}</FormHeading>
|
||||
<FormNumberField
|
||||
id="defaultMtu"
|
||||
v-model="data.defaultMtu"
|
||||
:label="$t('general.mtu')"
|
||||
:description="$t('admin.config.mtuDesc')"
|
||||
/>
|
||||
<FormNumberField
|
||||
id="defaultPersistentKeepalive"
|
||||
v-model="data.defaultPersistentKeepalive"
|
||||
:label="$t('general.persistentKeepalive')"
|
||||
:description="$t('admin.config.persistentKeepaliveDesc')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.actions') }}</FormHeading>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
<FormActionField :label="$t('form.revert')" @click="revert" />
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { data: _data, refresh } = await useFetch(`/api/admin/userconfig`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const data = toRef(_data.value);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/admin/userconfig`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{ revert }
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit(data.value);
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
await refresh();
|
||||
data.value = toRef(_data.value).value;
|
||||
}
|
||||
</script>
|
||||
42
src/app/pages/admin/hooks.vue
Normal file
42
src/app/pages/admin/hooks.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<main v-if="data">
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormTextField id="PreUp" v-model="data.preUp" label="PreUp" />
|
||||
<FormTextField id="PostUp" v-model="data.postUp" label="PostUp" />
|
||||
<FormTextField id="PreDown" v-model="data.preDown" label="PreDown" />
|
||||
<FormTextField id="PostDown" v-model="data.postDown" label="PostDown" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.actions') }}</FormHeading>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
<FormActionField :label="$t('form.revert')" @click="revert" />
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: _data, refresh } = await useFetch(`/api/admin/hooks`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const data = toRef(_data.value);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/admin/hooks`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{ revert }
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
return _submit(data.value);
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
await refresh();
|
||||
data.value = toRef(_data.value).value;
|
||||
}
|
||||
</script>
|
||||
64
src/app/pages/admin/index.vue
Normal file
64
src/app/pages/admin/index.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<main v-if="data">
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormNumberField
|
||||
id="session"
|
||||
v-model="data.sessionTimeout"
|
||||
:label="$t('admin.general.sessionTimeout')"
|
||||
:description="$t('admin.general.sessionTimeoutDesc')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('admin.general.metrics') }}</FormHeading>
|
||||
<FormNullTextField
|
||||
id="password"
|
||||
v-model="data.metricsPassword"
|
||||
:label="$t('admin.general.metricsPassword')"
|
||||
:description="$t('admin.general.metricsPasswordDesc')"
|
||||
/>
|
||||
<FormSwitchField
|
||||
id="prometheus"
|
||||
v-model="data.metricsPrometheus"
|
||||
:label="$t('admin.general.prometheus')"
|
||||
:description="$t('admin.general.prometheusDesc')"
|
||||
/>
|
||||
<FormSwitchField
|
||||
id="json"
|
||||
v-model="data.metricsJson"
|
||||
:label="$t('admin.general.json')"
|
||||
:description="$t('admin.general.jsonDesc')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.actions') }}</FormHeading>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
<FormActionField :label="$t('form.revert')" @click="revert" />
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
|
||||
method: 'get',
|
||||
});
|
||||
const data = toRef(_data.value);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/admin/general`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{ revert }
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit(data.value);
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
await refresh();
|
||||
data.value = toRef(_data.value).value;
|
||||
}
|
||||
</script>
|
||||
85
src/app/pages/admin/interface.vue
Normal file
85
src/app/pages/admin/interface.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<main v-if="data">
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormNumberField
|
||||
id="mtu"
|
||||
v-model="data.mtu"
|
||||
:label="$t('general.mtu')"
|
||||
:description="$t('admin.interface.mtuDesc')"
|
||||
/>
|
||||
<FormNumberField
|
||||
id="port"
|
||||
v-model="data.port"
|
||||
:label="$t('general.port')"
|
||||
:description="$t('admin.interface.portDesc')"
|
||||
/>
|
||||
<FormTextField
|
||||
id="device"
|
||||
v-model="data.device"
|
||||
:label="$t('admin.interface.device')"
|
||||
:description="$t('admin.interface.deviceDesc')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.actions') }}</FormHeading>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
<FormActionField :label="$t('form.revert')" @click="revert" />
|
||||
<AdminCidrDialog
|
||||
trigger-class="col-span-2"
|
||||
:ipv4-cidr="data.ipv4Cidr"
|
||||
:ipv6-cidr="data.ipv6Cidr"
|
||||
@change="changeCidr"
|
||||
>
|
||||
<FormActionField
|
||||
:label="$t('admin.interface.changeCidr')"
|
||||
class="w-full"
|
||||
/>
|
||||
</AdminCidrDialog>
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const data = toRef(_data.value);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/admin/interface`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{ revert }
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit(data.value);
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
await refresh();
|
||||
data.value = toRef(_data.value).value;
|
||||
}
|
||||
|
||||
const _changeCidr = useSubmit(
|
||||
`/api/admin/interface/cidr`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert,
|
||||
successMsg: t('admin.interface.cidrSuccess'),
|
||||
errorMsg: t('admin.interface.cidrError'),
|
||||
}
|
||||
);
|
||||
|
||||
async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
|
||||
await _changeCidr({ ipv4Cidr, ipv6Cidr });
|
||||
}
|
||||
</script>
|
||||
146
src/app/pages/clients/[id].vue
Normal file
146
src/app/pages/clients/[id].vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<main v-if="data">
|
||||
<Panel>
|
||||
<PanelHead>
|
||||
<PanelHeadTitle :text="data.name" />
|
||||
</PanelHead>
|
||||
<PanelBody>
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormHeading>
|
||||
{{ $t('form.sectionGeneral') }}
|
||||
</FormHeading>
|
||||
<FormTextField
|
||||
id="name"
|
||||
v-model="data.name"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<FormSwitchField
|
||||
id="enabled"
|
||||
v-model="data.enabled"
|
||||
:label="$t('client.enabled')"
|
||||
/>
|
||||
<FormDateField
|
||||
id="expiresAt"
|
||||
v-model="data.expiresAt"
|
||||
:description="$t('client.expireDateDesc')"
|
||||
:label="$t('client.expireDate')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('client.address') }}</FormHeading>
|
||||
<FormTextField
|
||||
id="ipv4Address"
|
||||
v-model="data.ipv4Address"
|
||||
label="IPv4"
|
||||
/>
|
||||
<FormTextField
|
||||
id="ipv6Address"
|
||||
v-model="data.ipv6Address"
|
||||
label="IPv6"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading :description="$t('client.allowedIpsDesc')">{{
|
||||
$t('general.allowedIps')
|
||||
}}</FormHeading>
|
||||
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading :description="$t('client.serverAllowedIpsDesc')">{{
|
||||
$t('client.serverAllowedIps')
|
||||
}}</FormHeading>
|
||||
<FormArrayField
|
||||
v-model="data.serverAllowedIps"
|
||||
name="serverAllowedIps"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup></FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.sectionAdvanced') }}</FormHeading>
|
||||
<FormNumberField
|
||||
id="mtu"
|
||||
v-model="data.mtu"
|
||||
:description="$t('client.mtuDesc')"
|
||||
:label="$t('general.mtu')"
|
||||
/>
|
||||
<FormNumberField
|
||||
id="persistentKeepalive"
|
||||
v-model="data.persistentKeepalive"
|
||||
:description="$t('client.persistentKeepaliveDesc')"
|
||||
:label="$t('general.persistentKeepalive')"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.actions') }}</FormHeading>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
<FormActionField :label="$t('form.revert')" @click="revert" />
|
||||
<ClientsDeleteDialog
|
||||
trigger-class="col-span-2"
|
||||
:client-name="data.name"
|
||||
@delete="deleteClient"
|
||||
>
|
||||
<FormActionField
|
||||
label="Delete"
|
||||
class="w-full"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
as="span"
|
||||
/>
|
||||
</ClientsDeleteDialog>
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</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);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/client/${id}`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await navigateTo('/');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit(data.value);
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
await refresh();
|
||||
data.value = toRef(_data.value).value;
|
||||
}
|
||||
|
||||
const _deleteClient = useSubmit(
|
||||
`/api/client/${id}`,
|
||||
{
|
||||
method: 'delete',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
await navigateTo('/');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function deleteClient() {
|
||||
return _deleteClient(undefined);
|
||||
}
|
||||
</script>
|
||||
61
src/app/pages/index.vue
Normal file
61
src/app/pages/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<main>
|
||||
<Panel>
|
||||
<PanelHead>
|
||||
<PanelHeadTitle :text="$t('pages.clients')" />
|
||||
<PanelHeadBoat>
|
||||
<ClientsSort />
|
||||
<ClientsNew />
|
||||
</PanelHeadBoat>
|
||||
</PanelHead>
|
||||
|
||||
<div>
|
||||
<ClientsList
|
||||
v-if="clientsStore.clients && clientsStore.clients.length > 0"
|
||||
/>
|
||||
</div>
|
||||
<ClientsEmpty
|
||||
v-if="clientsStore.clients && clientsStore.clients.length === 0"
|
||||
/>
|
||||
<div
|
||||
v-if="clientsStore.clients === null"
|
||||
class="p-5 text-gray-200 dark:text-red-300"
|
||||
>
|
||||
<IconsLoading class="mx-auto w-5 animate-spin" />
|
||||
</div>
|
||||
</Panel>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore();
|
||||
authStore.update();
|
||||
|
||||
const globalStore = useGlobalStore();
|
||||
const clientsStore = useClientsStore();
|
||||
|
||||
// TODO?: use hover card to show more detailed info without leaving the page
|
||||
// or do something like a accordion
|
||||
|
||||
const intervalId = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
clientsStore.refresh();
|
||||
|
||||
onMounted(() => {
|
||||
// TODO?: replace with websocket or similar
|
||||
intervalId.value = setInterval(() => {
|
||||
clientsStore
|
||||
.refresh({
|
||||
updateCharts: globalStore.uiShowCharts,
|
||||
})
|
||||
.catch(console.error);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId.value !== null) {
|
||||
clearInterval(intervalId.value);
|
||||
intervalId.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
91
src/app/pages/login.vue
Normal file
91
src/app/pages/login.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<main>
|
||||
<UiBanner />
|
||||
<form
|
||||
class="mx-auto mt-10 flex w-64 flex-col gap-5 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="mx-auto mb-5 mt-5 h-20 w-20 overflow-hidden rounded-full bg-red-800 dark:bg-red-800"
|
||||
>
|
||||
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
|
||||
</div>
|
||||
|
||||
<BaseInput
|
||||
v-model="username"
|
||||
type="text"
|
||||
:placeholder="$t('general.username')"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
name="username"
|
||||
/>
|
||||
|
||||
<BaseInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
:placeholder="$t('general.password')"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<label
|
||||
class="flex gap-2 whitespace-nowrap"
|
||||
:title="$t('login.rememberMeDesc')"
|
||||
>
|
||||
<BaseSwitch v-model="remember" />
|
||||
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="rounded py-2 text-sm text-white shadow transition dark:text-white"
|
||||
:class="{
|
||||
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
|
||||
password && username,
|
||||
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
|
||||
!password || !username,
|
||||
}"
|
||||
>
|
||||
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
|
||||
<span v-else>{{ $t('login.signIn') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authenticating = ref(false);
|
||||
const remember = ref(false);
|
||||
const username = ref<null | string>(null);
|
||||
const password = ref<null | string>(null);
|
||||
|
||||
const _submit = useSubmit(
|
||||
'/api/session',
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async (success) => {
|
||||
authenticating.value = false;
|
||||
password.value = null;
|
||||
|
||||
if (success) {
|
||||
await navigateTo('/');
|
||||
}
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (!username.value || !password.value || authenticating.value) return;
|
||||
|
||||
authenticating.value = true;
|
||||
|
||||
return _submit({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
remember: remember.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
104
src/app/pages/me.vue
Normal file
104
src/app/pages/me.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<main>
|
||||
<Panel>
|
||||
<PanelHead>
|
||||
<PanelHeadTitle :text="$t('pages.me')" />
|
||||
</PanelHead>
|
||||
<PanelBody class="dark:text-neutral-200">
|
||||
<FormElement @submit.prevent="submit">
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('form.sectionGeneral') }}</FormHeading>
|
||||
<FormTextField
|
||||
id="name"
|
||||
v-model="name"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<FormNullTextField
|
||||
id="email"
|
||||
v-model="email"
|
||||
:label="$t('user.email')"
|
||||
/>
|
||||
<FormActionField type="submit" :label="$t('form.save')" />
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
<FormElement @submit.prevent="updatePassword">
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('general.password') }}</FormHeading>
|
||||
<FormPasswordField
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
autocomplete="current-password"
|
||||
:label="$t('me.currentPassword')"
|
||||
/>
|
||||
<FormPasswordField
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
autocomplete="new-password"
|
||||
:label="$t('general.newPassword')"
|
||||
/>
|
||||
<FormPasswordField
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
:label="$t('me.confirmPassword')"
|
||||
/>
|
||||
<FormActionField
|
||||
type="submit"
|
||||
:label="$t('general.updatePassword')"
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore();
|
||||
authStore.update();
|
||||
|
||||
const name = ref(authStore.userData?.name);
|
||||
const email = ref(authStore.userData?.email);
|
||||
|
||||
const _submit = useSubmit(
|
||||
`/api/me`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: () => {
|
||||
return authStore.update();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function submit() {
|
||||
return _submit({ name: name.value, email: email.value });
|
||||
}
|
||||
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
|
||||
const _updatePassword = useSubmit(
|
||||
`/api/me/password`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async () => {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function updatePassword() {
|
||||
return _updatePassword({
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user