mirror of https://github.com/wg-easy/wg-easy
47 changed files with 1015 additions and 224 deletions
@ -0,0 +1,37 @@ |
|||
<script setup lang="ts"> |
|||
import { |
|||
ToastAction, |
|||
ToastClose, |
|||
ToastDescription, |
|||
ToastRoot, |
|||
ToastTitle, |
|||
} from 'radix-vue'; |
|||
|
|||
defineProps<{ |
|||
title: string; |
|||
content: string; |
|||
}>(); |
|||
</script> |
|||
|
|||
<template> |
|||
<ToastRoot |
|||
class="bg-white rounded-md shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] p-[15px] grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center data-[state=open]:animate-slideIn data-[state=closed]:animate-hide data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-swipeOut" |
|||
> |
|||
<ToastTitle |
|||
v-if="title" |
|||
class="[grid-area:_title] mb-[5px] font-medium text-slate12 text-[15px]" |
|||
> |
|||
{{ title }} |
|||
</ToastTitle> |
|||
<ToastDescription |
|||
class="[grid-area:_description] m-0 text-slate11 text-[13px] leading-[1.3]" |
|||
>{{ content }}</ToastDescription |
|||
> |
|||
<ToastAction as-child alt-text="toast" class="[grid-area:_action]"> |
|||
<slot /> |
|||
</ToastAction> |
|||
<ToastClose aria-label="Close"> |
|||
<span aria-hidden>×</span> |
|||
</ToastClose> |
|||
</ToastRoot> |
|||
</template> |
@ -0,0 +1,92 @@ |
|||
<template> |
|||
<DropdownMenuRoot v-model:open="toggleState"> |
|||
<DropdownMenuTrigger> |
|||
<button |
|||
class="flex items-center pe-1 font-medium text-sm text-gray-400 rounded-full hover:text-red-800 dark:hover:text-red-800 md:me-0 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:text-neutral-400" |
|||
type="button" |
|||
> |
|||
<AvatarRoot |
|||
class="inline-flex h-8 w-8 select-none items-center justify-center overflow-hidden rounded-full align-middle mr-2" |
|||
> |
|||
<AvatarFallback |
|||
class="text-grass11 leading-1 flex h-full w-full items-center justify-center bg-white text-[15px] font-medium" |
|||
:delay-ms="600" |
|||
> |
|||
{{ fallbackName }} |
|||
</AvatarFallback> |
|||
</AvatarRoot> |
|||
{{ authStore.userData?.name }} |
|||
</button> |
|||
</DropdownMenuTrigger> |
|||
|
|||
<DropdownMenuPortal> |
|||
<DropdownMenuContent |
|||
:side-offset="5" |
|||
class="z-10 bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-neutral-700 dark:divide-neutral-800 text-gray-700 dark:text-gray-200" |
|||
> |
|||
<DropdownMenuItem> |
|||
<div class="truncate">{{ authStore.userData?.name }}</div> |
|||
<div class="truncate">@{{ authStore.userData?.username }}</div> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem> |
|||
<NuxtLink |
|||
to="/" |
|||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" |
|||
> |
|||
Clients |
|||
</NuxtLink> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem> |
|||
<NuxtLink |
|||
to="/me" |
|||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" |
|||
> |
|||
Account |
|||
</NuxtLink> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem v-if="authStore.userData?.role === 'ADMIN'"> |
|||
<NuxtLink |
|||
to="/admin" |
|||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" |
|||
> |
|||
Admin Panel |
|||
</NuxtLink> |
|||
</DropdownMenuItem> |
|||
<DropdownMenuItem> |
|||
<button |
|||
class="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white" |
|||
@click.prevent="logout" |
|||
> |
|||
<IconsLogout class="h-5" /> |
|||
{{ $t('logout') }} |
|||
</button> |
|||
</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenuPortal> |
|||
</DropdownMenuRoot> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const authStore = useAuthStore(); |
|||
const toggleState = ref(false); |
|||
|
|||
async function logout() { |
|||
try { |
|||
await authStore.logout(); |
|||
navigateTo('/login'); |
|||
} catch (err) { |
|||
if (err instanceof Error) { |
|||
// TODO: better ui |
|||
alert(err.message || err.toString()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
const fallbackName = computed(() => { |
|||
return authStore.userData?.name |
|||
.split(' ') |
|||
.map((word) => word.charAt(0).toUpperCase()) |
|||
.slice(0, 2) |
|||
.join(''); |
|||
}); |
|||
</script> |
@ -0,0 +1,52 @@ |
|||
<template> |
|||
<div> |
|||
<div class="container mx-auto p-4"> |
|||
<div class="flex"> |
|||
<div class="w-64 bg-white dark:bg-neutral-700 rounded-lg p-4 mr-4"> |
|||
<NuxtLink to="/admin"> |
|||
<h2 class="text-xl font-bold dark:text-neutral-200 mb-4"> |
|||
Admin Panel |
|||
</h2> |
|||
</NuxtLink> |
|||
<div class="space-y-2 flex flex-col"> |
|||
<NuxtLink |
|||
v-for="(item, index) in menuItems" |
|||
:key="index" |
|||
:to="`/admin/${item.id}`" |
|||
> |
|||
<BaseButton |
|||
class="font-medium dark:text-neutral-200 p-2 rounded cursor-pointer hover:bg-red-800 transition-colors duration-200 w-full" |
|||
> |
|||
{{ item.name }} |
|||
</BaseButton> |
|||
</NuxtLink> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="flex-1 bg-white dark:text-neutral-200 dark:bg-neutral-700 rounded-lg p-6" |
|||
> |
|||
<h1 class="text-3xl font-bold mb-6">{{ activeMenuItem?.name }}</h1> |
|||
<NuxtPage /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const authStore = useAuthStore(); |
|||
authStore.update(); |
|||
|
|||
const route = useRoute(); |
|||
|
|||
const menuItems = [ |
|||
{ id: 'features', name: 'Features' }, |
|||
{ id: 'statistics', name: 'Statistics' }, |
|||
{ id: 'metrics', name: 'Metrics' }, |
|||
]; |
|||
|
|||
const activeMenuItem = computed(() => { |
|||
return menuItems.find((item) => route.path === `/admin/${item.id}`); |
|||
}); |
|||
</script> |
@ -0,0 +1,83 @@ |
|||
<template> |
|||
<div class="flex flex-col"> |
|||
<div v-for="(feature, key) in featuresData" :key="key" class="space-y-2"> |
|||
<div class="flex items-center justify-between"> |
|||
<div> |
|||
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200"> |
|||
{{ feature.name }} |
|||
</h3> |
|||
<p class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
{{ feature.description }} |
|||
</p> |
|||
</div> |
|||
<SwitchRoot |
|||
:checked="feature.enabled" |
|||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-800" |
|||
:class="feature.enabled ? 'bg-red-800' : 'bg-gray-200'" |
|||
@update:checked="toggleFeature(key)" |
|||
> |
|||
<SwitchThumb |
|||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform" |
|||
:class="feature.enabled ? 'translate-x-6' : 'translate-x-1'" |
|||
/> |
|||
</SwitchRoot> |
|||
</div> |
|||
</div> |
|||
<BaseButton class="self-end" @click="submit">Save</BaseButton> |
|||
<UiToast |
|||
v-model:open="open" |
|||
title="Saved successfully" |
|||
content="Features saved successfully" |
|||
/> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const globalStore = useGlobalStore(); |
|||
const open = ref(false); |
|||
|
|||
type ExtendedFeatures = { |
|||
[K in keyof (typeof globalStore)['features']]: { |
|||
name: string; |
|||
description: string; |
|||
enabled: boolean; |
|||
}; |
|||
}; |
|||
|
|||
const featuresData = ref({ |
|||
sortClients: { |
|||
name: 'Sort Clients', |
|||
description: 'Be able to sort Clients by Name', |
|||
enabled: globalStore.features.sortClients.enabled, |
|||
}, |
|||
oneTimeLinks: { |
|||
name: 'One Time Links', |
|||
description: 'Be able to generate One Time Link to download Config', |
|||
enabled: globalStore.features.oneTimeLinks.enabled, |
|||
}, |
|||
clientExpiration: { |
|||
name: 'Client Expiration', |
|||
description: 'Be able to set Date when Client will be disabled', |
|||
enabled: globalStore.features.clientExpiration.enabled, |
|||
}, |
|||
} satisfies ExtendedFeatures); |
|||
|
|||
function toggleFeature(key: keyof ExtendedFeatures) { |
|||
const feat = featuresData.value[key]; |
|||
if (!feat) { |
|||
return; |
|||
} |
|||
feat.enabled = !feat.enabled; |
|||
} |
|||
|
|||
async function submit() { |
|||
const response = await $fetch('/api/admin/features', { |
|||
method: 'post', |
|||
body: { features: featuresData.value }, |
|||
}); |
|||
if (response.success) { |
|||
open.value = true; |
|||
} |
|||
globalStore.fetchFeatures(); |
|||
} |
|||
</script> |
@ -0,0 +1,10 @@ |
|||
<template> |
|||
<div> |
|||
This is the Admin Panel. Your are running wg-easy |
|||
{{ globalStore.currentRelease }} |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const globalStore = useGlobalStore(); |
|||
</script> |
@ -0,0 +1,115 @@ |
|||
<template> |
|||
<div class="flex flex-col"> |
|||
<div class="flex items-center justify-between"> |
|||
<div> |
|||
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200"> |
|||
Traffic Stats |
|||
</h3> |
|||
<p class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
Show more concise Stats about Traffic Usage |
|||
</p> |
|||
</div> |
|||
<SwitchRoot |
|||
v-model:checked="enabled" |
|||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-800" |
|||
:class="enabled ? 'bg-red-800' : 'bg-gray-200'" |
|||
> |
|||
<SwitchThumb |
|||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform" |
|||
:class="enabled ? 'translate-x-6' : 'translate-x-1'" |
|||
/> |
|||
</SwitchRoot> |
|||
</div> |
|||
<div class="flex items-center justify-between"> |
|||
<div> |
|||
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200"> |
|||
Chart Type |
|||
</h3> |
|||
<p class="text-sm text-gray-500 dark:text-neutral-300"> |
|||
Select Type of Chart you want to show |
|||
</p> |
|||
</div> |
|||
<SelectRoot v-model="chartType"> |
|||
<SelectTrigger |
|||
class="inline-flex min-w-[160px] items-center justify-between rounded px-[15px] text-[13px] leading-none h-[35px] gap-[5px] bg-white text-grass11 shadow-[0_2px_10px] shadow-black/10 hover:bg-mauve3 focus:shadow-[0_0_0_2px] focus:shadow-black data-[placeholder]:text-green9 outline-none" |
|||
aria-label="Customize options" |
|||
> |
|||
<SelectValue placeholder="Select a fruit..." /> |
|||
<IconsArrowDown class="h-3.5 w-3.5" /> |
|||
</SelectTrigger> |
|||
|
|||
<SelectPortal> |
|||
<SelectContent |
|||
class="min-w-[160px] bg-white rounded shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)] will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-[100]" |
|||
:side-offset="5" |
|||
> |
|||
<SelectScrollUpButton |
|||
class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default" |
|||
> |
|||
<IconsArrowUp /> |
|||
</SelectScrollUpButton> |
|||
<SelectViewport class="p-[5px]"> |
|||
<SelectItem |
|||
v-for="(option, index) in options" |
|||
:key="index" |
|||
class="text-[13px] leading-none text-grass11 rounded-[3px] flex items-center h-[25px] pr-[35px] pl-[25px] relative select-none data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-green9 data-[highlighted]:text-green1" |
|||
:value="option" |
|||
> |
|||
<SelectItemText> |
|||
{{ option }} |
|||
</SelectItemText> |
|||
</SelectItem> |
|||
</SelectViewport> |
|||
<SelectScrollDownButton |
|||
class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default" |
|||
> |
|||
<IconsArrowDown /> |
|||
</SelectScrollDownButton> |
|||
</SelectContent> |
|||
</SelectPortal> |
|||
</SelectRoot> |
|||
</div> |
|||
<BaseButton class="self-end" @click="submit">Save</BaseButton> |
|||
<UiToast |
|||
v-model:open="open" |
|||
title="Saved successfully" |
|||
content="Statistics saved successfully" |
|||
/> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const globalStore = useGlobalStore(); |
|||
const open = ref(false); |
|||
const enabled = ref(globalStore.statistics.enabled); |
|||
const options: Record<number, string> = { |
|||
0: 'None', |
|||
1: 'Line', |
|||
2: 'Area', |
|||
3: 'Bar', |
|||
}; |
|||
const stringToIndex = Object.entries(options).reduce( |
|||
(obj, [k, v]) => { |
|||
obj[v] = Number.parseInt(k); |
|||
return obj; |
|||
}, |
|||
{} as Record<string, number> |
|||
); |
|||
const chartType = ref(options[globalStore.statistics.chartType]); |
|||
|
|||
async function submit() { |
|||
const response = await $fetch('/api/admin/statistics', { |
|||
method: 'post', |
|||
body: { |
|||
statistics: { |
|||
enabled: enabled.value, |
|||
chartType: stringToIndex[chartType.value!], |
|||
}, |
|||
}, |
|||
}); |
|||
if (response.success) { |
|||
open.value = true; |
|||
} |
|||
globalStore.fetchStatistics(); |
|||
} |
|||
</script> |
@ -0,0 +1,54 @@ |
|||
<template> |
|||
<main> |
|||
<div class="container mx-auto max-w-3xl px-3 md:px-0"> |
|||
<div |
|||
class="shadow-md rounded-lg bg-white dark:bg-neutral-700 overflow-hidden" |
|||
> |
|||
<div |
|||
class="flex flex-row flex-auto items-center p-3 px-5 border-b-2 border-gray-100 dark:border-neutral-600" |
|||
> |
|||
<div class="flex-grow"> |
|||
<p class="text-2xl font-medium dark:text-neutral-200">Account</p> |
|||
</div> |
|||
</div> |
|||
<div class="space-y-2"> |
|||
<div class="flex flex-wrap items-center gap-[15px] px-5"> |
|||
<Label class="font-semibold dark:text-neutral-200" for="username"> |
|||
Username |
|||
</Label> |
|||
<input id="username" v-model.trim="username" type="text" /> |
|||
</div> |
|||
<div class="flex flex-wrap items-center gap-[15px] px-5"> |
|||
<Label class="font-semibold dark:text-neutral-200" for="name"> |
|||
Name |
|||
</Label> |
|||
<input id="name" v-model.trim="name" type="text" /> |
|||
</div> |
|||
<div class="flex flex-wrap items-center gap-[15px] px-5"> |
|||
<Label class="font-semibold dark:text-neutral-200" for="name"> |
|||
E-Mail |
|||
</Label> |
|||
<input id="name" v-model.trim="email" type="text" /> |
|||
</div> |
|||
<BaseButton class="self-end" @click="openPasswordModal"> |
|||
Change Password |
|||
</BaseButton> |
|||
<BaseButton class="self-end" @click="submit">Save</BaseButton> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const authStore = useAuthStore(); |
|||
authStore.update(); |
|||
|
|||
const username = ref(authStore.userData?.username); |
|||
const name = ref(authStore.userData?.name); |
|||
const email = ref(authStore.userData?.email); |
|||
|
|||
function submit() {} |
|||
|
|||
function openPasswordModal() {} |
|||
</script> |
@ -0,0 +1,8 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const { features } = await readValidatedBody( |
|||
event, |
|||
validateZod(featuresType) |
|||
); |
|||
await Database.system.updateFeatures(features); |
|||
return { success: true }; |
|||
}); |
@ -0,0 +1,8 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const { statistics } = await readValidatedBody( |
|||
event, |
|||
validateZod(statisticsType) |
|||
); |
|||
await Database.system.updateStatistics(statistics); |
|||
return { success: true }; |
|||
}); |
@ -1,9 +1,4 @@ |
|||
export default defineEventHandler(async () => { |
|||
const system = await Database.system.get(); |
|||
return { |
|||
trafficStats: system.trafficStats, |
|||
sortClients: system.sortClients, |
|||
clientExpiration: system.clientExpiration, |
|||
oneTimeLinks: system.oneTimeLinks, |
|||
}; |
|||
return system.features; |
|||
}); |
|||
|
@ -1,5 +1,5 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
const system = await Database.system.get(); |
|||
return system.lang; |
|||
return system.general.lang; |
|||
}); |
|||
|
@ -1,9 +1,24 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const session = await useWGSession(event); |
|||
const authenticated = session.data.authenticated; |
|||
|
|||
if (!session.data.userId) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Not logged in', |
|||
}); |
|||
} |
|||
const user = await Database.user.findById(session.data.userId); |
|||
if (!user) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Not found in Database', |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
requiresPassword: true, |
|||
authenticated, |
|||
role: user.role, |
|||
username: user.username, |
|||
name: user.name, |
|||
email: user.email, |
|||
}; |
|||
}); |
|||
|
@ -0,0 +1,4 @@ |
|||
export default defineEventHandler(async () => { |
|||
const system = await Database.system.get(); |
|||
return system.statistics; |
|||
}); |
@ -1,14 +1,35 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const url = getRequestURL(event); |
|||
const session = await useWGSession(event); |
|||
|
|||
// Api handled by session, Setup handled with setup middleware
|
|||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/setup')) { |
|||
return; |
|||
} |
|||
|
|||
if (url.pathname === '/login') { |
|||
if (session.data.authenticated) { |
|||
if (session.data.userId) { |
|||
return sendRedirect(event, '/', 302); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// Require auth for every page other than Login
|
|||
// TODO: investigate /__nuxt_error (error page when unauthenticated)
|
|||
if (!session.data.userId) { |
|||
return sendRedirect(event, '/login', 302); |
|||
} |
|||
if (url.pathname === '/') { |
|||
if (!session.data.authenticated) { |
|||
|
|||
if (url.pathname.startsWith('/admin')) { |
|||
const user = await Database.user.findById(session.data.userId); |
|||
if (!user) { |
|||
return sendRedirect(event, '/login', 302); |
|||
} |
|||
if (user.role !== 'ADMIN') { |
|||
throw createError({ |
|||
statusCode: 403, |
|||
statusMessage: 'Not allowed to access Admin Panel', |
|||
}); |
|||
} |
|||
} |
|||
}); |
|||
|
@ -1,10 +1,10 @@ |
|||
import type { H3Event } from 'h3'; |
|||
|
|||
export type WGSession = { |
|||
authenticated: boolean; |
|||
}; |
|||
export type WGSession = Partial<{ |
|||
userId: string; |
|||
}>; |
|||
|
|||
export async function useWGSession(event: H3Event) { |
|||
const system = await Database.system.get(); |
|||
return useSession<Partial<WGSession>>(event, system.sessionConfig); |
|||
return useSession<WGSession>(event, system.sessionConfig); |
|||
} |
|||
|
Loading…
Reference in new issue