Browse Source

fix features, add toast

pull/1397/head
Bernd Storath 7 months ago
parent
commit
008f711c45
  1. 7
      src/app/app.vue
  2. 37
      src/app/components/ui/Toast.vue
  3. 14
      src/app/layouts/Header.vue
  4. 49
      src/app/pages/admin/features.vue
  5. 7
      src/app/utils/api.ts
  6. 1
      src/app/utils/math.ts
  7. 8
      src/server/api/features.post.ts
  8. 2
      src/server/middleware/setup.ts
  9. 36
      src/server/utils/types.ts
  10. 17
      src/services/database/lowdb.ts
  11. 13
      src/services/database/repositories/system.ts

7
src/app/app.vue

@ -1,7 +1,12 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<NuxtLayout name="header" /> <NuxtLayout name="header" />
<NuxtPage /> <ToastProvider>
<NuxtPage />
<ToastViewport
class="[--viewport-padding:_25px] fixed bottom-0 right-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
/>
</ToastProvider>
<NuxtLayout name="footer" /> <NuxtLayout name="footer" />
</NuxtLayout> </NuxtLayout>
</template> </template>

37
src/app/components/ui/Toast.vue

@ -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>

14
src/app/layouts/Header.vue

@ -2,14 +2,14 @@
<header class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6"> <header class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
<div <div
:class=" :class="
isLoginPage hasOwnLogo
? 'flex justify-end' ? 'flex justify-end'
: 'flex flex-col-reverse xxs:flex-row flex-auto items-center gap-3' : 'flex flex-col-reverse xxs:flex-row flex-auto items-center gap-3'
" "
> >
<NuxtLink to="/" class="flex-grow self-start mb-4"> <NuxtLink to="/" class="flex-grow self-start mb-4">
<h1 <h1
v-if="isLoginPage" v-if="!hasOwnLogo"
class="text-4xl dark:text-neutral-200 font-medium" class="text-4xl dark:text-neutral-200 font-medium"
> >
<img <img
@ -53,7 +53,7 @@
class="w-5 h-5 peer fill-gray-400 peer-checked:fill-gray-600 dark:fill-neutral-600 peer-checked:dark:fill-neutral-400 group-hover:dark:fill-neutral-500 transition" class="w-5 h-5 peer fill-gray-400 peer-checked:fill-gray-600 dark:fill-neutral-600 peer-checked:dark:fill-neutral-400 group-hover:dark:fill-neutral-500 transition"
/> />
</label> </label>
<UiUserMenu v-if="!isLoginPage" /> <UiUserMenu v-if="loggedIn" />
</div> </div>
</div> </div>
<div class="text-sm text-gray-400 dark:text-neutral-400 mb-5" /> <div class="text-sm text-gray-400 dark:text-neutral-400 mb-5" />
@ -84,7 +84,13 @@
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const route = useRoute(); const route = useRoute();
const isLoginPage = computed(() => route.path == '/login'); const hasOwnLogo = computed(
() => route.path === '/login' || route.path === '/setup'
);
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/setup'
);
const theme = useTheme(); const theme = useTheme();
const uiShowCharts = ref(getItem('uiShowCharts') === '1'); const uiShowCharts = ref(getItem('uiShowCharts') === '1');

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

@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<div v-for="feature in featuresData" :key="feature.name" class="space-y-2"> <div v-for="(feature, key) in featuresData" :key="key" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200"> <h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200">
@ -14,7 +14,7 @@
:checked="feature.enabled" :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="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'" :class="feature.enabled ? 'bg-red-800' : 'bg-gray-200'"
@update:checked="toggleFeature(feature)" @update:checked="toggleFeature(key)"
> >
<SwitchThumb <SwitchThumb
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform" class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
@ -23,41 +23,60 @@
</SwitchRoot> </SwitchRoot>
</div> </div>
</div> </div>
<BaseButton class="self-end">Save</BaseButton> <BaseButton class="self-end" @click="submit">Save</BaseButton>
<UiToast
v-model:open="open"
title="Saved successfully"
content="Features saved successfully"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UiToast } from '#build/components';
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const open = ref(false);
const featuresData = ref([ type ExtendedFeatures = {
{ [K in keyof Omit<(typeof globalStore)['features'], 'trafficStats'>]: {
name: 'Traffic Stats', name: string;
description: 'Show more detailed Statistics about Client Traffic', description: string;
enabled: globalStore.features.trafficStats.enabled, enabled: boolean;
}, };
{ };
const featuresData = ref({
sortClients: {
name: 'Sort Clients', name: 'Sort Clients',
description: 'Be able to sort Clients by Name', description: 'Be able to sort Clients by Name',
enabled: globalStore.features.sortClients.enabled, enabled: globalStore.features.sortClients.enabled,
}, },
{ oneTimeLinks: {
name: 'One Time Links', name: 'One Time Links',
description: 'Be able to generate One Time Link to download Config', description: 'Be able to generate One Time Link to download Config',
enabled: globalStore.features.oneTimeLinks.enabled, enabled: globalStore.features.oneTimeLinks.enabled,
}, },
{ clientExpiration: {
name: 'Client Expiration', name: 'Client Expiration',
description: 'Be able to set Date when Client will be disabled', description: 'Be able to set Date when Client will be disabled',
enabled: globalStore.features.clientExpiration.enabled, enabled: globalStore.features.clientExpiration.enabled,
}, },
]); } satisfies ExtendedFeatures);
function toggleFeature(feature: (typeof featuresData)['value'][number]) { function toggleFeature(key: keyof ExtendedFeatures) {
const feat = featuresData.value.find((v) => v.name === feature.name); const feat = featuresData.value[key];
if (!feat) { if (!feat) {
return; return;
} }
feat.enabled = !feat.enabled; feat.enabled = !feat.enabled;
} }
async function submit() {
const response = await api.updateFeatures(featuresData.value);
if (response.success) {
open.value = true;
}
globalStore.fetchFeatures();
}
</script> </script>

7
src/app/utils/api.ts

@ -145,6 +145,13 @@ class API {
method: 'get', method: 'get',
}); });
} }
async updateFeatures(features: Record<string, { enabled: boolean }>) {
return $fetch('/api/features', {
method: 'post',
body: { features },
});
}
} }
type WGClientReturn = Awaited< type WGClientReturn = Awaited<

1
src/app/utils/math.ts

@ -21,6 +21,7 @@ export function bytes(
} }
export function dateTime(value: Date) { export function dateTime(value: Date) {
// TODO: results in mismatch because of different locales
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(undefined, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',

8
src/server/api/features.post.ts

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

2
src/server/middleware/setup.ts

@ -2,6 +2,8 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const url = getRequestURL(event); const url = getRequestURL(event);
// TODO: redirect to login page if already set up
if ( if (
url.pathname === '/setup' || url.pathname === '/setup' ||
url.pathname === '/api/account/setup' || url.pathname === '/api/account/setup' ||

36
src/server/utils/types.ts

@ -58,6 +58,19 @@ const oneTimeLink = z
.min(1, 'oneTimeLink must be at least 1 Character') .min(1, 'oneTimeLink must be at least 1 Character')
.pipe(safeStringRefine); .pipe(safeStringRefine);
const features = z.record(
z.string({ message: 'key must be a valid object' }),
z.object(
{
enabled: z.boolean({ message: 'enabled must be a valid boolean' }),
},
{ message: 'value must be a valid object' }
),
{ message: 'features must be a valid record' }
);
const objectMessage = 'Body must be a valid object';
export const clientIdType = z.object( export const clientIdType = z.object(
{ {
clientId: id, clientId: id,
@ -69,28 +82,28 @@ export const address4Type = z.object(
{ {
address4: address4, address4: address4,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const nameType = z.object( export const nameType = z.object(
{ {
name: name, name: name,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const expireDateType = z.object( export const expireDateType = z.object(
{ {
expireDate: expireDate, expireDate: expireDate,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const oneTimeLinkType = z.object( export const oneTimeLinkType = z.object(
{ {
oneTimeLink: oneTimeLink, oneTimeLink: oneTimeLink,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const createType = z.object( export const createType = z.object(
@ -98,14 +111,14 @@ export const createType = z.object(
name: name, name: name,
expireDate: expireDate, expireDate: expireDate,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const fileType = z.object( export const fileType = z.object(
{ {
file: file, file: file,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const credentialsType = z.object( export const credentialsType = z.object(
@ -114,7 +127,7 @@ export const credentialsType = z.object(
password: password, password: password,
remember: remember, remember: remember,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
); );
export const passwordType = z.object( export const passwordType = z.object(
@ -122,7 +135,14 @@ export const passwordType = z.object(
username: username, username: username,
password: password, password: password,
}, },
{ message: 'Body must be a valid object' } { message: objectMessage }
);
export const featuresType = z.object(
{
features: features,
},
{ message: objectMessage }
); );
export function validateZod<T>(schema: ZodSchema<T>) { export function validateZod<T>(schema: ZodSchema<T>) {

17
src/services/database/lowdb.ts

@ -18,7 +18,11 @@ import {
type NewClient, type NewClient,
type OneTimeLink, type OneTimeLink,
} from './repositories/client'; } from './repositories/client';
import { SystemRepository } from './repositories/system'; import {
Features,
SystemRepository,
type Feature,
} from './repositories/system';
const DEBUG = debug('LowDB'); const DEBUG = debug('LowDB');
@ -37,6 +41,17 @@ export class LowDBSystem extends SystemRepository {
} }
return system; return system;
} }
async updateFeatures(features: Record<string, Feature>) {
DEBUG('Update Features');
this.#db.update((v) => {
for (const key in features) {
if (Features.includes(key as Features)) {
v.system[key as Features].enabled = features[key]!.enabled;
}
}
});
}
} }
export class LowDBUser extends UserRepository { export class LowDBUser extends UserRepository {

13
src/services/database/repositories/system.ts

@ -65,16 +65,25 @@ export type System = {
wgConfigPort: number; wgConfigPort: number;
iptables: IpTables; iptables: IpTables;
trafficStats: TrafficStats; trafficStats: TrafficStats;
prometheus: Prometheus;
clientExpiration: Feature; clientExpiration: Feature;
oneTimeLinks: Feature; oneTimeLinks: Feature;
sortClients: Feature; sortClients: Feature;
prometheus: Prometheus;
sessionConfig: SessionConfig; sessionConfig: SessionConfig;
}; };
export const Features = [
'clientExpiration',
'oneTimeLinks',
'sortClients',
] as const;
export type Features = (typeof Features)[number];
/** /**
* Interface for system-related database operations. * Interface for system-related database operations.
* This interface provides methods for retrieving system configuration data * This interface provides methods for retrieving system configuration data
@ -85,4 +94,6 @@ export abstract class SystemRepository {
* Retrieves the system configuration data from the database. * Retrieves the system configuration data from the database.
*/ */
abstract get(): Promise<System>; abstract get(): Promise<System>;
abstract updateFeatures(features: Record<string, Feature>): Promise<void>;
} }

Loading…
Cancel
Save