From 06380e48c5e05082c514c9c7efc8daa97d719371 Mon Sep 17 00:00:00 2001
From: Julian Krauser <jkrauser209@gmail.com>
Date: Tue, 29 Apr 2025 13:10:30 +0200
Subject: [PATCH] Settings form and handling

---
 .../admin/management/setting/AppSetting.vue   |  65 +++++++---
 .../management/setting/BackupSetting.vue      |  49 +++++---
 .../admin/management/setting/BaseSetting.vue  |  79 ++++++++++++
 .../management/setting/ClubImageSetting.vue   |  90 +++++++++++++
 .../admin/management/setting/ClubSetting.vue  | 107 ++++++++++------
 .../admin/management/setting/MailSetting.vue  | 119 ++++++++++++------
 .../management/setting/SessionSetting.vue     |  74 ++++++++---
 src/stores/admin/management/setting.ts        |  28 ++++-
 src/stores/configuration.ts                   |   6 +-
 .../admin/management/setting/Setting.vue      |   3 +
 10 files changed, 485 insertions(+), 135 deletions(-)
 create mode 100644 src/components/admin/management/setting/BaseSetting.vue
 create mode 100644 src/components/admin/management/setting/ClubImageSetting.vue

diff --git a/src/components/admin/management/setting/AppSetting.vue b/src/components/admin/management/setting/AppSetting.vue
index 234a4ab..787dbe1 100644
--- a/src/components/admin/management/setting/AppSetting.vue
+++ b/src/components/admin/management/setting/AppSetting.vue
@@ -1,42 +1,67 @@
 <template>
-  <div class="flex flex-col w-full">
-    <div class="border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
-      <p class="text-lg font-semibold">Anwendungs Einstellungen</p>
+  <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="border-l-3 border-l-primary p-2 rounded-b-lg">
-      <div class="w-full">
-        <label for="name">Vereins-Name</label>
-        <input id="name" type="text" readonly :value="appSettings['app.custom_login_message']" />
-      </div>
-      <div class="w-full flex flex-row items-center gap-2">
-        <div
-          v-if="true"
-          class="border-2 border-gray-500 rounded-sm"
-          :class="appSettings['app.show_link_to_calendar'] ? 'bg-gray-500' : 'h-3 w-3'"
-        >
-          <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="name" type="checkbox" :checked="appSettings['app.show_link_to_calendar']" />
-        <label for="name">Kalender-Link anzeigen</label>
+    <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>
-  </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 { mapState } from "pinia";
+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>
diff --git a/src/components/admin/management/setting/BackupSetting.vue b/src/components/admin/management/setting/BackupSetting.vue
index f18298a..f71a36a 100644
--- a/src/components/admin/management/setting/BackupSetting.vue
+++ b/src/components/admin/management/setting/BackupSetting.vue
@@ -1,34 +1,53 @@
 <template>
-  <div class="flex flex-col w-full">
-    <div class="border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
-      <p class="text-lg font-semibold">Backup Einstellungen</p>
+  <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="border-l-3 border-l-primary p-2 rounded-b-lg">
-      <div class="w-full">
-        <label for="name">Anzahl paralleler Backups</label>
-        <input id="name" type="text" readonly :value="backupSettings['backup.copies']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Intervall zur Backup-Erstellung</label>
-        <input id="name" type="text" readonly :value="backupSettings['backup.interval']" />
-      </div>
-    </div>
-  </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 { mapState } from "pinia";
+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>
diff --git a/src/components/admin/management/setting/BaseSetting.vue b/src/components/admin/management/setting/BaseSetting.vue
new file mode 100644
index 0000000..96c1308
--- /dev/null
+++ b/src/components/admin/management/setting/BaseSetting.vue
@@ -0,0 +1,79 @@
+<template>
+  <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">
+          <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,
+    },
+  },
+  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(() => {
+            this.enableEdit = false;
+            this.status = undefined;
+          }, 2000);
+        });
+    },
+  },
+});
+</script>
diff --git a/src/components/admin/management/setting/ClubImageSetting.vue b/src/components/admin/management/setting/ClubImageSetting.vue
new file mode 100644
index 0000000..e2ab129
--- /dev/null
+++ b/src/components/admin/management/setting/ClubImageSetting.vue
@@ -0,0 +1,90 @@
+<template>
+  <BaseSetting title="Vereins-Auftritt Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
+    <div class="w-full">
+      <p>Vereins-Icon</p>
+      <AppIcon v-if="clubSettings['club.icon'] != '' && !overwriteIcon" class="h-10! max-w-full mx-auto" />
+      <img v-else-if="overwriteIcon" ref="icon_img" class="hidden w-full h-20 object-contain" />
+      <div
+        v-else
+        class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm cursor-pointer"
+        @click="($refs.icon as HTMLInputElement).click()"
+      >
+        Kein eigenes Icon ausgewählt
+      </div>
+      <input class="hidden!" type="file" ref="icon" accept="image/*" @change="previewImage('icon')" />
+    </div>
+    <div class="w-full">
+      <p>Vereins-Logo</p>
+      <AppLogo v-if="clubSettings['club.logo'] != '' && !overwriteLogo" class="h-10! max-w-full mx-auto" />
+      <img v-else-if="overwriteLogo" ref="logo_img" class="hidden w-full h-20 object-contain" />
+      <div
+        v-else
+        class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm cursor-pointer"
+        @click="($refs.logo as HTMLInputElement).click()"
+      >
+        Kein eigenes Logo ausgewählt
+      </div>
+      <input class="hidden!" type="file" ref="logo" accept="image/*" @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";
+</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) {
+      return this.uploadImage([
+        {
+          key: "club.icon" as SettingString,
+          value: (this.$refs.icon as HTMLInputElement).files?.[0],
+        },
+        {
+          key: "club.logo" as SettingString,
+          value: (this.$refs.logo as HTMLInputElement).files?.[0],
+        },
+      ]);
+    },
+  },
+});
+</script>
diff --git a/src/components/admin/management/setting/ClubSetting.vue b/src/components/admin/management/setting/ClubSetting.vue
index 51dfb40..8b6cbcc 100644
--- a/src/components/admin/management/setting/ClubSetting.vue
+++ b/src/components/admin/management/setting/ClubSetting.vue
@@ -1,58 +1,89 @@
 <template>
-  <div class="flex flex-col w-full">
-    <div class="border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
-      <p class="text-lg font-semibold">Vereins Einstellungen</p>
+  <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="border-l-3 border-l-primary p-2 rounded-b-lg">
-      <div class="w-full">
-        <p>Vereins-Icon</p>
-        <AppIcon v-if="clubSettings['club.icon'] != ''" class="h-10! max-w-full mx-auto" />
-        <div v-else class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm">
-          Kein Icon hochgeladen
-        </div>
-      </div>
-      <div class="w-full">
-        <p>Vereins-Logo</p>
-        <AppLogo v-if="clubSettings['club.logo'] != ''" class="h-10! max-w-full mx-auto" />
-        <div v-else class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm">
-          Kein Logo hochgeladen
-        </div>
-      </div>
-      <div class="w-full">
-        <label for="name">Vereins-Name</label>
-        <input id="name" type="text" readonly :value="clubSettings['club.name']" />
-      </div>
-      <div class="w-full">
-        <label for="imprint">Vereins-Impressum Link</label>
-        <input id="imprint" type="url" readonly :value="clubSettings['club.imprint']" />
-      </div>
-      <div class="w-full">
-        <label for="icon">Vereins-Datenschutz Link</label>
-        <input id="privacy" type="url" readonly :value="clubSettings['club.privacy']" />
-      </div>
-      <div class="w-full">
-        <label for="website">Vereins-Webseite Link</label>
-        <input id="website" type="url" readonly :value="clubSettings['club.website']" />
-      </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>
+    <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 { useSettingStore } from "@/stores/admin/management/setting";
-import { mapState } from "pinia";
-import { defineComponent } from "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>
diff --git a/src/components/admin/management/setting/MailSetting.vue b/src/components/admin/management/setting/MailSetting.vue
index b945b2a..64ca03d 100644
--- a/src/components/admin/management/setting/MailSetting.vue
+++ b/src/components/admin/management/setting/MailSetting.vue
@@ -1,57 +1,100 @@
 <template>
-  <div class="flex flex-col w-full">
-    <div class="border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
-      <p class="text-lg font-semibold">E-Mail Einstellungen</p>
+  <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="border-l-3 border-l-primary p-2 rounded-b-lg">
-      <div class="w-full">
-        <label for="name">Mailadresse</label>
-        <input id="name" type="text" readonly :value="mailSettings['mail.email']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Benutzername</label>
-        <input id="name" type="text" readonly :value="mailSettings['mail.username']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Server-Host</label>
-        <input id="name" type="text" readonly :value="mailSettings['mail.host']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Server-Port</label>
-        <input id="name" type="text" readonly :value="mailSettings['mail.port']" />
-      </div>
-      <div class="w-full flex flex-row items-center gap-2">
-        <div
-          v-if="true"
-          class="border-2 border-gray-500 rounded-sm"
-          :class="mailSettings['mail.secure'] ? 'bg-gray-500' : 'h-3 w-3'"
-        >
-          <CheckIcon v-if="mailSettings['mail.secure']" class="h-2.5 w-2.5 stroke-4 text-white" />
-        </div>
-        <input v-else id="name" type="checkbox" :checked="mailSettings['mail.secure']" />
-        <label for="name">Secure-Verbindung</label>
-      </div>
-      <div class="w-full">
-        <label for="name">Passwort</label>
-        <input id="name" type="password" readonly />
-      </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>
+    <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)</label>
+      <input id="password" type="password" :readonly="!enableEdit" autocomplete="new-password" />
+    </div>
+  </BaseSetting>
 </template>
 
 <script setup lang="ts">
-import { useSettingStore } from "@/stores/admin/management/setting";
-import { mapState } from "pinia";
 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>
diff --git a/src/components/admin/management/setting/SessionSetting.vue b/src/components/admin/management/setting/SessionSetting.vue
index e93aee8..712ea75 100644
--- a/src/components/admin/management/setting/SessionSetting.vue
+++ b/src/components/admin/management/setting/SessionSetting.vue
@@ -1,38 +1,76 @@
 <template>
-  <div class="flex flex-col w-full">
-    <div class="border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
-      <p class="text-lg font-semibold">Login-Session Einstellungen</p>
+  <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="border-l-3 border-l-primary p-2 rounded-b-lg">
-      <div class="w-full">
-        <label for="name">JWT-Gültigkeitsdauer</label>
-        <input id="name" type="text" readonly :value="sessionSettings['session.jwt_expiration']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Session-Gültigkeitsdauer</label>
-        <input id="name" type="text" readonly :value="sessionSettings['session.refresh_expiration']" />
-      </div>
-      <div class="w-full">
-        <label for="name">Sesion-Gültigkeitsdauer PWA</label>
-        <input id="name" type="text" readonly :value="sessionSettings['session.pwa_refresh_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>
+    <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 { mapState } from "pinia";
+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>
diff --git a/src/stores/admin/management/setting.ts b/src/stores/admin/management/setting.ts
index 174141a..defb78b 100644
--- a/src/stores/admin/management/setting.ts
+++ b/src/stores/admin/management/setting.ts
@@ -50,14 +50,32 @@ export const useSettingStore = defineStore("setting", {
         return res;
       });
     },
+    async uploadImage(data: { key: SettingString; value?: File }[]): Promise<AxiosResponse<any, any>> {
+      const formData = new FormData();
+      for (let entry of data) {
+        if (entry.value) formData.append(entry.key, entry.value);
+      }
+      return await http.put("/admin/setting/img", formData, {
+        headers: {
+          "Content-Type": "multipart/form-data",
+        },
+      });
+    },
+    async updateSettings<K extends SettingString>(
+      data: { key: K; value: SettingValueMapping[K] }[]
+    ): Promise<AxiosResponse<any, any>> {
+      return await http.put("/admin/setting", data);
+    },
     async updateSetting<K extends SettingString>(
       key: K,
-      val: SettingValueMapping[K]
+      value: SettingValueMapping[K]
     ): Promise<AxiosResponse<any, any>> {
-      return await http.put("/admin/setting", {
-        setting: key,
-        value: val,
-      });
+      return await http.put("/admin/setting", [
+        {
+          setting: key,
+          value: value,
+        },
+      ]);
     },
     async resetSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
       return await http.delete(`/admin/setting/${key}`);
diff --git a/src/stores/configuration.ts b/src/stores/configuration.ts
index c33803a..1e8bfd8 100644
--- a/src/stores/configuration.ts
+++ b/src/stores/configuration.ts
@@ -10,6 +10,8 @@ export const useConfigurationStore = defineStore("configuration", {
       clubWebsite: "",
       appCustom_login_message: "",
       appShow_link_to_calendar: false as boolean,
+
+      serverOffline: false as boolean,
     };
   },
   actions: {
@@ -24,7 +26,9 @@ export const useConfigurationStore = defineStore("configuration", {
           this.appCustom_login_message = res.data["app.custom_login_message"];
           this.appShow_link_to_calendar = res.data["app.show_link_to_calendar"];
         })
-        .catch(() => {});
+        .catch(() => {
+          this.serverOffline = true;
+        });
     },
   },
 });
diff --git a/src/views/admin/management/setting/Setting.vue b/src/views/admin/management/setting/Setting.vue
index 2220105..25e602a 100644
--- a/src/views/admin/management/setting/Setting.vue
+++ b/src/views/admin/management/setting/Setting.vue
@@ -6,6 +6,8 @@
       </div>
     </template>
     <template #main>
+      <p>Hinweis: Optionale Felder können leer gelassen werden und nutzen dann einen Fallback-Werte.</p>
+      <ClubImageSetting />
       <ClubSetting />
       <AppSetting />
       <MailSetting />
@@ -26,6 +28,7 @@ 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">