Browse Source

add tooltip info, extract strings

pull/1666/head
Bernd Storath 6 months ago
parent
commit
e0f50c0375
  1. 2
      src/app/components/admin/CidrDialog.vue
  2. 3
      src/app/components/base/Dialog.vue
  3. 24
      src/app/components/base/Tooltip.vue
  4. 2
      src/app/components/form/ArrayField.vue
  5. 9
      src/app/components/form/Heading.vue
  6. 13
      src/app/components/form/NullTextField.vue
  7. 13
      src/app/components/form/NumberField.vue
  8. 13
      src/app/components/form/SwitchField.vue
  9. 13
      src/app/components/form/TextField.vue
  10. 15
      src/app/components/icons/Info.vue
  11. 11
      src/app/components/ui/UserMenu.vue
  12. 42
      src/app/composables/useSubmit.ts
  13. 11
      src/app/pages/admin.vue
  14. 75
      src/app/pages/admin/config.vue
  15. 6
      src/app/pages/admin/hooks.vue
  16. 50
      src/app/pages/admin/index.vue
  17. 98
      src/app/pages/admin/interface.vue
  18. 1
      src/app/pages/login.vue
  19. 37
      src/app/stores/toast.ts
  20. 55
      src/i18n/locales/en.json

2
src/app/components/admin/CidrDialog.vue

@ -1,5 +1,5 @@
<template>
<BaseDialog>
<BaseDialog :trigger-class="triggerClass">
<template #trigger><slot /></template>
<template #title>Change CIDR</template>
<template #description>

3
src/app/components/base/Dialog.vue

@ -6,7 +6,7 @@
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
/>
<DialogContent
class="data-[state=open]:animate-contentShow 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"
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"
@ -27,5 +27,6 @@
</template>
<script lang="ts" setup>
// TODO: improve
defineProps<{ triggerClass?: string }>();
</script>

24
src/app/components/base/Tooltip.vue

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

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

@ -18,6 +18,8 @@
</template>
<script lang="ts" setup>
// TODO: style
const data = defineModel<string[]>();
defineProps<{ emptyText?: string[]; name: string }>();

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

@ -1,5 +1,12 @@
<template>
<h4 class="col-span-full py-6 text-2xl">
<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>

13
src/app/components/form/NullTextField.vue

@ -1,7 +1,12 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<div class="flex items-center">
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<input
:id="id"
v-model.trim="data"
@ -12,7 +17,7 @@
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<string | null>({
set(value) {

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

@ -1,7 +1,12 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<div class="flex items-center">
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<input
:id="id"
v-model.number="data"
@ -12,7 +17,7 @@
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<number>();
</script>

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

@ -1,11 +1,16 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<div class="flex items-center">
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<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 }>();
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<boolean>();
</script>

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

@ -1,7 +1,12 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<div class="flex items-center">
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<input
:id="id"
v-model.trim="data"
@ -12,7 +17,7 @@
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<string>();
</script>

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

@ -0,0 +1,15 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 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>

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

@ -1,15 +1,14 @@
<template>
<DropdownMenuRoot v-model:open="toggleState">
<DropdownMenuTrigger>
<button
<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"
type="button"
>
<BaseAvatar class="h-8 w-8">
{{ fallbackName }}
</BaseAvatar>
{{ authStore.userData?.name }}
</button>
</span>
</DropdownMenuTrigger>
<DropdownMenuPortal>
@ -26,7 +25,7 @@
to="/"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Clients
{{ $t('pages.clients') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>
@ -34,7 +33,7 @@
to="/me"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Account
{{ $t('pages.me') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem
@ -47,7 +46,7 @@
to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Admin Panel
{{ $t('pages.admin.panel') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem>

42
src/app/composables/useSubmit.ts

@ -0,0 +1,42 @@
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types';
import { FetchError } from 'ofetch';
type RevertFn = () => Promise<void>;
export function useSubmit<
R extends NitroFetchRequest,
O extends NitroFetchOptions<R>,
>(url: R, options: O, revert: RevertFn, success?: string, error?: string) {
const toast = useToast();
const { t: $t } = useI18n();
return async () => {
try {
const res = await $fetch(url, options);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(res as any).success) {
throw new Error(error || $t('toast.errored'));
}
toast.showToast({
type: 'success',
message: success,
});
await revert();
} 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 revert();
}
};
}

11
src/app/pages/admin.vue

@ -5,7 +5,7 @@
<div class="mr-4 w-64 rounded-lg bg-white p-4 dark:bg-neutral-700">
<NuxtLink to="/admin">
<h2 class="mb-4 text-xl font-bold dark:text-neutral-200">
Admin Panel
{{ t('pages.admin.panel') }}
</h2>
</NuxtLink>
<div class="flex flex-col space-y-2">
@ -37,14 +37,15 @@
<script setup lang="ts">
const authStore = useAuthStore();
authStore.update();
const { t } = useI18n();
const route = useRoute();
const menuItems = [
{ id: '', name: 'General' },
{ id: 'config', name: 'Config' },
{ id: 'interface', name: 'Interface' },
{ id: 'hooks', name: 'Hooks' },
{ 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(() => {

75
src/app/pages/admin/config.vue

@ -2,79 +2,74 @@
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Connection</FormHeading>
<FormTextField id="host" v-model="data.host" label="Host" />
<FormNumberField id="port" v-model="data.port" label="Port" />
<FormHeading>{{ $t('admin.config.connection') }}</FormHeading>
<FormTextField
id="host"
v-model="data.host"
:label="$t('admin.config.host')"
:description="$t('admin.config.hostDesc')"
/>
<FormNumberField
id="port"
v-model="data.port"
:label="$t('admin.generic.port')"
:description="$t('admin.config.portDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormHeading :description="$t('admin.config.allowedIpsDesc')">{{
$t('admin.config.allowedIps')
}}</FormHeading>
<FormArrayField
v-model="data.defaultAllowedIps"
name="defaultAllowedIps"
/>
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormHeading :description="$t('admin.config.dnsDesc')">{{
$t('admin.config.dns')
}}</FormHeading>
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormHeading>{{ $t('admin.config.advanced') }}</FormHeading>
<FormNumberField
id="defaultMtu"
v-model="data.defaultMtu"
label="MTU"
:label="$t('admin.generic.mtu')"
:description="$t('admin.config.mtuDesc')"
/>
<FormNumberField
id="defaultPersistentKeepalive"
v-model="data.defaultPersistentKeepalive"
label="Persistent Keepalive"
:label="$t('admin.config.persistentKeepalive')"
:description="$t('admin.config.persistentKeepaliveDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<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 toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/userconfig`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/userconfig`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
// TODO: avoid refreshNuxtData
await revert();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
await revert();
}
}
const submit = useSubmit(
`/api/admin/userconfig`,
{
method: 'post',
body: data.value,
},
revert
);
async function revert() {
await refresh();

6
src/app/pages/admin/hooks.vue

@ -8,9 +8,9 @@
<FormTextField id="PostDown" v-model="data.postDown" label="PostDown" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormActionField type="submit" :label="$t('form.save')" />
<FormActionField :label="$t('form.revert')" @click="revert" />
</FormGroup>
</FormElement>
</main>

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

@ -5,25 +5,29 @@
<FormNumberField
id="session"
v-model="data.sessionTimeout"
:label="$t('general.sessionTimeout')"
:label="$t('admin.general.sessionTimeout')"
:description="$t('admin.general.sessionTimeoutDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('general.metrics') }}</FormHeading>
<FormHeading>{{ $t('admin.general.metrics') }}</FormHeading>
<FormNullTextField
id="password"
v-model="data.metricsPassword"
:label="$t('passsword')"
:label="$t('admin.general.metricsPassword')"
:description="$t('admin.general.metricsPasswordDesc')"
/>
<FormSwitchField
id="prometheus"
v-model="data.metricsPrometheus"
:label="$t('general.prometheus')"
:label="$t('admin.general.prometheus')"
:description="$t('admin.general.prometheusDesc')"
/>
<FormSwitchField
id="json"
v-model="data.metricsJson"
:label="$t('general.json')"
:label="$t('admin.general.json')"
:description="$t('admin.general.jsonDesc')"
/>
</FormGroup>
<FormGroup>
@ -36,39 +40,19 @@
</template>
<script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/general`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
const submit = useSubmit(
`/api/admin/general`,
{
method: 'post',
body: data.value,
},
revert
);
async function revert() {
await refresh();

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

@ -2,22 +2,39 @@
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Interface Settings</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField id="port" v-model="data.port" label="Port" />
<FormTextField id="device" v-model="data.device" label="Device" />
<FormNumberField
id="mtu"
v-model="data.mtu"
:label="$t('admin.generic.mtu')"
:description="$t('admin.interface.mtuDesc')"
/>
<FormNumberField
id="port"
v-model="data.port"
:label="$t('admin.generic.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>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<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="Change CIDR" class="w-full" />
<FormActionField
:label="$t('admin.interface.changeCidr')"
class="w-full"
/>
</AdminCidrDialog>
</FormGroup>
</FormElement>
@ -25,7 +42,7 @@
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
method: 'get',
@ -33,31 +50,14 @@ const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/interface`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
const submit = useSubmit(
`/api/admin/interface`,
{
method: 'post',
body: data.value,
},
revert
);
async function revert() {
await refresh();
@ -65,28 +65,16 @@ async function revert() {
}
async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
try {
const res = await $fetch(`/api/admin/interface/cidr`, {
const _changeCidr = useSubmit(
`/api/admin/interface/cidr`,
{
method: 'post',
body: { ipv4Cidr, ipv6Cidr },
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Changed CIDR',
});
if (!res.success) {
throw new Error('Failed to change CIDR');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
},
revert,
t('admin.interface.cidrSuccess'),
t('admin.interface.cidrError')
);
await _changeCidr();
}
</script>

1
src/app/pages/login.vue

@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
// TODO: improve
import { FetchError } from 'ofetch';
const { t } = useI18n();

37
src/app/stores/toast.ts

@ -1,4 +1,6 @@
export const useToast = defineStore('Toast', () => {
const { t: $t } = useI18n();
type ToastInterface = {
publish: (e: { title: string; message: string }) => void;
};
@ -11,15 +13,32 @@ export const useToast = defineStore('Toast', () => {
toast.value = toastInstance;
}
function showToast({
title,
message,
}: {
type: 'success' | 'error';
title: string;
message: string;
}) {
toast.value?.value?.publish({ title, message });
type ShowToast =
| {
type: 'success';
title?: string;
message?: string;
}
| {
type: 'error';
title?: string;
message: string;
};
function showToast({ type, title, message }: ShowToast) {
if (type === 'success') {
if (!title) {
title = $t('toast.success');
}
if (!message) {
message = $t('toast.saved');
}
} else if (type === 'error') {
if (!title) {
title = $t('toast.error');
}
}
toast.value?.value?.publish({ title: title ?? '', message: message ?? '' });
}
return { setToast, showToast };

55
src/i18n/locales/en.json

@ -1,7 +1,14 @@
{
"pages": {
"me": "Account",
"clients": "Clients"
"clients": "Clients",
"admin": {
"panel": "Admin Panel",
"general": "General",
"config": "Config",
"interface": "Interface",
"hooks": "Hooks"
}
},
"me": {
"sectionGeneral": "General",
@ -80,12 +87,58 @@
"clear": "Clear",
"login": "Log in error"
},
"toast": {
"success": "Success",
"saved": "Saved",
"error": "Error",
"errored": "Failed to save"
},
"form": {
"actions": "Actions",
"save": "Save",
"revert": "Revert"
},
"password": "Password",
"admin": {
"general": {
"sessionTimeout": "Session Timeout",
"sessionTimeoutDesc": "Session duration for Remember Me (seconds)",
"metrics": "Metrics",
"metricsPassword": "Password",
"metricsPasswordDesc": "Bearer Password for the metrics endpoint (argon2 hash)",
"json": "JSON",
"jsonDesc": "Route for metrics in JSON format",
"prometheus": "Prometheus",
"prometheusDesc": "Route for Prometheus metrics"
},
"config": {
"connection": "Connection",
"host": "Host",
"hostDesc": "Public hostname clients will connect to (invalidates config)",
"portDesc": "Public UDP port clients will connect to (invalidates config)",
"allowedIps": "Allowed IPs",
"allowedIpsDesc": "Allowed IPs clients will use (invalidates config)",
"dns": "DNS",
"dnsDesc": "DNS server clients will use (invalidates config)",
"advanced": "Advanced",
"mtuDesc": "MTU clients will use (invalidates config)",
"persistentKeepalive": "Persistent Keepalive",
"persistentKeepaliveDesc": "Interval in seconds to send keepalives to the server. 0 = disabled (invalidates config)"
},
"interface": {
"cidrSuccess": "Changed CIDR",
"cidrError": "Failed to change CIDR",
"device": "Device",
"deviceDesc": "Ethernet device the wireguard traffic should be forwarded through",
"mtuDesc": "MTU WireGuard will use",
"portDesc": "UDP Port WireGuard will listen on (could invalidate config)",
"changeCidr": "Change CIDR"
},
"generic": {
"mtu": "MTU",
"port": "Port"
}
},
"zod": {
"generic": {
"required": "{0} is required",

Loading…
Cancel
Save