Merge pull request 'feature/#67-settings-store' (#90) from feature/#67-settings-store into develop

Reviewed-on: #90
This commit is contained in:
Julian Krauser 2025-05-01 15:48:37 +00:00
commit fa8f051252
49 changed files with 1663 additions and 139 deletions

View file

@ -1,5 +1 @@
VITE_SERVER_ADDRESS = backend_url #ohne pfad
VITE_APP_NAME_OVERWRITE = Mitgliederverwaltung # overwrites FF Admin
VITE_IMPRINT_LINK = https://mywebsite-imprint-url
VITE_PRIVACY_LINK = https://mywebsite-privacy-url
VITE_CUSTOM_LOGIN_MESSAGE = betrieben von xy

View file

@ -1,5 +1 @@
VITE_SERVER_ADDRESS = __SERVERADDRESS__
VITE_APP_NAME_OVERWRITE = __APPNAMEOVERWRITE__
VITE_IMPRINT_LINK = __IMPRINTLINK__
VITE_PRIVACY_LINK = __PRIVACYLINK__
VITE_CUSTOM_LOGIN_MESSAGE = __CUSTOMLOGINMESSAGE__

View file

@ -1,7 +1,7 @@
#!/bin/sh
keys="SERVERADDRESS APPNAMEOVERWRITE IMPRINTLINK PRIVACYLINK CUSTOMLOGINMESSAGE"
files="/usr/share/nginx/html/assets/config-*.js /usr/share/nginx/html/manifest.webmanifest"
keys="SERVERADDRESS"
files="/usr/share/nginx/html/assets/config-*.js"
# Replace env vars in files served by NGINX
for file in $files
@ -12,11 +12,6 @@ do
# Get environment variable
value=$(eval echo "\$$key")
# Set default value for APPNAMEOVERWRITE if empty
if [ "$key" = "APPNAMEOVERWRITE" ] && [ -z "$value" ]; then
value="FF Admin"
fi
echo "replace $key by $value"
# replace __[variable_name]__ value with environment variable

View file

@ -2,11 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- icon and manifest are provided by App.vue -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
// screen.orientation.lock("portrait-primary").catch(() => {});
</script>
</body>
</html>

7
package-lock.json generated
View file

@ -35,6 +35,7 @@
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
"pinia": "^3.0.2",
"pwacompat": "^2.0.17",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"socket.io-client": "^4.5.0",
@ -8752,6 +8753,12 @@
"node": ">=6"
}
},
"node_modules/pwacompat": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz",
"integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==",
"license": "Apache-2.0"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",

View file

@ -50,6 +50,7 @@
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
"pinia": "^3.0.2",
"pwacompat": "^2.0.17",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"socket.io-client": "^4.5.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View file

@ -8,6 +8,12 @@
</div>
<Footer @contextmenu.prevent />
<Notification />
<Teleport to="head">
<title>{{ clubName }}</title>
<link rel="icon" type="image/ico" :href="config.server_address + '/api/public/favicon.ico'" />
<link rel="manifest" :href="config.server_address + '/api/public/manifest.webmanifest'" />
</Teleport>
</template>
<script setup lang="ts">
@ -15,20 +21,25 @@ import { defineComponent } from "vue";
import { RouterView } from "vue-router";
import Header from "./components/Header.vue";
import Footer from "./components/Footer.vue";
import { mapState } from "pinia";
import { mapActions, mapState } from "pinia";
import { useAuthStore } from "./stores/auth";
import { isAuthenticatedPromise } from "./router/authGuard";
import ContextMenu from "./components/ContextMenu.vue";
import Modal from "./components/Modal.vue";
import Notification from "./components/Notification.vue";
import { config } from "./config";
import { useConfigurationStore } from "@/stores/configuration";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),
...mapState(useConfigurationStore, ["clubName"]),
},
mounted() {
this.configure();
if (!this.authCheck && localStorage.getItem("access_token")) {
isAuthenticatedPromise().catch(() => {
localStorage.removeItem("access_token");
@ -36,5 +47,8 @@ export default defineComponent({
});
}
},
methods: {
...mapActions(useConfigurationStore, ["configure"]),
},
});
</script>

View file

@ -0,0 +1,26 @@
<template>
<img ref="icon" :src="url + '/api/public/icon.png'" alt="LOGO" class="h-full w-auto" />
</template>
<script setup lang="ts">
import { url } from "@/serverCom";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapState } from "pinia";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
watch: {
icon() {
(this.$refs.icon as HTMLImageElement).src = url + "/api/public/icon.png?" + new Date().getTime();
},
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
icon() {
return this.readSetting("club.icon");
},
},
});
</script>

View file

@ -0,0 +1,26 @@
<template>
<img ref="logo" :src="url + '/api/public/applogo.png'" alt="LOGO" class="h-full w-auto" />
</template>
<script setup lang="ts">
import { url } from "@/serverCom";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapState } from "pinia";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
watch: {
logo() {
(this.$refs.logo as HTMLImageElement).src = url + "/api/public/applogo.png?t=" + new Date().getTime();
},
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
logo() {
return this.readSetting("club.logo");
},
},
});
</script>

View file

@ -0,0 +1,53 @@
<template>
<div class="w-full flex flex-row items-center">
<div class="contents" v-for="(i, index) in total" :key="index">
<div
v-if="index <= successfull && index != step"
class="relative flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
>
<SuccessCheckmark class="h-8! asolute top-0 m-0!" />
</div>
<div
v-else-if="index <= step"
class="flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
>
<div class="h-2 w-2 border-4 border-success bg-success rounded-full"></div>
</div>
<div v-else class="h-8 w-8 border-4 border-gray-400 rounded-full"></div>
<div v-if="i != total" class="grow border-2" :class="index < step ? ' border-success' : 'border-gray-400'"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import SuccessCheckmark from "./SuccessCheckmark.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
step: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
successfull: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
total: {
type: Number,
default: 1,
validator(value: number) {
return value >= 1;
},
},
},
});
</script>

View file

@ -1,10 +1,11 @@
<template>
<div class="flex flex-col text-gray-400 text-sm mt-4 items-center">
<div class="flex flex-row gap-2 justify-center">
<a v-if="config.imprint_link" :href="config.imprint_link" target="_blank">Datenschutz</a>
<a v-if="config.privacy_link" :href="config.privacy_link" target="_blank">Impressum</a>
<p v-if="appCustom_login_message">{{ appCustom_login_message }}</p>
<div class="flex flex-row gap-2 justify-center mb-3">
<a v-if="clubWebsite" :href="clubWebsite" target="_blank">Webseite</a>
<a v-if="clubImprint" :href="clubImprint" target="_blank">Datenschutz</a>
<a v-if="clubPrivacy" :href="clubPrivacy" target="_blank">Impressum</a>
</div>
<p v-if="config.custom_login_message">{{ config.custom_login_message }}</p>
<p>
<a href="https://ff-admin.de/admin" target="_blank">FF Admin</a>
entwickelt von
@ -14,5 +15,21 @@
</template>
<script setup lang="ts">
import { config } from "@/config";
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useConfigurationStore } from "@/stores/configuration";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useConfigurationStore, [
"appCustom_login_message",
"appShow_link_to_calendar",
"clubImprint",
"clubPrivacy",
"clubWebsite",
]),
},
});
</script>

View file

@ -1,9 +1,9 @@
<template>
<header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-xs">
<RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full">
<img src="/Logo.png" alt="LOGO" class="h-full w-auto" />
<AppLogo />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">
{{ config.app_name_overwrite || "FF Admin" }}
{{ clubName }}
</h1>
</RouterLink>
<div class="flex flex-row gap-2 items-center">
@ -37,15 +37,17 @@ import { useAuthStore } from "@/stores/auth";
import { useNavigationStore } from "@/stores/admin/navigation";
import TopLevelLink from "./admin/TopLevelLink.vue";
import UserMenu from "./UserMenu.vue";
import { config } from "@/config";
</script>
<script lang="ts">
import { defineComponent } from "vue";
import AppLogo from "./AppLogo.vue";
import { useConfigurationStore } from "../stores/configuration";
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),
...mapState(useNavigationStore, ["topLevel"]),
...mapState(useConfigurationStore, ["clubName"]),
routeName() {
return typeof this.$route.name == "string" ? this.$route.name : "";
},

View file

@ -25,7 +25,7 @@
<button button primary @click="close">Mein Account</button>
</RouterLink>
</MenuItem>
<MenuItem v-slot="{ close }">
<MenuItem v-if="false" v-slot="{ close }">
<RouterLink to="/docs" target="_blank">
<button button primary @click="close">Dokumentation</button>
</RouterLink>

View file

@ -0,0 +1,67 @@
<template>
<BaseSetting title="Anwendungs Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="custom_login_message">Nachricht unter Login (optional)</label>
<input
id="custom_login_message"
type="text"
:readonly="!enableEdit"
:value="appSettings['app.custom_login_message']"
/>
</div>
<div class="w-full flex flex-row items-center gap-2">
<div
v-if="!enableEdit"
class="border-2 border-gray-500 rounded-sm"
:class="appSettings['app.show_link_to_calendar'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
>
<CheckIcon v-if="appSettings['app.show_link_to_calendar']" class="h-2.5 w-2.5 stroke-4 text-white" />
</div>
<input v-else id="show_link_to_calendar" type="checkbox" :checked="appSettings['app.show_link_to_calendar']" />
<label for="show_link_to_calendar">Kalender-Link anzeigen</label>
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { CheckIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
appSettings() {
return this.readByTopic("app");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "app.custom_login_message",
value: formData.custom_login_message.value || null,
},
{
key: "app.show_link_to_calendar",
value: formData.show_link_to_calendar.checked || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,53 @@
<template>
<BaseSetting title="Backup Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="copies">Anzahl paralleler Backups (optional)</label>
<input id="copies" type="text" :readonly="!enableEdit" :value="backupSettings['backup.copies']" />
</div>
<div class="w-full">
<label for="interval">Intervall zur Backup-Erstellung (optional)</label>
<input id="interval" type="text" :readonly="!enableEdit" :value="backupSettings['backup.interval']" /></div
></BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
backupSettings() {
return this.readByTopic("backup");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "backup.copies",
value: formData.copies.value || null,
},
{
key: "backup.interval",
value: formData.interval.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,87 @@
<template>
<form ref="form" class="flex flex-col w-full" @submit.prevent="submit">
<div class="flex flex-row gap-2 items-center border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
<p class="text-lg font-semibold grow">{{ title }}</p>
<Spinner v-if="status == 'loading'" />
<SuccessCheckmark v-else-if="status == 'success'" />
<FailureXMark v-else-if="status == 'failed'" />
<div v-else-if="enableEdit" class="flex flex-row gap-2">
<button type="submit" class="!w-fit !h-fit !p-0">
<CheckIcon class="h-5 w-5 cursor-pointer" />
</button>
<button
type="reset"
class="!w-fit !h-fit !p-0"
@click="
enableEdit = false;
$emit('reset');
"
>
<XMarkIcon class="h-5 w-5 cursor-pointer" />
</button>
</div>
<PencilSquareIcon
v-else-if="can('create', 'management', 'setting')"
class="h-5 w-5 cursor-pointer"
@click="enableEdit = true"
/>
</div>
<div class="border-l-3 border-l-primary p-2 rounded-b-lg">
<slot :enableEdit="enableEdit"></slot>
</div>
</form>
</template>
<script setup lang="ts">
import FailureXMark from "@/components/FailureXMark.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import { useAbilityStore } from "@/stores/ability";
import { CheckIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import type { PropType } from "vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
title: {
type: String,
required: true,
},
submitFunction: {
type: Function as PropType<(e: any) => Promise<any>>,
required: true,
},
},
emits: ["reset"],
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
submit(e: any) {
this.status = "loading";
this.submitFunction(e)
.then(() => {
this.status = "success";
})
.catch(() => {
this.status = "failed";
})
.finally(() => {
setTimeout(() => {
if (this.status == "success") this.enableEdit = false;
this.status = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,152 @@
<template>
<BaseSetting title="Vereins-Auftritt Einstellungen" :submit-function="submit" v-slot="{ enableEdit }" @reset="reset">
<div class="w-full">
<p>Vereins-Icon</p>
<div class="flex flex-row gap-2">
<AppIcon v-if="icon != '' && !overwriteIcon" class="h-10! max-w-full mx-auto" />
<div
v-else-if="!overwriteIcon"
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
:class="{ 'cursor-pointer': enableEdit }"
@click="enableEdit ? ($refs.icon as HTMLInputElement).click() : null"
>
Kein eigenes Icon ausgewählt
</div>
<img ref="icon_img" class="hidden w-full h-10 object-contain" />
<XMarkIcon
v-if="enableEdit && (icon != '' || overwriteIcon)"
class="h-5 w-5 cursor-pointer"
@click="resetImage('icon')"
/>
</div>
<input class="hidden!" type="file" ref="icon" accept="image/png" @change="previewImage('icon')" />
</div>
<div class="w-full">
<p>Vereins-Logo</p>
<div class="flex flex-row gap-2">
<AppLogo v-if="logo != '' && !overwriteLogo" class="h-10! max-w-full mx-auto" />
<div
v-else-if="!overwriteLogo"
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
:class="{ 'cursor-pointer': enableEdit }"
@click="enableEdit ? ($refs.logo as HTMLInputElement).click() : null"
>
Kein eigenes Logo ausgewählt
</div>
<img ref="logo_img" class="hidden w-full h-10 object-contain" />
<XMarkIcon
v-if="enableEdit && (logo != '' || overwriteLogo)"
class="h-5 w-5 cursor-pointer"
@click="resetImage('logo')"
/>
</div>
<input class="hidden!" type="file" ref="logo" accept="image/png" @change="previewImage('logo')" />
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import AppIcon from "@/components/AppIcon.vue";
import AppLogo from "@/components/AppLogo.vue";
import { useAbilityStore } from "@/stores/ability";
import type { SettingString } from "@/types/settingTypes";
import BaseSetting from "./BaseSetting.vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
watch: {
clubSettings() {
this.reset();
},
},
data() {
return {
logo: "",
icon: "",
overwriteIcon: false as boolean,
overwriteLogo: false as boolean,
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
clubSettings() {
return this.readByTopic("club");
},
},
mounted() {
this.reset();
},
methods: {
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
reset() {
this.icon = this.clubSettings["club.icon"];
this.overwriteIcon = false;
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
(this.$refs.icon as HTMLInputElement).value = "";
this.logo = this.clubSettings["club.logo"];
this.overwriteLogo = false;
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
(this.$refs.logo as HTMLInputElement).value = "";
},
resetImage(inputname: "icon" | "logo") {
if (inputname == "icon") {
this.icon = "";
this.overwriteIcon = false;
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
(this.$refs.icon as HTMLInputElement).value = "";
} else {
this.logo = "";
this.overwriteLogo = false;
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
(this.$refs.logo as HTMLInputElement).value = "";
}
},
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
if (inputname == "icon") {
this.overwriteIcon = true;
} else {
this.overwriteLogo = true;
}
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
submit(e: any) {
return this.uploadImage([
{
key: "club.icon",
value:
(this.$refs.icon as HTMLInputElement).files?.[0] ??
(this.icon != "" && !this.overwriteIcon ? "keep" : undefined),
},
{
key: "club.logo",
value:
(this.$refs.logo as HTMLInputElement).files?.[0] ??
(this.logo != "" && !this.overwriteLogo ? "keep" : undefined),
},
]);
},
},
});
</script>

View file

@ -0,0 +1,89 @@
<template>
<BaseSetting title="Vereins Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="clubname">Vereins-Name (optional)</label>
<input id="clubname" type="text" :readonly="!enableEdit" :value="clubSettings['club.name']" />
</div>
<div class="w-full">
<label for="imprint">Vereins-Impressum Link (optional)</label>
<input id="imprint" type="url" :readonly="!enableEdit" :value="clubSettings['club.imprint']" />
</div>
<div class="w-full">
<label for="privacy">Vereins-Datenschutz Link (optional)</label>
<input id="privacy" type="url" :readonly="!enableEdit" :value="clubSettings['club.privacy']" />
</div>
<div class="w-full">
<label for="website">Vereins-Webseite Link (optional)</label>
<input id="website" type="url" :readonly="!enableEdit" :value="clubSettings['club.website']" /></div
></BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import AppIcon from "@/components/AppIcon.vue";
import AppLogo from "@/components/AppLogo.vue";
import { useAbilityStore } from "@/stores/ability";
import type { SettingString } from "@/types/settingTypes";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
overwriteIcon: false as boolean,
overwriteLogo: false as boolean,
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
clubSettings() {
return this.readByTopic("club");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "club.name",
value: formData.clubname.value || null,
},
{
key: "club.imprint",
value: formData.imprint.value || null,
},
{
key: "club.privacy",
value: formData.privacy.value || null,
},
{
key: "club.website",
value: formData.website.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,100 @@
<template>
<BaseSetting title="E-Mail Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="email">Mailadresse</label>
<input id="email" type="email" autocomplete="email" :readonly="!enableEdit" :value="mailSettings['mail.email']" />
</div>
<div class="w-full">
<label for="username">Benutzername</label>
<input
id="username"
type="text"
:readonly="!enableEdit"
autocomplete="username"
:value="mailSettings['mail.username']"
/>
</div>
<div class="w-full">
<label for="host">Server-Host</label>
<input id="host" type="text" :readonly="!enableEdit" :value="mailSettings['mail.host']" />
</div>
<div class="w-full">
<label for="port">Server-Port (25, 465, 587)</label>
<input id="port" type="number" :readonly="!enableEdit" :value="mailSettings['mail.port']" />
</div>
<div class="w-full flex flex-row items-center gap-2">
<div
v-if="!enableEdit"
class="border-2 border-gray-500 rounded-sm"
:class="mailSettings['mail.secure'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
>
<CheckIcon v-if="mailSettings['mail.secure']" class="h-2.5 w-2.5 stroke-4 text-white" />
</div>
<input v-else id="secure" type="checkbox" :checked="mailSettings['mail.secure']" />
<label for="secure">Secure-Verbindung (setzen bei Port 465)</label>
</div>
<div class="w-full">
<label for="password">Passwort (optional - leeres Feld setzt Passwort nicht zurück)</label>
<input id="password" type="password" :readonly="!enableEdit" autocomplete="new-password" />
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { CheckIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import { useAbilityStore } from "@/stores/ability";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
mailSettings() {
return this.readByTopic("mail");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "mail.email",
value: formData.email.value,
},
{
key: "mail.username",
value: formData.username.value,
},
{
key: "mail.host",
value: formData.host.value,
},
{
key: "mail.port",
value: formData.port.value,
},
{
key: "mail.secure",
value: formData.secure.checked,
},
{
key: "mail.password",
value: formData.password.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,76 @@
<template>
<BaseSetting title="Login-Session Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="jwt_expiration">JWT-Gültigkeitsdauer (optional)</label>
<input
id="jwt_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.jwt_expiration']"
/>
</div>
<div class="w-full">
<label for="refresh_expiration">Session-Gültigkeitsdauer (optional)</label>
<input
id="refresh_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.refresh_expiration']"
/>
</div>
<div class="w-full">
<label for="pwa_refresh_expiration">Sesion-Gültigkeitsdauer PWA (optional)</label>
<input
id="pwa_refresh_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.pwa_refresh_expiration']"
/></div
></BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
sessionSettings() {
return this.readByTopic("session");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "session.jwt_expiration",
value: formData.jwt_expiration.value || null,
},
{
key: "session.refresh_expiration",
value: formData.refresh_expiration.value || null,
},
{
key: "session.pwa_refresh_expiration",
value: formData.pwa_refresh_expiration.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,70 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Admin Account</p>
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div>
<div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div>
<div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Admin-Account anlegen
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["createAdmin"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.createAdmin({
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,66 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">App Konfiguration</p>
<div class="-space-y-px">
<div>
<input id="login_message" name="login_message" type="text" placeholder="Nachricht unter Login (optional)" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="show_cal_link" checked />
<label for="show_cal_link">Link zum Kalender anzeigen (optional)</label>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('app')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Anwendungsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setApp", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setApp({
login_message: formData.login_message.value,
show_cal_link: formData.show_cal_link.checked,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,99 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereinsdaten</p>
<div class="-space-y-px">
<div>
<input
id="name"
name="name"
type="text"
placeholder="Feuerwehr-/Vereinsname (optional)"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="imprint"
name="imprint"
type="url"
placeholder="Link zum Impressum (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="privacy"
name="privacy"
type="url"
placeholder="Link zur Datenschutzerklärung (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="website"
name="website"
type="url"
placeholder="Link zur Webseite (optional)"
class="rounded-t-none!"
/>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('club')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Vereinsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClub", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setClub({
name: formData.name.value,
imprint: formData.imprint.value,
privacy: formData.privacy.value,
website: formData.website.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,3 @@
<template>
<p class="text-center">Sie haben einen Verifizierungslink per Mail erhalten.</p>
</template>

View file

@ -0,0 +1,87 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereins-Auftritt</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<p>quadratisches Icon für App (optional)</p>
<img ref="icon_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="icon" accept="image/*" @change="previewImage('icon')" />
<button type="button" primary-outline @click="($refs.icon as HTMLInputElement).click()">Icon auswählen</button>
</div>
<div class="flex flex-col gap-2">
<p>Logo (optional)</p>
<img ref="logo_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="logo" accept="image/*" @change="previewImage('logo')" />
<button type="button" primary-outline @click="($refs.logo as HTMLInputElement).click()">Logo auswählen</button>
</div>
</div>
<br />
<p class="text-primary cursor-pointer ml-auto" @click="skip('appImages')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Bilder speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClubImages", "skip"]),
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
setup(e: any) {
this.setupStatus = "loading";
this.setupMessage = "";
this.setClubImages({
icon: (this.$refs.icon as HTMLInputElement).files?.[0],
logo: (this.$refs.logo as HTMLInputElement).files?.[0],
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Mailversand</p>
<div class="-space-y-px">
<div class="mb-2">
<input id="mail" name="mail" type="email" placeholder="Mailadresse" required autocomplete="email" />
</div>
<div>
<input
id="user"
name="user"
type="text"
placeholder="Benutzername (kann auch Mail sein)"
required
autocomplete="username"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="password"
name="password"
type="password"
placeholder="Passwort"
required
autocomplete="new-password"
class="rounded-none!"
/>
</div>
<div>
<input id="host" name="host" type="text" placeholder="Server-Host" required class="rounded-none!" />
</div>
<div>
<input id="port" name="port" type="number" placeholder="Port (25, 465, 587)" required class="rounded-t-none!" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="secure" />
<label for="secure">SSL-Verbindung (setzen bei Port 465)</label>
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Mailversand speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setMailConfig", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setMailConfig({
host: formData.host.value,
port: formData.port.value,
secure: formData.secure.checked,
mail: formData.mail.value,
username: formData.user.value,
password: formData.password.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -1,15 +1,7 @@
export interface Config {
server_address: string;
app_name_overwrite: string;
imprint_link: string;
privacy_link: string;
custom_login_message: string;
}
export const config: Config = {
server_address: import.meta.env.VITE_SERVER_ADDRESS,
app_name_overwrite: import.meta.env.VITE_APP_NAME_OVERWRITE,
imprint_link: import.meta.env.VITE_IMPRINT_LINK,
privacy_link: import.meta.env.VITE_PRIVACY_LINK,
custom_login_message: import.meta.env.VITE_CUSTOM_LOGIN_MESSAGE,
};

View file

@ -18,7 +18,7 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #388994;
--success: #73ad0f;
--success: #7ac142;
}
.dark {
--primary: #ff0d00;
@ -27,7 +27,7 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #4ccbda;
--success: #73ad0f;
--success: #7ac142;
}
}

View file

@ -9,6 +9,9 @@ import "../node_modules/nprogress/nprogress.css";
import { http } from "./serverCom";
import "./main.css";
// auto generates splash screen for iOS
import "pwacompat";
NProgress.configure({ showSpinner: false });
const app = createApp(App);

View file

@ -2,14 +2,12 @@ import { createRouter, createWebHistory } from "vue-router";
import Login from "@/views/Login.vue";
import { isAuthenticated } from "./authGuard";
import { loadAccountData } from "./accountGuard";
import { isSetup } from "./setupGuard";
import { abilityAndNavUpdate } from "./adminGuard";
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
import { resetMemberStores, setMemberId } from "./memberGuard";
import { resetProtocolStores, setProtocolId } from "./protocolGuard";
import { resetNewsletterStores, setNewsletterId } from "./newsletterGuard";
import { config } from "../config";
import { setBackupPage } from "./backupGuard";
const router = createRouter({
@ -642,6 +640,13 @@ const router = createRouter({
},
],
},
{
path: "settings",
name: "admin-management-setting",
component: () => import("@/views/admin/management/setting/Setting.vue"),
meta: { type: "read", section: "management", module: "setting" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: "backup",
name: "admin-management-backup-route",
@ -777,10 +782,6 @@ const router = createRouter({
],
});
router.afterEach((to, from) => {
document.title = config.app_name_overwrite || "FF Admin";
});
export default router;
declare module "vue-router" {

View file

@ -135,4 +135,4 @@ async function* streamingFetch(path: string, abort?: AbortController) {
}
}
export { http, newEventSource, streamingFetch, host };
export { http, newEventSource, streamingFetch, host, url };

View file

@ -0,0 +1,103 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { SettingString, SettingTopic, SettingValueMapping } from "@/types/settingTypes";
import type { AxiosResponse } from "axios";
export const useSettingStore = defineStore("setting", {
state: () => {
return {
settings: {} as { [key in SettingString]: SettingValueMapping[key] },
loading: "loading" as "loading" | "fetched" | "failed",
};
},
getters: {
readSetting:
(state) =>
<K extends SettingString>(key: K): SettingValueMapping[K] => {
return state.settings[key];
},
readByTopic:
(state) =>
<T extends SettingTopic>(
topic: T
): { [K in SettingString as K extends `${T}.${string}` ? K : never]: SettingValueMapping[K] } => {
return Object.entries(state.settings).reduce((acc, [key, value]) => {
const typedKey = key as SettingString;
if (key.startsWith(topic)) {
acc[typedKey] = value;
}
return acc;
}, {} as any);
},
},
actions: {
fetchSettings() {
this.loading = "loading";
http
.get("/admin/setting")
.then((result) => {
this.settings = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
async getSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/setting/${key}`).then((res) => {
//@ts-expect-error
this.settings[key] = res.data;
return res;
});
},
async updateSetting<K extends SettingString>(
key: K,
value: SettingValueMapping[K]
): Promise<AxiosResponse<any, any>> {
return await http
.put("/admin/setting", {
setting: key,
value: value,
})
.then((res) => {
this.settings[key] = value;
return res;
});
},
async updateSettings<K extends SettingString>(
data: { key: K; value: SettingValueMapping[K] }[]
): Promise<AxiosResponse<any, any>> {
return await http.put("/admin/setting/multi", data).then((res) => {
for (const element of data) {
this.settings[element.key] = element.value;
}
return res;
});
},
async uploadImage(
data: { key: "club.logo" | "club.icon"; value?: File | "keep" }[]
): Promise<AxiosResponse<any, any>> {
const formData = new FormData();
for (let entry of data) {
if (entry.value) {
formData.append(typeof entry.value == "string" ? entry.key : entry.key.split(".")[1], entry.value);
}
}
return await http
.put("/admin/setting/images", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((res) => {
for (const element of data) {
this.settings[element.key] = element.value ? "configured" : "";
}
return res;
});
},
async resetSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
return await http.delete(`/admin/setting/${key}`);
},
},
});

View file

@ -137,6 +137,7 @@ export const useNavigationStore = defineStore("navigation", {
...(abilityStore.can("read", "management", "user") ? [{ key: "user", title: "Benutzer" }] : []),
...(abilityStore.can("read", "management", "role") ? [{ key: "role", title: "Rollen" }] : []),
...(abilityStore.can("read", "management", "webapi") ? [{ key: "webapi", title: "Webapi-Token" }] : []),
...(abilityStore.can("read", "management", "setting") ? [{ key: "setting", title: "Einstellungen" }] : []),
...(abilityStore.can("read", "management", "backup") ? [{ key: "backup", title: "Backups" }] : []),
...(abilityStore.isAdmin() ? [{ key: "version", title: "Version" }] : []),
],

View file

@ -0,0 +1,34 @@
import { defineStore } from "pinia";
import { http } from "../serverCom";
export const useConfigurationStore = defineStore("configuration", {
state: () => {
return {
clubName: "",
clubImprint: "",
clubPrivacy: "",
clubWebsite: "",
appCustom_login_message: "",
appShow_link_to_calendar: false as boolean,
serverOffline: false as boolean,
};
},
actions: {
configure() {
http
.get("/public/configuration")
.then((res) => {
this.clubName = res.data["club.name"];
this.clubImprint = res.data["club.imprint"];
this.clubPrivacy = res.data["club.privacy"];
this.clubWebsite = res.data["club.website"];
this.appCustom_login_message = res.data["app.custom_login_message"];
this.appShow_link_to_calendar = res.data["app.show_link_to_calendar"];
})
.catch(() => {
this.serverOffline = true;
});
},
},
});

130
src/stores/setup.ts Normal file
View file

@ -0,0 +1,130 @@
import { defineStore } from "pinia";
import { http } from "../serverCom";
import type { AxiosResponse } from "axios";
import { useConfigurationStore } from "./configuration";
export const useSetupStore = defineStore("setup", {
state: () => {
return {
dictionary: ["club", "clubImages", "app", "mail", "account", "finished"],
step: 0 as number,
successfull: 0 as number,
};
},
getters: {
stepIndex: (state) => (dict: string) => state.dictionary.findIndex((d) => d == dict),
},
actions: {
skip(dict: string) {
let myIndex = this.stepIndex(dict);
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex + 1;
}
},
async setClub(data: {
name?: string;
imprint?: string;
privacy?: string;
website?: string;
}): Promise<AxiosResponse<any, any>> {
let configStore = useConfigurationStore();
let myIndex = this.stepIndex("club");
const res = await http.post(`/setup/club`, {
name: data.name,
imprint: data.imprint,
privacy: data.privacy,
website: data.website,
});
configStore.configure();
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setClubImages(data: { icon?: File; logo?: File }): Promise<AxiosResponse<any, any>> {
let configStore = useConfigurationStore();
let myIndex = this.stepIndex("clubImages");
const formData = new FormData();
if (data.icon) {
formData.append("icon", data.icon);
}
if (data.logo) {
formData.append("logo", data.logo);
}
const res = await http.post(`/setup/club/images`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
configStore.configure();
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setApp(data: { login_message: string; show_cal_link: boolean }): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("app");
const res = await http.post(`/setup/app`, {
custom_login_message: data.login_message,
show_link_to_calendar: data.show_cal_link,
});
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setMailConfig(data: {
host: string;
port: number;
secure: boolean;
mail: string;
username: string;
password: string;
}): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("mail");
const res = await http.post(`/setup/mail`, {
host: data.host,
port: data.port,
secure: data.secure,
mail: data.mail,
username: data.username,
password: data.password,
});
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async createAdmin(credentials: {
username: string;
mail: string;
firstname: string;
lastname: string;
}): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("account");
const res = await http.post(`/setup/me`, {
username: credentials.username,
mail: credentials.mail,
firstname: credentials.firstname,
lastname: credentials.lastname,
});
this.step += 1;
if (this.successfull < myIndex) {
this.successfull = myIndex;
}
return res;
},
},
});

View file

@ -21,7 +21,8 @@ export type PermissionModule =
| "query_store"
| "template"
| "template_usage"
| "backup";
| "backup"
| "setting";
export type PermissionType = "read" | "create" | "update" | "delete";
@ -67,6 +68,7 @@ export const permissionModules: Array<PermissionModule> = [
"template",
"template_usage",
"backup",
"setting",
];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
@ -84,5 +86,5 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"template_usage",
"newsletter_config",
],
management: ["user", "role", "webapi", "backup"],
management: ["user", "role", "webapi", "backup", "setting"],
};

80
src/types/settingTypes.ts Normal file
View file

@ -0,0 +1,80 @@
export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security";
export type SettingString =
| "club.icon"
| "club.logo"
| "club.name"
| "club.imprint"
| "club.privacy"
| "club.website"
| "app.custom_login_message"
| "app.show_link_to_calendar"
| "session.jwt_expiration"
| "session.refresh_expiration"
| "session.pwa_refresh_expiration"
| "mail.email"
| "mail.username"
| "mail.password"
| "mail.host"
| "mail.port"
| "mail.secure"
| "backup.interval"
| "backup.copies";
export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url" | "email";
export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`;
export type SettingValueMapping = {
"club.icon": string;
"club.logo": string;
"club.name": string;
"club.imprint": string;
"club.privacy": string;
"club.website": string;
"app.custom_login_message": string;
"app.show_link_to_calendar": boolean;
"session.jwt_expiration": string;
"session.refresh_expiration": string;
"session.pwa_refresh_expiration": string;
"mail.email": string;
"mail.username": string;
"mail.password": string;
"mail.host": string;
"mail.port": number;
"mail.secure": boolean;
"backup.interval": number;
"backup.copies": number;
};
// Typsicherer Zugriff auf Settings
export type SettingDefinition<T extends SettingType | SettingTypeAtom[]> = {
type: T;
default?: string | number | boolean;
optional?: boolean;
min?: T extends "number" | `number/crypt` | `number/rand` ? number : never;
};
export type SettingsSchema = {
[key in SettingString]: SettingDefinition<SettingType | SettingTypeAtom[]>;
};
export const settingsType: SettingsSchema = {
"club.icon": { type: "string", optional: true },
"club.logo": { type: "string", optional: true },
"club.name": { type: "string", default: "FF Admin" },
"club.imprint": { type: "url", optional: true },
"club.privacy": { type: "url", optional: true },
"club.website": { type: "url", optional: true },
"app.custom_login_message": { type: "string", optional: true },
"app.show_link_to_calendar": { type: "boolean", default: true },
"session.jwt_expiration": { type: "ms", default: "15m" },
"session.refresh_expiration": { type: "ms", default: "1d" },
"session.pwa_refresh_expiration": { type: "ms", default: "5d" },
"mail.email": { type: "email", optional: false },
"mail.username": { type: "string", optional: false },
"mail.password": { type: "string/crypt", optional: false },
"mail.host": { type: "url", optional: false },
"mail.port": { type: "number", default: 587 },
"mail.secure": { type: "boolean", default: false },
"backup.interval": { type: "number", default: 1, min: 1 },
"backup.copies": { type: "number", default: 7, min: 1 },
};

View file

@ -2,9 +2,9 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">
{{ config.app_name_overwrite || "FF Admin" }}
{{ clubName }}
</h2>
</div>
@ -50,7 +50,9 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { resetAllPiniaStores } from "@/helpers/piniaReset";
import FormBottomBar from "@/components/FormBottomBar.vue";
import { config } from "@/config";
import AppLogo from "@/components/AppLogo.vue";
import { mapState } from "pinia";
import { useConfigurationStore } from "@/stores/configuration";
</script>
<script lang="ts">
@ -61,6 +63,9 @@ export default defineComponent({
loginError: "" as string,
};
},
computed: {
...mapState(useConfigurationStore, ["clubName"]),
},
mounted() {
resetAllPiniaStores();
},

View file

@ -1,11 +1,7 @@
<template>
<SidebarLayout>
<template #sidebar>
<SidebarTemplate
mainTitle="Mein Account"
:topTitle="config.app_name_overwrite || 'FF Admin'"
:showTopList="isOwner"
>
<SidebarTemplate mainTitle="Mein Account" :topTitle="clubName" :showTopList="isOwner">
<template v-if="isOwner" #topList>
<RoutingLink
title="Administration"
@ -42,13 +38,14 @@ import SidebarTemplate from "@/templates/Sidebar.vue";
import RoutingLink from "@/components/admin/RoutingLink.vue";
import { RouterView } from "vue-router";
import { useAbilityStore } from "@/stores/ability";
import { config } from "@/config";
import { useConfigurationStore } from "@/stores/configuration";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAbilityStore, ["isOwner"]),
...mapState(useConfigurationStore, ["clubName"]),
activeRouteName() {
return this.$route.name;
},

View file

@ -0,0 +1,46 @@
<template>
<MainTemplate>
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Einstellungen</h1>
</div>
</template>
<template #main>
<p>Hinweis: Optionale Felder können leer gelassen werden und nutzen dann einen Fallback-Werte.</p>
<ClubImageSetting />
<ClubSetting />
<AppSetting />
<MailSetting />
<SessionSetting />
<BackupSetting />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import ClubSetting from "@/components/admin/management/setting/ClubSetting.vue";
import AppSetting from "@/components/admin/management/setting/AppSetting.vue";
import MailSetting from "@/components/admin/management/setting/MailSetting.vue";
import SessionSetting from "@/components/admin/management/setting/SessionSetting.vue";
import BackupSetting from "@/components/admin/management/setting/BackupSetting.vue";
import ClubImageSetting from "@/components/admin/management/setting/ClubImageSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchSettings();
},
methods: {
...mapActions(useSettingStore, ["fetchSettings"]),
},
});
</script>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div>
@ -49,6 +49,7 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import FormBottomBar from "@/components/FormBottomBar.vue";
import TextCopy from "@/components/TextCopy.vue";
import AppLogo from "@/components/AppLogo.vue";
</script>
<script lang="ts">

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2>
</div>
@ -49,6 +49,7 @@ import FailureXMark from "@/components/FailureXMark.vue";
import { RouterLink } from "vue-router";
import FormBottomBar from "@/components/FormBottomBar.vue";
import TextCopy from "@/components/TextCopy.vue";
import AppLogo from "@/components/AppLogo.vue";
</script>
<script lang="ts">

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2>
</div>
@ -36,6 +36,7 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import FormBottomBar from "@/components/FormBottomBar.vue";
import AppLogo from "@/components/AppLogo.vue";
</script>
<script lang="ts">

View file

@ -2,36 +2,18 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div>
<CheckProgressBar :total="dictionary.length" :step="step" :successfull="successfull" />
<form class="flex flex-col gap-2" @submit.prevent="setup">
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div>
<div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div>
<div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Admin-Account anlegen
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
<Club v-if="step == stepIndex('club')" />
<Images v-else-if="step == stepIndex('clubImages')" />
<App v-else-if="step == stepIndex('app')" />
<Mail v-else-if="step == stepIndex('mail')" />
<Account v-else-if="step == stepIndex('account')" />
<Finished v-else-if="step == stepIndex('finished')" />
<p v-else class="text-center">UI-Fehler - versuchen Sie einen Reload der Seite</p>
<FormBottomBar />
</div>
@ -40,41 +22,23 @@
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import FormBottomBar from "@/components/FormBottomBar.vue";
import AppLogo from "@/components/AppLogo.vue";
import App from "@/components/setup/App.vue";
import Account from "@/components/setup/Account.vue";
import CheckProgressBar from "@/components/CheckProgressBar.vue";
import { mapState } from "pinia";
import { useSetupStore } from "@/stores/setup";
import Club from "@/components/setup/Club.vue";
import Images from "@/components/setup/Images.vue";
import Finished from "@/components/setup/Finished.vue";
import Mail from "../../components/setup/Mail.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.$http
.post(`/setup`, {
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
})
.then((result) => {
this.setupStatus = "success";
this.setupMessage = "Sie haben einen Verifizierungslink per Mail erhalten.";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
computed: {
...mapState(useSetupStore, ["step", "stepIndex", "successfull", "dictionary"]),
},
});
</script>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div>
@ -50,6 +50,7 @@ import FailureXMark from "@/components/FailureXMark.vue";
import { RouterLink } from "vue-router";
import FormBottomBar from "@/components/FormBottomBar.vue";
import TextCopy from "@/components/TextCopy.vue";
import AppLogo from "@/components/AppLogo.vue";
</script>
<script lang="ts">
@ -92,7 +93,7 @@ export default defineComponent({
this.setupStatus = "loading";
this.setupError = "";
this.$http
.put(`/setup`, {
.post(`/setup/finish`, {
token: this.token,
mail: this.mail,
totp: formData.totp.value,

View file

@ -39,27 +39,9 @@ export default defineConfig({
VitePWA({
registerType: "autoUpdate",
injectRegister: "auto",
includeAssets: ["favicon.png", "favicon.ico"],
manifest: {
name: "__APPNAMEOVERWRITE__",
short_name: "__APPNAMEOVERWRITE__",
theme_color: "#990b00",
display: "standalone",
start_url: "/",
icons: [
{
src: "favicon.ico",
sizes: "48x48",
type: "image/ico",
},
{
src: "favicon.png",
sizes: "512x512",
type: "image/png",
},
],
},
manifest: false,
workbox: {
navigateFallbackDenylist: [/^\/api*/],
runtimeCaching: [
{
urlPattern: /^\/api\//,