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>
<NuxtLayout>
<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>
</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">
<div
:class="
isLoginPage
hasOwnLogo
? 'flex justify-end'
: 'flex flex-col-reverse xxs:flex-row flex-auto items-center gap-3'
"
>
<NuxtLink to="/" class="flex-grow self-start mb-4">
<h1
v-if="isLoginPage"
v-if="!hasOwnLogo"
class="text-4xl dark:text-neutral-200 font-medium"
>
<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"
/>
</label>
<UiUserMenu v-if="!isLoginPage" />
<UiUserMenu v-if="loggedIn" />
</div>
</div>
<div class="text-sm text-gray-400 dark:text-neutral-400 mb-5" />
@ -84,7 +84,13 @@
const globalStore = useGlobalStore();
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 uiShowCharts = ref(getItem('uiShowCharts') === '1');

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

@ -1,6 +1,6 @@
<template>
<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>
<h3 class="text-lg font-medium text-gray-900 dark:text-neutral-200">
@ -14,7 +14,7 @@
: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(feature)"
@update:checked="toggleFeature(key)"
>
<SwitchThumb
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
@ -23,41 +23,60 @@
</SwitchRoot>
</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>
</template>
<script setup lang="ts">
import type { UiToast } from '#build/components';
const globalStore = useGlobalStore();
const open = ref(false);
const featuresData = ref([
{
name: 'Traffic Stats',
description: 'Show more detailed Statistics about Client Traffic',
enabled: globalStore.features.trafficStats.enabled,
},
{
type ExtendedFeatures = {
[K in keyof Omit<(typeof globalStore)['features'], 'trafficStats'>]: {
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(feature: (typeof featuresData)['value'][number]) {
const feat = featuresData.value.find((v) => v.name === feature.name);
function toggleFeature(key: keyof ExtendedFeatures) {
const feat = featuresData.value[key];
if (!feat) {
return;
}
feat.enabled = !feat.enabled;
}
async function submit() {
const response = await api.updateFeatures(featuresData.value);
if (response.success) {
open.value = true;
}
globalStore.fetchFeatures();
}
</script>

7
src/app/utils/api.ts

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

1
src/app/utils/math.ts

@ -21,6 +21,7 @@ export function bytes(
}
export function dateTime(value: Date) {
// TODO: results in mismatch because of different locales
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
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) => {
const url = getRequestURL(event);
// TODO: redirect to login page if already set up
if (
url.pathname === '/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')
.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(
{
clientId: id,
@ -69,28 +82,28 @@ export const address4Type = z.object(
{
address4: address4,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const nameType = z.object(
{
name: name,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const expireDateType = z.object(
{
expireDate: expireDate,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const oneTimeLinkType = z.object(
{
oneTimeLink: oneTimeLink,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const createType = z.object(
@ -98,14 +111,14 @@ export const createType = z.object(
name: name,
expireDate: expireDate,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const fileType = z.object(
{
file: file,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const credentialsType = z.object(
@ -114,7 +127,7 @@ export const credentialsType = z.object(
password: password,
remember: remember,
},
{ message: 'Body must be a valid object' }
{ message: objectMessage }
);
export const passwordType = z.object(
@ -122,7 +135,14 @@ export const passwordType = z.object(
username: username,
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>) {

17
src/services/database/lowdb.ts

@ -18,7 +18,11 @@ import {
type NewClient,
type OneTimeLink,
} from './repositories/client';
import { SystemRepository } from './repositories/system';
import {
Features,
SystemRepository,
type Feature,
} from './repositories/system';
const DEBUG = debug('LowDB');
@ -37,6 +41,17 @@ export class LowDBSystem extends SystemRepository {
}
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 {

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

@ -65,16 +65,25 @@ export type System = {
wgConfigPort: number;
iptables: IpTables;
trafficStats: TrafficStats;
prometheus: Prometheus;
clientExpiration: Feature;
oneTimeLinks: Feature;
sortClients: Feature;
prometheus: Prometheus;
sessionConfig: SessionConfig;
};
export const Features = [
'clientExpiration',
'oneTimeLinks',
'sortClients',
] as const;
export type Features = (typeof Features)[number];
/**
* Interface for system-related database operations.
* 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.
*/
abstract get(): Promise<System>;
abstract updateFeatures(features: Record<string, Feature>): Promise<void>;
}

Loading…
Cancel
Save