Compare commits

..

62 commits

Author SHA1 Message Date
4ebacc5f52 Merge branch 'develop' into milestone/ff-admin-unit 2025-05-09 11:25:31 +02:00
a39044dffc change: remove logging 2025-05-08 08:17:14 +02:00
f64397862c Merge branch 'main' into develop 2025-05-07 07:28:50 +00:00
18d52e4bab change: refactor imports 2025-05-07 09:20:32 +02:00
b4fdd5fc60 enhance: permission handling 2025-05-07 09:05:25 +02:00
c17355fcd1 change: request method for account credential change 2025-05-07 08:27:31 +02:00
fa5fb54876 update: ReadMe 2025-05-07 08:27:03 +02:00
ccc6d47d9e 1.5.0 2025-05-06 09:53:15 +02:00
0be649c3ba Merge pull request 'minor v1.5.0' (#92) from develop into main
Reviewed-on: #92
2025-05-06 07:52:22 +00:00
424f4772f0 Merge branch 'main' into develop 2025-05-06 07:52:03 +00:00
f01c895d30 update all packages 2025-05-06 09:30:50 +02:00
f04fabefb0 Merge pull request 'feature/#66-static-user-login' (#91) from feature/#66-static-user-login into develop
Reviewed-on: #91
2025-05-06 07:20:18 +00:00
625a2df308 fix: temporary fix on tailwind hover 2025-05-06 09:17:26 +02:00
f65b3108ee check if password and repeat match 2025-05-06 09:02:55 +02:00
196a92325a fix: redirect to nopermission screen 2025-05-06 08:38:49 +02:00
b39198c935 enable password on invite or reset 2025-05-06 08:38:28 +02:00
ee52363bde enable switch to pw totp in account settings 2025-05-05 17:44:03 +02:00
63d97d0b83 login by password or totp 2025-05-05 14:21:22 +02:00
9cf2cf2d80 fix: compatability layer for query builder joins 2025-05-02 09:16:29 +02:00
fa8f051252 Merge pull request 'feature/#67-settings-store' (#90) from feature/#67-settings-store into develop
Reviewed-on: #90
2025-05-01 15:48:37 +00:00
d5193842d2 Merge branch 'develop' into feature/#67-settings-store 2025-04-30 10:29:18 +00:00
bbf5b65aab enhance: provide latest inserted internal Id 2025-04-30 12:22:41 +02:00
eb622658d9 no form reset on failure 2025-04-30 11:36:48 +02:00
751370fed4 reload images if updated 2025-04-30 10:51:07 +02:00
939c982c40 image upload and keep if not changed 2025-04-30 10:43:11 +02:00
91ede95530 Image Upload 2025-04-29 18:32:08 +02:00
0771b43f56 change url 2025-04-29 13:19:50 +02:00
06380e48c5 Settings form and handling 2025-04-29 13:10:30 +02:00
6f155ada66 Display Settings 2025-04-28 14:36:47 +02:00
b7dd5a95cd settings Sceleton 2025-04-28 12:39:32 +02:00
9bd663f266 base settings operations 2025-04-26 09:21:27 +02:00
beaf6a5926 base structure settings inside Admin UI 2025-04-25 12:31:49 +02:00
e607f8c599 fix: false positive auth true by existing expired jwt 2025-04-25 12:22:04 +02:00
8880af2880 Setup wizard for Settings 2025-04-25 12:13:02 +02:00
5d9007f517 display app configuration values 2025-04-25 08:18:27 +02:00
20a2a3ccd0 move pwa manifest to backend 2025-04-24 16:49:14 +02:00
a20c0d3ed3 enhance: use QueryStoreId to fetch query 2025-04-19 10:05:01 +02:00
916e61897a Merge pull request 'feature/#87-newsletter-no-sendto-entry' (#88) from feature/#87-newsletter-no-sendto-entry into develop
Reviewed-on: #88
2025-04-19 07:43:49 +00:00
b19e8df561 add send none to newsletter config 2025-04-19 09:42:25 +02:00
fb78360946 show who does not have newsletter configured 2025-04-19 09:24:55 +02:00
caf1919930 1.4.1 2025-04-18 11:10:49 +02:00
d25fa07512 Merge pull request 'patches v1.4.1' (#85) from develop into main
Reviewed-on: #85
2025-04-18 09:10:26 +00:00
48502efc1d Merge branch 'main' into develop 2025-04-18 09:10:15 +00:00
9a9742597a fix: calendar height after custom calendar header 2025-04-17 15:34:27 +02:00
ea38b1835c 1.4.0 2025-04-16 17:02:28 +02:00
802b7d25f0 Merge pull request 'minor v1.4.0' (#84) from develop into main
Reviewed-on: #84
2025-04-16 15:01:07 +00:00
d1bde66e1e Merge branch 'main' into develop 2025-04-16 15:00:49 +00:00
dea2a1c40f Merge pull request 'feature/#41-query-builder-joins' (#83) from feature/#41-query-builder-joins into develop
Reviewed-on: #83
2025-04-16 14:39:42 +00:00
f94cc8b365 deactivate custom join switch
deactivated custom join switch as data is not passed down
2025-04-16 16:37:24 +02:00
238a35da9f extend query builder by custom join 2025-04-16 16:11:10 +02:00
d39ebc5029 Merge pull request '#40-query-builder-sorting' (#82) from #40-query-builder-sorting into develop
Reviewed-on: #82
2025-04-15 08:47:14 +00:00
9a7785917c show changes detected 2025-04-15 10:45:15 +02:00
fc1185d1c8 upgrade query to ids by default 2025-04-15 10:29:25 +02:00
68b0aeffa8 show correct columns to table 2025-04-15 10:02:30 +02:00
8087108b90 change order of sorts 2025-04-15 09:35:08 +02:00
5ce7aa8a17 add global sort 2025-04-15 09:26:25 +02:00
d018f97274 Merge pull request 'feature/#69-calendar-view' (#81) from feature/#69-calendar-view into develop
Reviewed-on: #81
2025-04-14 07:18:01 +00:00
6d45325543 add styling option for wide calendar view 2025-04-14 09:17:32 +02:00
1296331796 extend calendar by list 2025-04-14 09:12:00 +02:00
303ce7a58d Merge pull request 'version-update' (#80) from version-update into develop
Reviewed-on: #80
2025-04-13 14:39:34 +00:00
e4c2f47eb0 1.3.6 2025-04-10 12:48:38 +02:00
387736721f Merge pull request 'patches v1.3.6' (#78) from develop into main
Reviewed-on: #78
2025-04-10 10:47:10 +00:00
107 changed files with 4980 additions and 1739 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

@ -8,12 +8,9 @@ Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber
Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de).
Für die Verwendung muss ein TOTP-Code eingegeben werden.
Die Zugangsdaten (Lesebeschränkt) sind unterhalb dem Login angegeben.
Die Zugangsdaten (Lesebeschränkt) sind:\
EMAIL: demo-besucher\
TOTP: ![alt text](demo-totp-qrcode.png)\
TOTP-Code: FBMDAJKFOYQXM2DNH47GWWBGJ5KWOUCW
Das Handbuch zur Anwendung finden sie unter [https://ff-admin.de/ff-admin-handbook](https://ff-admin.de/ff-admin-handbook).
## Installation
@ -32,14 +29,6 @@ services:
#environment:
# - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad
# - APPNAMEOVERWRITE=<appname> # ersetzt den Namen FF-Admin auf der Login-Seite und sonstigen Positionen in der Oberfläche
# - IMPRINTLINK=<imprint link>
# - PRIVACYLINK=<privacy link>
# - CUSTOMLOGINMESSAGE=betrieben von xy
#volumes:
# - <volume|local path>/favicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung
# - <volume|local path>/favicon.png:/usr/share/nginx/html/favicon.png # 512x512 px Auflösung - wird als pwa Icon genutzt
# - <volume|local path>/Logo.png:/usr/share/nginx/html/Logo.png
```
Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

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>

2924
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "ff-admin",
"version": "1.3.5",
"version": "1.5.0",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module",
"scripts": {
@ -24,18 +24,19 @@
"author": "JK Effects",
"license": "AGPL-3.0-only",
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@fullcalendar/vue3": "^6.1.17",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@tailwindcss/vite": "^4.1.3",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.5",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9",
"axios": "^1.9.0",
"event-source-polyfill": "^1.0.31",
"grapesjs": "^0.22.4",
"grapesjs": "^0.22.7",
"grapesjs-preset-newsletter": "^1.0.2",
"highlight.js": "^11.11.1",
"jwt-decode": "^4.0.0",
@ -49,44 +50,45 @@
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
"pinia": "^3.0.2",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"socket.io-client": "^4.5.0",
"pwacompat": "^2.0.17",
"qrcode": "^1.5.4",
"qs": "^6.14.0",
"socket.io-client": "^4.8.1",
"unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tailwindcss/postcss": "^4.1.3",
"@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0",
"@rushstack/eslint-patch": "^1.11.0",
"@tailwindcss/postcss": "^4.1.5",
"@tsconfig/node20": "^20.1.5",
"@types/eslint": "~9.6.1",
"@types/event-source-polyfill": "^1.0.5",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9",
"@types/lodash.isequal": "^4.5.8",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.14.0",
"@types/nprogress": "^0.2.0",
"@types/node": "^22.15.12",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.11",
"@types/qs": "^6.9.18",
"@types/uuid": "^10.0.0",
"@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.24.0",
"eslint-plugin-vue": "^10.0.0",
"npm-run-all2": "^7.0.2",
"prettier": "^3.2.5",
"tailwindcss": "^4.1.3",
"eslint": "^9.26.0",
"eslint-plugin-vue": "^10.1.0",
"npm-run-all2": "^8.0.1",
"prettier": "^3.5.3",
"tailwindcss": "^4.1.5",
"typescript": "^5.8.3",
"vite": "^6.2.6",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.0.21"
"vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^2.2.10"
}
}

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

@ -0,0 +1,181 @@
<template>
<div class="flex flex-col w-full h-full gap-2 justify-between overflow-hidden">
<div
class="flex flex-row gap-2 justify-between max-sm:justify-center"
:class="smallStyling ? 'max-lg:flex-wrap' : 'max-xl:flex-wrap'"
>
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-2' : 'max-xl:order-2'">
<button
:primary="view == 'dayGridMonth'"
:primary-outline="view != 'dayGridMonth'"
class="rounded-r-none!"
@click="setView('dayGridMonth')"
>
Monat
</button>
<button
:primary="view == 'timeGridWeek'"
:primary-outline="view != 'timeGridWeek'"
class="rounded-none! border-x-0!"
@click="setView('timeGridWeek')"
>
Woche
</button>
<button
:primary="view == 'listMonth'"
:primary-outline="view != 'listMonth'"
class="rounded-l-none!"
@click="setView('listMonth')"
>
Liste
</button>
</div>
<p class="text-3xl w-full text-center" :class="smallStyling ? 'max-lg:order-1' : 'max-xl:order-1'">
{{ currentTitle }}
</p>
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-3' : 'max-xl:order-3'">
<button primary-outline class="rounded-r-none!" @click="navigateView('prev')">
<ChevronLeftIcon />
</button>
<button
:primary="containsToday"
:primary-outline="!containsToday"
class="rounded-none! border-x-0!"
@click="
calendarApi?.today();
containsToday = true;
"
>
heute
</button>
<button primary-outline class="rounded-l-none!" @click="navigateView('next')">
<ChevronRightIcon />
</button>
</div>
</div>
<div class="flex flex-col w-full grow overflow-hidden">
<FullCalendar ref="fullCalendar" :options="calendarOptions" class="max-h-full h-full" />
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import interactionPlugin from "@fullcalendar/interaction";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
import type { CalendarOptions } from "@fullcalendar/core/index.js";
</script>
<script lang="ts">
export default defineComponent({
props: {
items: {
type: Array as PropType<
{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
}[]
>,
default: [],
},
allowInteraction: {
type: Boolean,
default: true,
},
smallStyling: {
type: Boolean,
default: false,
},
},
emits: {
dateSelect: ({ start, end, allDay }: { start: string; end: string; allDay: boolean }) => {
return typeof start == "string" && typeof end == "string" && typeof allDay === "boolean";
},
eventSelect: (id: string) => {
return typeof id == "string";
},
},
data() {
return {
view: "dayGridMonth" as "dayGridMonth" | "timeGridWeek" | "listMonth",
calendarApi: null as null | typeof FullCalendar,
currentTitle: "" as string,
containsToday: false as boolean,
};
},
computed: {
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: "dayGridMonth",
eventDisplay: "block",
headerToolbar: false,
weekends: true,
editable: this.allowInteraction,
selectable: this.allowInteraction,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.items,
select: this.select,
eventClick: this.eventClick,
} as CalendarOptions;
},
},
mounted() {
this.calendarApi = (this.$refs.fullCalendar as typeof FullCalendar).getApi();
this.setTitle();
this.setContainsToday();
},
methods: {
setTitle() {
this.currentTitle = this.calendarApi?.view.title ?? "";
},
setView(view: "dayGridMonth" | "timeGridWeek" | "listMonth") {
this.calendarApi?.changeView(view);
this.view = view;
this.setTitle();
this.setContainsToday();
},
navigateView(change: "prev" | "next") {
if (change == "prev") {
this.calendarApi?.prev();
} else {
this.calendarApi?.next();
}
this.setTitle();
this.setContainsToday();
},
setContainsToday() {
const start = this.calendarApi?.view.currentStart;
const end = this.calendarApi?.view.currentEnd;
const today = new Date();
this.containsToday = today >= start && today < end;
},
select(e: any) {
this.$emit("dateSelect", {
start: e?.startStr ?? new Date().toISOString(),
end: e?.endStr ?? new Date().toISOString(),
allDay: e?.allDay ?? false,
});
},
eventClick(e: any) {
this.$emit("eventSelect", e.event.id);
},
},
});
</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,106 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="change">
<div class="-space-y-px">
<div>
<input
id="new"
name="new"
type="password"
required
placeholder="neues Passwort"
autocomplete="new-password"
class="rounded-b-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<div>
<input
id="new_rep"
name="new_rep"
type="password"
required
placeholder="neues Passwort wiederholen"
autocomplete="new-password"
class="rounded-t-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
zu Passwort wechseln
</button>
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
<FailureXMark v-else-if="changeStatus == 'failed'" />
</div>
<p v-if="changeError" class="text-center">{{ changeError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
export default defineComponent({
props: {
currentRoutine: {
type: String,
default: "",
},
},
emits: ["updateCurrent"],
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
changeStatus: undefined as undefined | "loading" | "success" | "failed",
changeError: "" as string,
notMatching: false as boolean,
};
},
mounted() {},
methods: {
async change(e: any) {
let formData = e.target.elements;
let new_pw = await hashString(formData.new.value);
let new_rep = await hashString(formData.new_rep.value);
if (new_pw != new_rep) {
this.notMatching = true;
return;
}
this.notMatching = false;
this.changeStatus = "loading";
this.changeError = "";
this.$http
.patch(`/user/changeToPW`, {
newpassword: await hashString(formData.new.value),
})
.then((result) => {
this.changeStatus = "success";
})
.catch((err) => {
this.changeStatus = "failed";
this.changeError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.changeStatus = undefined;
this.$emit("updateCurrent");
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-2 grow">
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
<TextCopy :copyText="otp" />
</div>
<form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px">
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
zu TOTP wechseln
</button>
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
currentRoutine: {
type: String,
default: "",
},
},
emits: ["updateCurrent"],
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
image: undefined as undefined | string,
otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
};
},
mounted() {
this.$http
.get(`/user/changeToTOTP`)
.then((result) => {
this.verification = "success";
this.image = result.data.dataUrl;
this.otp = result.data.otp;
})
.catch((err) => {
this.verification = "failed";
});
},
methods: {
verify(e: any) {
let formData = e.target.elements;
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.patch(`/user/changeToTOTP`, {
otp: this.otp,
totp: formData.totp.value,
})
.then((result) => {
this.verifyStatus = "success";
})
.catch((err) => {
this.verifyStatus = "failed";
this.verifyError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.verifyStatus = undefined;
this.$emit("updateCurrent");
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,109 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="change">
<div class="-space-y-px">
<div>
<input
id="current"
name="current"
type="password"
required
placeholder="aktuelles Passwort"
autocomplete="current-password"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="new"
name="new"
type="password"
required
placeholder="neues Passwort"
autocomplete="new-password"
class="rounded-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<div>
<input
id="new_rep"
name="new_rep"
type="password"
required
placeholder="neues Passwort wiederholen"
autocomplete="new-password"
class="rounded-t-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
Passwort ändern
</button>
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
<FailureXMark v-else-if="changeStatus == 'failed'" />
</div>
<p v-if="changeError" class="text-center">{{ changeError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
changeStatus: undefined as undefined | "loading" | "success" | "failed",
changeError: "" as string,
notMatching: false as boolean,
};
},
mounted() {},
methods: {
async change(e: any) {
let formData = e.target.elements;
let new_pw = await hashString(formData.new.value);
let new_rep = await hashString(formData.new_rep.value);
if (new_pw != new_rep) {
this.notMatching = true;
return;
}
this.notMatching = false;
this.changeStatus = "loading";
this.changeError = "";
this.$http
.patch(`/user/changepw`, {
current: await hashString(formData.current.value),
newpassword: await hashString(formData.new.value),
})
.then((result) => {
this.changeStatus = "success";
})
.catch((err) => {
this.changeStatus = "failed";
this.changeError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.changeStatus = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,83 @@
<template>
<div class="flex flex-col gap-2 grow">
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
<TextCopy :copyText="otp" />
</div>
<form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px">
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP prüfen" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
TOTP prüfen
</button>
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
image: undefined as undefined | string,
otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
};
},
mounted() {
this.$http
.get(`/user/totp`)
.then((result) => {
this.verification = "success";
this.image = result.data.dataUrl;
this.otp = result.data.otp;
})
.catch((err) => {
this.verification = "failed";
});
},
methods: {
verify(e: any) {
let formData = e.target.elements;
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.post(`/user/verify`, {
totp: formData.totp.value,
})
.then((result) => {
this.verifyStatus = "success";
})
.catch((err) => {
this.verifyStatus = "failed";
this.verifyError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.verifyStatus = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -1,10 +1,11 @@
<template>
<div class="w-full">
<Combobox v-model="selected" :disabled="disabled" multiple>
<ComboboxLabel>{{ title }}</ComboboxLabel>
<div class="relative mt-1">
<ComboboxLabel v-if="!showTitleAsPlaceholder">{{ title }}</ComboboxLabel>
<div class="relative" :class="{ 'mt-1': !showTitleAsPlaceholder }">
<ComboboxInput
class="rounded-md shadow-xs relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
:placeholder="showTitleAsPlaceholder ? title : ''"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
@ -86,7 +87,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import difference from "lodash.difference";
import Spinner from "../Spinner.vue";
import Spinner from "@/components/Spinner.vue";
</script>
<script lang="ts">
@ -101,6 +102,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
showTitleAsPlaceholder: {
type: Boolean,
default: false,
},
},
emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"],
watch: {

View file

@ -18,22 +18,22 @@
<div class="flex flex-row border border-white rounded-md overflow-hidden">
<EyeIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'read', section) ? 'bg-success' : ''"
:class="_canSection(permissionUpdate, 'read', section) ? 'bg-success' : ''"
@click="togglePermission('read', section)"
/>
<PlusIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'create', section) ? 'bg-success' : ''"
:class="_canSection(permissionUpdate, 'create', section) ? 'bg-success' : ''"
@click="togglePermission('create', section)"
/>
<PencilIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'update', section) ? 'bg-success' : ''"
:class="_canSection(permissionUpdate, 'update', section) ? 'bg-success' : ''"
@click="togglePermission('update', section)"
/>
<TrashIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'delete', section) ? 'bg-success' : ''"
:class="_canSection(permissionUpdate, 'delete', section) ? 'bg-success' : ''"
@click="togglePermission('delete', section)"
/>
</div>
@ -132,7 +132,7 @@ export default defineComponent({
};
},
computed: {
...mapState(useAbilityStore, ["_can"]),
...mapState(useAbilityStore, ["_can", "_canSection"]),
canSaveOrReset(): boolean {
return isEqual(this.permissions, this.permissionUpdate);
},

View file

@ -69,7 +69,15 @@
<input type="date" id="birthdate" required />
</div>
<div>
<label for="internalId">Interne ID (optional)</label>
<div class="flex flex-row">
<label for="internalId" class="grow">
Interne ID (optional{{ lastId ? ` - zuletzte verwendet: ${lastId}` : "" }})
</label>
<div title="Es empfiehlt sich, die Interne Id mit Platzhaltern wie '0' vorne aufzufüllen.">
<InformationCircleIcon class="h-5 w-5" />
</div>
</div>
<input type="text" id="internalId" />
</div>
<div class="flex flex-row gap-2">
@ -101,8 +109,9 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation";
import type { SalutationViewModel } from "../../../../viewmodels/admin/configuration/salutation.models";
import { useSalutationStore } from "@/stores/admin/configuration/salutation";
import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
@ -112,6 +121,7 @@ export default defineComponent({
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
selectedSalutation: null as null | SalutationViewModel,
lastId: "" as string,
};
},
computed: {
@ -119,6 +129,11 @@ export default defineComponent({
},
mounted() {
this.fetchSalutations();
this.fetchLastInternalId()
.then((res) => {
this.lastId = res.data;
})
.catch(() => {});
},
beforeUnmount() {
try {
@ -127,7 +142,7 @@ export default defineComponent({
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberStore, ["createMember"]),
...mapActions(useMemberStore, ["createMember", "fetchLastInternalId"]),
...mapActions(useSalutationStore, ["fetchSalutations"]),
triggerCreate(e: any) {
if (!this.selectedSalutation) return;

View file

@ -36,8 +36,8 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useProtocolStore } from "@/stores/admin/club/protocol/protocol";
import type { CreateProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
import { useNewsletterStore } from "../../../../stores/admin/club/newsletter/newsletter";
import type { CreateNewsletterViewModel } from "../../../../viewmodels/admin/club/newsletter/newsletter.models";
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import type { CreateNewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
</script>
<script lang="ts">

View file

@ -3,13 +3,13 @@
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>Newsletter bei Type "{{ comType.type }}" versenden/exportieren als</p>
<div v-if="can('create', 'configuration', 'newsletter_config')" class="flex flex-row justify-end w-16">
<button v-if="status == null" type="submit" class="p-0! h-fit! w-fit!" title="speichern">
<button v-if="status == null" type="submit" class="p-0! h-fit! w-fit!" title="Änderung speichern">
<ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
<Spinner v-else-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
<button type="button" class="p-0! h-fit! w-fit!" title="zurücksetzen" @click="resetForm">
<button type="button" class="p-0! h-fit! w-fit!" title="Änderung zurücksetzen" @click="resetForm">
<ArchiveBoxXMarkIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
</div>
@ -36,7 +36,7 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useModalStore } from "@/stores/modal";
import { NewsletterConfigType } from "@/enums/newsletterConfigType";
import { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
import type { AxiosResponse } from "axios";
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
import { useAbilityStore } from "@/stores/ability";
@ -62,7 +62,7 @@ export default defineComponent({
},
},
mounted() {
this.configs = Object.values(NewsletterConfigType);
this.configs = Object.values(NewsletterConfigEnum);
},
beforeUnmount() {
try {

View file

@ -20,7 +20,7 @@ import { mapState, mapActions } from "pinia";
import { ArchiveBoxArrowDownIcon, ArrowDownTrayIcon, BarsArrowUpIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
import { useBackupStore } from "../../../../stores/admin/management/backup";
import { useBackupStore } from "@/stores/admin/management/backup";
</script>
<script lang="ts">

View file

@ -57,9 +57,9 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useBackupStore } from "@/stores/admin/management/backup";
import type { BackupRestoreViewModel } from "../../../../viewmodels/admin/management/backup.models";
import type { BackupRestoreViewModel } from "@/viewmodels/admin/management/backup.models";
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
import { backupSections, type BackupSection } from "../../../../types/backupTypes";
import { backupSections, type BackupSection } from "@/types/backupTypes";
</script>
<script lang="ts">

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

@ -39,7 +39,7 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useWebapiStore } from "@/stores/admin/management/webapi";
import type { CreateWebapiViewModel } from "../../../../viewmodels/admin/management/webapi.models";
import type { CreateWebapiViewModel } from "@/viewmodels/admin/management/webapi.models";
</script>
<script lang="ts">

View file

@ -28,7 +28,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import TextCopy from "@/components/TextCopy.vue";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline";
import { host } from "@/serverCom";
import { useWebapiStore } from "../../../../stores/admin/management/webapi";
import { useWebapiStore } from "@/stores/admin/management/webapi";
</script>
<script lang="ts">

View file

@ -30,6 +30,9 @@
v-if="allowPredefinedSelect && can('read', 'configuration', 'query_store')"
class="flex flex-row gap-2 max-lg:w-full max-lg:order-10"
>
<div v-if="!isAsStored" class="p-1 border border-gray-400 bg-gray-100 rounded-md" title="Änderung erkannt">
<DocumentCurrencyRupeeIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<select v-model="activeQueryId" class="max-h-[34px] py-0!">
<option :value="undefined" disabled>gepeicherte Anfrage auswählen</option>
<option v-for="query in queries" :key="query.id" :value="query.id">
@ -70,7 +73,7 @@
</div>
<div class="p-2 h-44 md:h-60 w-full overflow-y-auto">
<textarea v-if="typeof value == 'string'" v-model="value" placeholder="SQL Query" class="h-full w-full" />
<Table v-else v-model="value" />
<Table v-else v-model="value" enableOrder />
</div>
</div>
</template>
@ -78,7 +81,7 @@
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { type DynamicQueryStructure } from "@/types/dynamicQueries";
import {
ArchiveBoxArrowDownIcon,
CommandLineIcon,
@ -88,12 +91,15 @@ import {
RectangleGroupIcon,
TrashIcon,
SparklesIcon,
DocumentCurrencyRupeeIcon,
} from "@heroicons/vue/24/outline";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { useModalStore } from "@/stores/modal";
import Table from "./Table.vue";
import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
import { v4 as uuid } from "uuid";
import cloneDeep from "lodash.clonedeep";
</script>
<script lang="ts">
@ -102,6 +108,7 @@ export default defineComponent({
modelValue: {
type: [Object, String] as PropType<DynamicQueryStructure | string>,
default: {
id: uuid(),
select: "*",
table: "",
where: [],
@ -126,7 +133,12 @@ export default defineComponent({
} else {
this.queryMode = "builder";
}
this.value = query;
this.value = cloneDeep(query);
}
},
value() {
if (typeof this.value != "string" && !this.value.id) {
this.value.id = uuid();
}
},
},
@ -149,6 +161,11 @@ export default defineComponent({
this.$emit("update:model-value", val);
},
},
isAsStored() {
let stored = this.queries.find((q) => q.id == this.activeQueryId);
if (!stored) return true;
return JSON.stringify(this.value) == JSON.stringify(stored.query);
},
},
mounted() {
this.fetchTableMetas();
@ -163,6 +180,7 @@ export default defineComponent({
this.activeQueryId = undefined;
if (typeof this.value != "string") {
this.value = {
id: uuid(),
select: "*",
table: "",
where: [],
@ -182,6 +200,7 @@ export default defineComponent({
this.activeQueryId = undefined;
if (this.queryMode == "builder") {
this.value = {
id: uuid(),
select: "*",
table: "",
where: [],

View file

@ -1,6 +1,6 @@
<template>
<div class="flex flex-row gap-2 items-center w-full">
<select v-if="concat != '_'" v-model="concat" class="w-20! h-fit!">
<select v-if="!isFirst" v-model="concat" class="w-20! h-fit!">
<option value="" disabled>Verknüpfung auswählen</option>
<option v-for="operation in ['AND', 'OR']" :value="operation">
{{ operation }}
@ -68,6 +68,10 @@ import { TrashIcon } from "@heroicons/vue/24/outline";
<script lang="ts">
export default defineComponent({
props: {
isFirst: {
type: Boolean,
default: false,
},
table: {
type: String,
default: "",
@ -78,9 +82,6 @@ export default defineComponent({
},
},
emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() {
@ -144,5 +145,10 @@ export default defineComponent({
},
},
},
mounted() {
if (this.concat == "_") {
this.concat = "AND";
}
},
});
</script>

View file

@ -1,12 +1,13 @@
<template>
<div class="flex flex-row gap-2">
<p class="w-14 min-w-14 pt-2">JOIN</p>
<p class="w-14 min-w-14 pt-2">I_JOIN</p>
<div class="flex flex-row flex-wrap gap-2 items-center w-full">
<div class="flex flex-row flex-wrap gap-2 items-center justify-end w-full">
<JoinTable
v-for="(join, index) in value"
:model-value="join"
:table="table"
:alreadyJoined="alreadyJoined"
@update:model-value="($event) => (value[index] = $event)"
@remove="removeAtIndex(index)"
/>
@ -21,10 +22,11 @@
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { type DynamicQueryStructure, type JoinStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { PlusIcon } from "@heroicons/vue/24/outline";
import JoinTable from "./JoinTable.vue";
import { v4 as uuid } from "uuid";
</script>
<script lang="ts">
@ -35,24 +37,22 @@ export default defineComponent({
default: "",
},
modelValue: {
type: Array as PropType<Array<DynamicQueryStructure & { foreignColumn: string }>>,
type: Array as PropType<Array<DynamicQueryStructure & JoinStructure>>,
default: [],
},
alreadyJoined: {
type: Array as PropType<Array<string>>,
default: [],
},
},
emits: ["update:model-value"],
data() {
return {};
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() {
return this.tableMetas.find((tm) => tm.tableName == this.table);
},
value: {
get() {
return this.modelValue;
},
set(val: Array<DynamicQueryStructure & { foreignColumn: string }>) {
set(val: Array<DynamicQueryStructure & JoinStructure>) {
this.$emit("update:model-value", val);
},
},
@ -60,11 +60,13 @@ export default defineComponent({
methods: {
addToValue() {
this.value.push({
id: uuid(),
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
type: "defined",
foreignColumn: "",
});
},

View file

@ -2,13 +2,52 @@
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-col gap-2 w-full">
<select v-model="foreignColumn" class="w-full">
<div class="flex flex-row gap-2 w-full">
<div
v-if="false"
class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md"
title="Join Modus wechseln"
@click="swapJoinType(value.type)"
>
<ArrowsUpDownIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<select v-if="type == 'defined'" v-model="context" class="w-full">
<option value="" disabled>Relation auswählen</option>
<option v-for="relation in activeTable?.relations" :value="relation.column">
<option
v-for="relation in activeTable?.relations"
:value="relation.column"
:disabled="
alreadyJoined.includes(joinTableName(relation.referencedTableName)) &&
joinTableName(relation.referencedTableName) != value.table
"
>
{{ relation.column }} -> {{ joinTableName(relation.referencedTableName) }}
<span
v-if="
alreadyJoined.includes(joinTableName(relation.referencedTableName)) &&
joinTableName(relation.referencedTableName) != value.table
"
>
(Join auf dieser Ebene besteht schon)
</span>
</option>
</select>
<Table v-model="value" disable-table-select />
<div v-else class="flex flex-col w-full">
<select v-model="joinTable">
<option value="" disabled>Tabelle auswählen</option>
<option
v-for="table in tableMetas"
:value="table.tableName"
:disabled="alreadyJoined.includes(table.tableName) && table.tableName != value.table"
>
{{ table.tableName }}
</option>
</select>
<input v-model="context" type="text" placeholder="Join Condition tabA.col = tabB.col" />
</div>
</div>
<Table v-model="value" disable-table-select :show-table-select="false" />
</div>
<div class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')">
<TrashIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
@ -20,11 +59,12 @@
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { type DynamicQueryStructure, type JoinStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import Table from "./Table.vue";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { joinTableName } from "@/helpers/queryFormatter";
import { v4 as uuid } from "uuid";
</script>
<script lang="ts">
@ -35,25 +75,15 @@ export default defineComponent({
default: "",
},
modelValue: {
type: Object as PropType<
DynamicQueryStructure & {
foreignColumn: string;
}
>,
default: {
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
foreignColumn: "",
type: Object as PropType<DynamicQueryStructure & JoinStructure>,
required: true,
},
alreadyJoined: {
type: Array as PropType<Array<string>>,
default: [],
},
},
emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() {
@ -63,27 +93,73 @@ export default defineComponent({
get() {
return this.modelValue;
},
set(
val: DynamicQueryStructure & {
foreignColumn: string;
}
) {
set(val: DynamicQueryStructure & JoinStructure) {
this.$emit("update:model-value", val);
},
},
foreignColumn: {
context: {
get() {
return this.modelValue.foreignColumn;
if (this.modelValue.type == "defined") {
return this.modelValue.foreignColumn ?? "";
} else {
return this.modelValue.condition ?? "";
}
},
set(val: string) {
if (this.modelValue.type == "defined") {
let relTable = this.activeTable?.relations.find((r) => r.column == val);
this.$emit("update:model-value", {
...this.modelValue,
foreignColumn: val,
table: joinTableName(relTable?.referencedTableName ?? ""),
});
} else {
this.$emit("update:model-value", {
...this.modelValue,
condition: val,
});
}
},
},
type: {
get(): string {
return this.modelValue.type ?? "defined";
},
set(val: "custom" | "defined") {
this.$emit("update:model-value", {
...this.modelValue,
type: val,
});
},
},
joinTable: {
get(): string {
return this.modelValue.table;
},
set(val: string) {
this.$emit("update:model-value", {
...this.modelValue,
table: val,
});
},
},
},
mounted() {
if (!this.value.id) {
this.value.id = uuid();
}
if (!this.value.type) {
this.type = "defined";
}
},
methods: {
swapJoinType(type: string) {
if (type == "defined") {
this.type = "custom";
} else {
this.type = "defined";
}
},
},
});
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="flex flex-row gap-2 w-full border border-gray-300 rounded-md p-1">
<select v-if="concat != '_'" v-model="concat" class="w-20! h-fit!">
<select v-if="isFirst" v-model="concat" class="w-20! h-fit!">
<option value="" disabled>Verknüpfung auswählen</option>
<option v-for="operation in ['AND', 'OR']" :value="operation">
{{ operation }}
@ -28,6 +28,10 @@ import NestedWhere from "./NestedWhere.vue";
<script lang="ts">
export default defineComponent({
props: {
isFirst: {
type: Boolean,
default: false,
},
table: {
type: String,
default: "",
@ -38,9 +42,6 @@ export default defineComponent({
},
},
emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
concat: {
@ -60,5 +61,10 @@ export default defineComponent({
},
},
},
mounted() {
if (this.concat == "_") {
this.concat = "AND";
}
},
});
</script>

View file

@ -1,12 +1,17 @@
<template>
<div class="flex flex-row gap-2">
<p class="w-14 min-w-14 pt-2">ORDER</p>
<p class="w-14 min-w-14 pt-2">SORT</p>
<div class="flex flex-row flex-wrap gap-2 items-center w-full">
<OrderStructure
v-for="(order, index) in value"
:model-value="order"
:table="table"
:columns="columns"
:alreadySorted="alreadySorted"
:notFirst="index != 0"
:notLast="index != value.length - 1"
@up="changeSort('up', index)"
@down="changeSort('down', index)"
@update:model-value="($event) => (value[index] = $event)"
@remove="removeAtIndex(index)"
/>
@ -35,9 +40,15 @@ export default defineComponent({
type: String,
default: "",
},
// columns: {
// type: [Array, String] as PropType<"*" | Array<string>>,
// default: "*",
// },
columns: {
type: [Array, String] as PropType<"*" | Array<string>>,
default: "*",
type: Array as PropType<
Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
>,
default: [],
},
modelValue: {
type: Array as PropType<Array<OrderByStructure>>,
@ -50,6 +61,9 @@ export default defineComponent({
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
alreadySorted() {
return this.modelValue.map((m) => ({ id: m.id, col: m.column }));
},
value: {
get() {
return this.modelValue;
@ -62,6 +76,9 @@ export default defineComponent({
methods: {
addToValue() {
this.value.push({
id: "",
depth: 0,
table: "",
column: "",
order: "ASC",
});
@ -69,6 +86,12 @@ export default defineComponent({
removeAtIndex(index: number) {
this.value.splice(index, 1);
},
changeSort(dir: "up" | "down", index: number) {
const swapIndex = dir === "up" ? index - 1 : index + 1;
if (swapIndex >= 0 && swapIndex < this.value.length) {
[this.value[index], this.value[swapIndex]] = [this.value[swapIndex], this.value[index]];
}
},
},
});
</script>

View file

@ -1,15 +1,26 @@
<template>
<div class="flex flex-row gap-2 items-center w-full">
<div class="flex flex-col min-w-fit">
<ChevronUpIcon v-if="notFirst" class="w-4 h-4 stroke-2 cursor-pointer" @click.prevent="$emit('up')" />
<ChevronDownIcon v-if="notLast" class="w-4 h-4 stroke-2 cursor-pointer" @click.prevent="$emit('down')" />
</div>
<select v-model="column" class="w-full">
<option value="" disabled>Spalte auswählen</option>
<option v-for="column in selectableColumns" :value="column">
{{ column }}
<option
v-for="selectable in selectableColumns"
:value="`${selectable.id}_${selectable.column}`"
:disabled="
alreadySorted.some((as) => as.id == selectable.id && as.col == selectable.column) &&
`${selectable.id}_${selectable.column}` != column
"
>
{{ [...selectable.path, selectable.table].join("-") }} -> {{ selectable.column }}
</option>
</select>
<select v-model="order">
<option value="" disabled>Sortierung auswählen</option>
<option v-for="order in ['ASC', 'DESC']" :value="order">
{{ order }}
<option v-for="order in orderable" :value="order.key">
{{ order.val }}
</option>
</select>
<div class="p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')">
@ -23,47 +34,102 @@ import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia";
import type { OrderByStructure, OrderByType } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
notFirst: {
type: Boolean,
defailt: false,
},
notLast: {
type: Boolean,
defailt: false,
},
table: {
type: String,
default: "",
},
// columns: {
// type: [Array, String] as PropType<"*" | Array<string>>,
// default: "*",
// },
columns: {
type: [Array, String] as PropType<"*" | Array<string>>,
default: "*",
type: Array as PropType<
Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
>,
default: [],
},
modelValue: {
type: Object as PropType<OrderByStructure>,
default: {},
},
alreadySorted: {
type: Array as PropType<Array<{ id: string; col: string }>>,
default: [],
},
},
emits: ["update:model-value", "remove", "up", "down"],
watch: {
columns() {
if (!this.columns.some((c) => c.id == this.modelValue.id)) {
this.$emit("remove");
}
},
},
emits: ["update:model-value", "remove"],
data() {
return {};
return {
orderable: [
{ key: "ASC", val: "Aufsteigend (ABC)" },
{ key: "DESC", val: "Absteigend (CBA)" },
],
};
},
computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]),
// selectableColumns() {
// if (this.columns == "*") {
// let meta = this.tableMetas.find((tm) => tm.tableName == this.table);
// if (!meta) return [];
// let relCols = meta.relations.map((r) => r.column);
// return meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c));
// } else {
// return this.columns;
// }
// },
selectableColumns() {
if (this.columns == "*") {
let meta = this.tableMetas.find((tm) => tm.tableName == this.table);
if (!meta) return [];
return this.columns.reduce(
(acc, curr) => {
if (curr.columns == "*") {
let meta = this.tableMetas.find((tm) => tm.tableName == curr.table);
if (meta) {
let relCols = meta.relations.map((r) => r.column);
return meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c));
} else {
return this.columns;
meta.columns
.map((c) => c.column)
.filter((c) => !relCols.includes(c))
.forEach((c) =>
acc.push({ id: curr.id, depth: curr.depth, table: curr.table, column: c, path: curr.path })
);
}
} else {
curr.columns.forEach((c) =>
acc.push({ id: curr.id, depth: curr.depth, table: curr.table, column: c, path: curr.path })
);
}
return acc;
},
[] as Array<{ id: string; depth: number; table: string; column: string; path: string[] }>
);
},
column: {
get() {
return this.modelValue.column;
return `${this.modelValue.id}_${this.modelValue.column}`;
},
set(val: string) {
this.$emit("update:model-value", { ...this.modelValue, column: val });
set(val: `${string}_${string}`) {
let col = this.selectableColumns.find((sc) => sc.id == val.split("_")[0] && sc.column == val.split("_")[1]);
this.$emit("update:model-value", { ...this.modelValue, ...col });
},
},
order: {

View file

@ -1,23 +1,22 @@
<template>
<div class="flex flex-col gap-2 w-full">
<TableSelect v-model="table" :disableTableSelect="disableTableSelect" />
<TableSelect v-if="showTableSelect" v-model="table" :disableTableSelect="disableTableSelect" />
<ColumnSelect v-if="table != ''" v-model="columnSelect" :table="table" />
<Where v-if="table != ''" v-model="where" :table="table" />
<Order v-if="table != ''" v-model="order" :table="table" :columns="columnSelect" />
<Join v-if="table != ''" v-model="modelValue.join" :table="table" />
<Join v-if="table != ''" v-model="modelValue.join" :table="table" :alreadyJoined="alreadyJoined" />
<Order v-if="table != '' && enableOrder" v-model="order" :table="table" :columns="nestedTablesByDepth" />
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia";
import type { ConditionStructure, DynamicQueryStructure, OrderByStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { type ConditionStructure, type DynamicQueryStructure, type OrderByStructure } from "@/types/dynamicQueries";
import ColumnSelect from "./ColumnSelect.vue";
import Where from "./Where.vue";
import Order from "./Order.vue";
import Join from "./Join.vue";
import TableSelect from "./TableSelect.vue";
import { v4 as uuid } from "uuid";
</script>
<script lang="ts">
@ -25,21 +24,50 @@ export default defineComponent({
props: {
modelValue: {
type: Object as PropType<DynamicQueryStructure>,
default: {
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
},
required: true,
},
disableTableSelect: {
type: Boolean,
default: false,
},
enableOrder: {
type: Boolean,
default: false,
},
showTableSelect: {
type: Boolean,
default: true,
},
},
emits: ["update:model-value"],
computed: {
alreadyJoined() {
return this.modelValue.join?.map((j) => j.table);
},
nestedTablesByDepth() {
const tables: Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }> = [];
function recurse(item: DynamicQueryStructure, path: string[]) {
tables.push({ table: item.table, id: item.id, depth: path.length, path, columns: item.select });
if (item.join) {
item.join.forEach((child) => {
recurse(child, [...path, item.table]);
});
}
}
recurse(this.modelValue, []);
return tables;
},
value: {
get() {
return this.modelValue;
},
set(val: DynamicQueryStructure) {
this.$emit("update:model-value", val);
},
},
table: {
get() {
return this.modelValue.table || "";
@ -81,5 +109,10 @@ export default defineComponent({
},
},
},
mounted() {
if (!this.value.id) {
this.value.id = uuid();
}
},
});
</script>

View file

@ -5,6 +5,7 @@
<div v-for="(condition, index) in value" class="contents">
<NestedCondition
v-if="condition.structureType == 'nested'"
:isFirst="index == 0"
:model-value="condition"
:table="table"
@update:model-value="($event) => (value[index] = $event)"
@ -12,6 +13,7 @@
/>
<Condition
v-else
:isFirst="index == 0"
:model-value="condition"
:table="table"
@update:model-value="($event) => (value[index] = $event)"
@ -74,14 +76,14 @@ export default defineComponent({
addNestedToValue() {
this.value.push({
structureType: "nested",
concat: this.value.length == 0 ? "_" : "AND",
concat: "AND",
conditions: [],
});
},
addConditionToValue() {
this.value.push({
structureType: "condition",
concat: this.value.length == 0 ? "_" : "AND",
concat: "AND",
operation: "eq",
column: "",
value: "",

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

@ -0,0 +1,5 @@
export enum NewsletterConfigEnum {
pdf = "pdf",
mail = "mail",
none = "none",
}

View file

@ -1,4 +0,0 @@
export enum NewsletterConfigType {
pdf = "pdf",
mail = "mail",
}

7
src/helpers/crypto.ts Normal file
View file

@ -0,0 +1,7 @@
export async function hashString(message = ""): Promise<string> {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}

View file

@ -1,4 +1,4 @@
import { joinTableFormatter, type FieldType, type QueryResult } from "../types/dynamicQueries";
import { joinTableFormatter, type FieldType, type QueryResult } from "@/types/dynamicQueries";
export function joinTableName(name: string): string {
let normalized = joinTableFormatter[name];

View file

@ -18,7 +18,7 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #388994;
--success: #73ad0f;
--success: #7ac142;
}
.dark {
--primary: #ff0d00;
@ -27,10 +27,12 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #4ccbda;
--success: #73ad0f;
--success: #7ac142;
}
}
@custom-variant hover (&:hover);
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
@ -72,7 +74,7 @@ a[button] {
button[primary]:not([primary="false"]),
a[button][primary]:not([primary="false"]) {
@apply border border-transparent text-white bg-primary hover:bg-primary;
@apply border-2 border-transparent text-white bg-primary hover:bg-primary;
}
button[primary-outline]:not([primary-outline="false"]),
@ -131,29 +133,3 @@ summary > svg {
summary::-webkit-details-marker {
display: none;
}
.fc-button-primary {
@apply bg-primary! border-primary! outline-hidden! ring-0! hover:bg-red-700! hover:border-red-700! h-10 text-center;
}
.fc-button-active {
@apply bg-red-500! border-red-500!;
}
.fc-toolbar {
@apply flex-wrap;
}
/* For screens between 850px and 768px */
@media (max-width: 850px) and (min-width: 768px) {
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply order-1!;
}
/* Your styles for this range */
}
/* For screens between 525px and 0px */
@media (max-width: 525px) and (min-width: 0px) {
/* Your styles for this range */
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply order-1!;
}
}

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

@ -55,6 +55,7 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
// check jwt expiry
const exp = decoded.exp ?? 0;
const correctedLocalTime = new Date().getTime();
let failedRefresh = false;
if (exp < Math.floor(correctedLocalTime / 1000) || forceRefresh) {
await refreshToken()
.then(() => {
@ -63,13 +64,16 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
.catch((err: string) => {
console.log("expired");
auth.setFailed();
failedRefresh = true;
reject(err);
});
}
if (failedRefresh) return;
var { userId, firstname, lastname, mail, username, permissions, isOwner } = decoded;
if (Object.keys(permissions ?? {}).length === 0 && !isOwner) {
if (Object.keys(permissions ?? {}).filter((p) => p != "adminByOwner").length === 0 && !isOwner) {
auth.setFailed();
reject("nopermissions");
}

View file

@ -1,4 +1,4 @@
import { useBackupStore } from "../stores/admin/management/backup";
import { useBackupStore } from "@/stores/admin/management/backup";
export async function setBackupPage(to: any, from: any, next: any) {
const backup = useBackupStore();

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({
@ -649,6 +647,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",
@ -784,10 +789,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

@ -1,7 +1,7 @@
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients";
import { useNewsletterPrintoutStore } from "../stores/admin/club/newsletter/newsletterPrintout";
import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
export async function setNewsletterId(to: any, from: any, next: any) {
const newsletter = useNewsletterStore();

View file

@ -3,7 +3,7 @@ import { useProtocolAgendaStore } from "@/stores/admin/club/protocol/protocolAge
import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision";
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
import { useProtocolVotingStore } from "@/stores/admin/club/protocol/protocolVoting";
import { useProtocolPrintoutStore } from "../stores/admin/club/protocol/protocolPrintout";
import { useProtocolPrintoutStore } from "@/stores/admin/club/protocol/protocolPrintout";
export async function setProtocolId(to: any, from: any, next: any) {
const protocol = useProtocolStore();

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

@ -11,21 +11,18 @@ export const useAbilityStore = defineStore("ability", {
getters: {
can:
(state) =>
(type: PermissionType | "admin", section: PermissionSection, module?: PermissionModule): boolean => {
(type: PermissionType | "admin", section: PermissionSection, module: PermissionModule): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type)
permissions[section]?.all?.includes(type) ||
permissions[section]?.[module] == "*" ||
permissions[section]?.[module]?.includes(type)
)
return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return true;
return false;
},
canSection:
@ -33,8 +30,8 @@ export const useAbilityStore = defineStore("ability", {
(type: PermissionType | "admin", section: PermissionSection): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) ||
@ -54,20 +51,31 @@ export const useAbilityStore = defineStore("ability", {
permissions: PermissionObject,
type: PermissionType | "admin",
section: PermissionSection,
module?: PermissionModule
module: PermissionModule
): boolean => {
// ignores ownership
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type)
permissions[section]?.all?.includes(type) ||
permissions[section]?.[module] == "*" ||
permissions[section]?.[module]?.includes(type)
)
return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return false;
},
_canSection:
() =>
(permissions: PermissionObject, type: PermissionType | "admin", section: PermissionSection): boolean => {
// ignores ownership
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) ||
permissions[section] != undefined
)
return true;
return false;
},

View file

@ -87,6 +87,9 @@ export const useMemberStore = defineStore("member", {
})
.catch((err) => {});
},
fetchLastInternalId() {
return http.get(`/admin/member/last/internalId`);
},
async printMemberByActiveId() {
return http.get(`/admin/member/${this.activeMember}/print`, {
responseType: "blob",

View file

@ -1,9 +1,8 @@
import { defineStore } from "pinia";
import { http, newEventSource, streamingFetch } from "@/serverCom";
import { http, streamingFetch } from "@/serverCom";
import { useNewsletterStore } from "./newsletter";
import type { AxiosResponse } from "axios";
import type { EventSourcePolyfill } from "event-source-polyfill";
import { useNotificationStore, type NotificationType } from "../../../notification";
import { useNotificationStore, type NotificationType } from "@/stores/notification";
export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
state: () => {

View file

@ -2,6 +2,7 @@ import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { TableMeta } from "@/viewmodels/admin/configuration/query.models";
import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
import type { AxiosResponse } from "axios";
export const useQueryBuilderStore = defineStore("queryBuilder", {
state: () => {
@ -58,6 +59,16 @@ export const useQueryBuilderStore = defineStore("queryBuilder", {
this.loadingData = "failed";
});
},
async sendQueryByStoreId(
id: string,
offset = 0,
count = 25,
noLimit: boolean = false
): Promise<AxiosResponse<any, any>> {
return await http.post(
`/admin/querybuilder/query/${id}?` + (noLimit ? `noLimit=true` : `offset=${offset}&count=${count}`)
);
},
clearResults() {
this.data = [];
this.totalLength = 0;

View file

@ -6,7 +6,7 @@ import type {
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useNewsletterConfigStore = defineStore("newsletterConfi", {
export const useNewsletterConfigStore = defineStore("newsletterConfig", {
state: () => {
return {
config: [] as Array<NewsletterConfigViewModel>,

View file

@ -6,10 +6,10 @@ import type {
} from "@/viewmodels/admin/configuration/query.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import { useQueryBuilderStore } from "../club/queryBuilder";
import { useModalStore } from "../../modal";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { useModalStore } from "@/stores/modal";
import { defineAsyncComponent, markRaw } from "vue";
import { useAbilityStore } from "../../ability";
import { useAbilityStore } from "@/stores/ability";
export const useQueryStoreStore = defineStore("queryStore", {
state: () => {

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse, AxiosProgressEvent } from "axios";
import type { BackupRestoreViewModel } from "../../../viewmodels/admin/management/backup.models";
import type { BackupRestoreViewModel } from "@/viewmodels/admin/management/backup.models";
export const useBackupStore = defineStore("backup", {
state: () => {

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

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import router from "@/router";
import type { PermissionSection } from "../../types/permissionTypes";
import type { PermissionSection } from "@/types/permissionTypes";
export type navigationModel = {
[key in topLevelNavigationType]: navigationSplitModel;
@ -146,6 +146,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

@ -1,9 +1,10 @@
export interface DynamicQueryStructure {
id: string;
select: string[] | "*";
table: string;
where?: Array<ConditionStructure>;
join?: Array<DynamicQueryStructure & { foreignColumn: string }>;
orderBy?: Array<OrderByStructure>;
join?: Array<DynamicQueryStructure & JoinStructure>;
orderBy?: Array<OrderByStructure>; // only at top level
}
export type ConditionStructure = (
@ -47,7 +48,12 @@ export type WhereOperation =
| "timespanEq"; // Date before x years (YYYY-01-01 <bis> YYYY-12-31)
// TODO: age between | age equals | age greater | age smaller
export type JoinStructure = { foreignColumn: string; type: "defined" } | { condition: string; type: "custom" };
export type OrderByStructure = {
id: string;
depth: number;
table: string;
column: string;
order: OrderByType;
};

View file

@ -21,7 +21,8 @@ export type PermissionModule =
| "query_store"
| "template"
| "template_usage"
| "backup";
| "backup"
| "setting";
export type PermissionType = "read" | "create" | "update" | "delete";
@ -30,6 +31,7 @@ export type PermissionString =
| `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul
| `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt
| `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt
| `additional.${string}.${string}` // additional
| "*"; // für Admin
export type PermissionObject = {
@ -38,10 +40,20 @@ export type PermissionObject = {
} & { all?: Array<PermissionType> | "*" };
} & {
admin?: boolean;
adminByOwner?: boolean;
} & {
additional?: { [key: string]: string };
};
export type SectionsAndModulesObject = {
[section in PermissionSection]: Array<PermissionModule>;
} & {
additional?: Array<{
key: string;
name: string;
type: "number" | "string";
emptyIfAdmin: boolean;
}>;
};
export const permissionSections: Array<PermissionSection> = ["club", "unit", "configuration", "management"];
@ -67,6 +79,7 @@ export const permissionModules: Array<PermissionModule> = [
"template",
"template_usage",
"backup",
"setting",
];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
@ -85,5 +98,8 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"template_usage",
"newsletter_config",
],
management: ["user", "role", "webapi", "backup"],
management: ["user", "role", "webapi", "backup", "setting"],
additional: [
//{ key: "val", name: "name", type: "number", emptyIfAdmin: true },
],
};

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

@ -1,4 +1,4 @@
import type { CalendarTypeViewModel } from "../configuration/calendarType.models";
import type { CalendarTypeViewModel } from "@/viewmodels/admin/configuration/calendarType.models";
export interface CalendarViewModel {
id: string;

View file

@ -1,4 +1,4 @@
import type { CommunicationTypeViewModel } from "../../configuration/communicationType.models";
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
export interface CommunicationViewModel {
id: number;

View file

@ -1,6 +1,6 @@
import type { CommunicationViewModel } from "./communication.models";
import type { MembershipViewModel } from "./membership.models";
import type { SalutationViewModel } from "../../configuration/salutation.models";
import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
export interface MemberViewModel {
id: string;

View file

@ -1,5 +1,3 @@
import type { QueryViewModel } from "../../configuration/query.models";
export interface NewsletterViewModel {
id: number;
title: string;
@ -9,7 +7,6 @@ export interface NewsletterViewModel {
newsletterSignatur: string;
isSent: boolean;
recipientsByQueryId?: string | null;
recipientsByQuery?: QueryViewModel | null;
}
export interface CreateNewsletterViewModel {

View file

@ -1,4 +1,4 @@
import type { CalendarViewModel } from "../calendar.models";
import type { CalendarViewModel } from "@/viewmodels/admin/club/calendar.models";
export interface NewsletterDatesViewModel {
newsletterId: number;

View file

@ -1,4 +1,4 @@
import type { MemberViewModel } from "../member/member.models";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
export interface NewsletterRecipientsViewModel {
newsletterId: number;

View file

@ -1,13 +1,13 @@
import type { NewsletterConfigType } from "@/enums/newsletterConfigType";
import type { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
import type { CommunicationTypeViewModel } from "./communicationType.models";
export interface NewsletterConfigViewModel {
comTypeId: number;
config: NewsletterConfigType;
config: NewsletterConfigEnum;
comType: CommunicationTypeViewModel;
}
export interface SetNewsletterConfigViewModel {
comTypeId: number;
config: NewsletterConfigType;
config: NewsletterConfigEnum;
}

View file

@ -1,4 +1,4 @@
import type { BackupSection } from "../../../types/backupTypes";
import type { BackupSection } from "@/types/backupTypes";
export interface BackupRestoreViewModel {
filename: string;

View file

@ -2,19 +2,34 @@
<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>
<form class="flex flex-col gap-2" @submit.prevent="login">
<form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<div class="relative">
<input
id="username"
name="username"
type="text"
required
placeholder="Benutzer"
:class="routine == '' ? '' : 'rounded-b-none!'"
:value="username"
:disabled="username != ''"
/>
<div v-if="usernameStatus" class="h-full flex items-center justify-center w-5 absolute top-0 right-2">
<Spinner v-if="usernameStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="usernameStatus == 'success'" />
<FailureXMark v-else-if="usernameStatus == 'failed'" />
</div>
</div>
<div v-if="routine != ''">
<input
v-if="routine == 'totp'"
id="totp"
name="totp"
type="text"
@ -23,13 +38,26 @@
class="rounded-t-none!"
autocomplete="off"
/>
<input
v-else
id="password"
name="password"
type="password"
required
placeholder="Passwort"
class="rounded-t-none!"
autocomplete="current-password"
/>
</div>
</div>
<RouterLink :to="{ name: 'reset-start' }" class="w-fit self-end text-primary">TOTP verloren</RouterLink>
<p v-if="username != ''" class="w-fit self-end text-primary cursor-pointer" @click="resetRoutine">
Benutzer wechseln
</p>
<RouterLink :to="{ name: 'reset-start' }" class="w-fit self-end text-primary">Zugang verloren</RouterLink>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="loginStatus == 'loading' || loginStatus == 'success'">
anmelden
{{ routine == "" ? "Benutzer prüfen" : "anmelden" }}
</button>
<Spinner v-if="loginStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="loginStatus == 'success'" />
@ -50,7 +78,10 @@ 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";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
@ -58,21 +89,90 @@ export default defineComponent({
data() {
return {
loginStatus: undefined as undefined | "loading" | "success" | "failed",
usernameStatus: undefined as undefined | "loading" | "success" | "failed",
loginError: "" as string,
username: "" as string,
routine: "" as string,
};
},
computed: {
...mapState(useConfigurationStore, ["clubName"]),
},
mounted() {
resetAllPiniaStores();
this.username = localStorage.getItem("username") ?? "";
this.routine = localStorage.getItem("routine") ?? "";
if (this.username != "") {
this.$http
.post(`/auth/kickof`, {
username: this.username,
})
.then((result) => {
this.usernameStatus = "success";
this.routine = result.data.routine;
localStorage.setItem("routine", result.data.routine);
})
.catch((err) => {
this.usernameStatus = "failed";
this.loginError = err.response?.data;
});
}
},
methods: {
login(e: any) {
resetRoutine() {
this.routine = "";
this.username = "";
localStorage.removeItem("routine");
localStorage.removeItem("username");
},
submit(e: any) {
if (this.routine == "") this.kickof(e);
else this.login(e);
},
kickof(e: any) {
let formData = e.target.elements;
let username = formData.username.value;
this.usernameStatus = "loading";
this.loginError = "";
this.$http
.post(`/auth/kickof`, {
username: username,
})
.then((result) => {
this.usernameStatus = "success";
this.routine = result.data.routine;
this.username = username;
localStorage.setItem("routine", result.data.routine);
localStorage.setItem("username", username);
})
.catch((err) => {
this.usernameStatus = "failed";
this.loginError = err.response?.data;
})
.finally(() => {
setTimeout(() => {
this.usernameStatus = undefined;
this.loginError = "";
}, 2000);
});
},
async login(e: any) {
let formData = e.target.elements;
this.loginStatus = "loading";
this.loginError = "";
let secret = "";
if (this.routine == "totp") {
secret = formData.totp.value;
} else {
secret = await hashString(formData.password.value);
}
this.$http
.post(`/auth/login`, {
username: formData.username.value,
totp: formData.totp.value,
secret: secret,
})
.then((result) => {
this.loginStatus = "success";

View file

@ -6,29 +6,41 @@
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
<div class="flex flex-col gap-2">
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
<TextCopy :copyText="otp" />
<Spinner v-if="loading" class="mx-auto" />
<div v-else class="flex flex-col w-full h-full gap-2 px-7 overflow-hidden">
<div class="w-full flex flex-row gap-2 justify-center">
<p
class="w-1/2 p-0.5 pl-0 rounded-lg py-2.5 text-sm text-center font-medium leading-5 outline-hidden cursor-pointer"
:class="
tab == 'totp' ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200'
"
@click="tab = 'totp'"
>
TOTP
</p>
<p
class="w-1/2 p-0.5 rounded-lg py-2.5 text-sm text-center font-medium leading-5 outline-hidden cursor-pointer"
:class="
tab == 'password' ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : 'hover:bg-red-200'
"
@click="tab = 'password'"
>
Passwort
</p>
</div>
<form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px">
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP prüfen" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
TOTP prüfen
</button>
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
<ChangeToTOTP
v-if="currentRoutine == 'password' && tab == 'totp'"
:currentRoutine="currentRoutine"
@updateCurrent="currentRoutine = 'totp'"
/>
<ChangeToPassword
v-else-if="currentRoutine == 'totp' && tab == 'password'"
:currentRoutine="currentRoutine"
@updateCurrent="currentRoutine = 'password'"
/>
<TotpCheckAndScan v-else-if="tab == 'totp'" />
<PasswordChange v-else-if="tab == 'password'" />
<p v-else>etwas ist schief gelaufen</p>
</div>
</template>
</MainTemplate>
@ -42,53 +54,34 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
import TotpCheckAndScan from "@/components/account/TotpCheckAndScan.vue";
import PasswordChange from "@/components/account/PasswordChange.vue";
import ChangeToPassword from "@/components/account/ChangeToPassword.vue";
import ChangeToTOTP from "@/components/account/ChangeToTOTP.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
image: undefined as undefined | string,
otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
loading: false,
tab: "",
currentRoutine: "",
};
},
mounted() {
this.loading = true;
this.$http
.get(`/user/totp`)
.get(`/user/routine`)
.then((result) => {
this.verification = "success";
this.image = result.data.dataUrl;
this.otp = result.data.otp;
})
.catch((err) => {
this.verification = "failed";
});
},
methods: {
verify(e: any) {
let formData = e.target.elements;
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.post(`/user/verify`, {
totp: formData.totp.value,
})
.then((result) => {
this.verifyStatus = "success";
})
.catch((err) => {
this.verifyStatus = "failed";
this.verifyError = err.response.data;
this.tab = result.data.routine;
this.currentRoutine = result.data.routine;
})
.catch((err) => {})
.finally(() => {
setTimeout(() => {
this.verifyStatus = undefined;
}, 2000);
this.loading = false;
});
},
},
methods: {},
});
</script>

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

@ -4,14 +4,17 @@
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Kalender</h1>
<div class="flex flex-row gap-2">
<PlusIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="select" />
<PlusIcon
class="text-gray-500 h-5 w-5 cursor-pointer"
@click="select({ start: '', end: '', allDay: false })"
/>
<LinkIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="openLinkModal" />
</div>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
<FullCalendar :options="calendarOptions" class="max-h-full h-full" />
<CustomCalendar :items="formattedItems" @date-select="select" @event-select="eventClick" />
</div>
</template>
</MainTemplate>
@ -22,14 +25,10 @@ import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useModalStore } from "@/stores/modal";
import MainTemplate from "@/templates/Main.vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { useCalendarStore } from "@/stores/admin/club/calendar";
import { useAbilityStore } from "@/stores/ability";
import { LinkIcon, PlusIcon } from "@heroicons/vue/24/outline";
import CustomCalendar from "@/components/CustomCalendar.vue";
</script>
<script lang="ts">
@ -40,33 +39,6 @@ export default defineComponent({
computed: {
...mapState(useCalendarStore, ["formattedItems"]),
...mapState(useAbilityStore, ["can"]),
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
headerToolbar: {
left: "dayGridMonth,timeGridWeek",
center: "title",
right: "prev,today,next",
},
eventDisplay: "block",
weekends: true,
editable: true,
selectable: true,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.formattedItems,
select: this.select,
eventClick: this.eventClick,
};
},
},
mounted() {
this.fetchCalendars();
@ -74,22 +46,22 @@ export default defineComponent({
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useCalendarStore, ["fetchCalendars"]),
select(e: any) {
select({ start, end, allDay }: { start: string; end: string; allDay: boolean }) {
if (!this.can("create", "club", "calendar")) return;
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CreateCalendarModal.vue"))),
{
start: e?.startStr ?? new Date().toISOString(),
end: e?.endStr ?? new Date().toISOString(),
allDay: e?.allDay ?? false,
start,
end,
allDay,
}
);
},
eventClick(e: any) {
eventClick(id: string) {
if (!this.can("update", "club", "calendar")) return;
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/UpdateCalendarModal.vue"))),
e.event.id
id
);
},
openLinkModal(e: any) {
@ -99,55 +71,4 @@ export default defineComponent({
},
},
});
/**
locale: deLocale,
events: this.absencesList.map((x) => ({
id: x.absenceId,
start: x.startDate,
end: x.endDate,
allday: true,
backgroundColor: this.getColorForAbsenceType(x.absenceType),
borderColor: '#ffffff',
title: this.getAbsenceType(x.absenceType) + ' ' + x.fullName,
})),
plugins: [
interactionPlugin,
dayGridPlugin,
timeGridPlugin,
listPlugin,
multiMonthPlugin,
],
initialView: 'dayGridMonth',
eventDisplay: 'block',
weekends: false,
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: false,
weekText: 'KW',
validRange: { start: '2023-01-01', end: '' },
headerToolbar: {
left: 'today prev,next',
center: 'title',
right: 'listMonth,dayGridMonth,multiMonthYear,customview',
},
views: {
customview: {
type: 'multiMonth',
multiMonthMaxColumns: 1,
duration: { month: 12 },
buttonText: 'grid',
},
},
dateClick: this.handleDateSelect.bind(this),
datesSet: this.handleMonthChange.bind(this),
select: this.handleDateSelect.bind(this),
eventClick: this.handleEventClick.bind(this),
eventsSet: this.handleEvents.bind(this),
};
*/
</script>

View file

@ -103,7 +103,7 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation";
import { useSalutationStore } from "@/stores/admin/configuration/salutation";
</script>
<script lang="ts">

View file

@ -38,7 +38,7 @@ import Pagination from "@/components/Pagination.vue";
import { useAbilityStore } from "@/stores/ability";
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import type { NewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
import NewsletterListItem from "../../../../components/admin/club/newsletter/NewsletterListItem.vue";
import NewsletterListItem from "@/components/admin/club/newsletter/NewsletterListItem.vue";
</script>
<script lang="ts">

View file

@ -81,7 +81,7 @@ import FailureXMark from "@/components/FailureXMark.vue";
import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
import { useNewsletterPrintoutStore } from "../../../../stores/admin/club/newsletter/newsletterPrintout";
import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
</script>
<script lang="ts">

View file

@ -4,51 +4,55 @@
<p v-else-if="loading == 'failed'" @click="fetchNewsletterRecipients" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-1/2">
<div v-if="!showMemberSelect" class="flex flex-row gap-2 items-center">
<select v-model="recipientsByQueryId">
<option value="def">Optional</option>
<option v-for="query in queries" :key="query.id" :value="query.id">{{ query.title }}</option>
</select>
<p>Empfänger durch gespeicherte Abfrage</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in queried"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<div>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
<div title="Empfänger manuell hinzufügen" @click="showMemberSelect = true">
<UserPlusIcon class="w-7 h-7 cursor-pointer" />
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2 h-1/2">
<div v-else class="flex flex-row gap-2 items-center">
<MemberSearchSelect
title="weitere Empfänger suchen"
showTitleAsPlaceholder
v-model="recipients"
:disabled="!can('create', 'club', 'newsletter')"
/>
<div title="Empfänger über Query hinzufügen" @click="showMemberSelect = false">
<ArchiveBoxIcon class="w-7 h-7 cursor-pointer" />
</div>
</div>
<p v-if="!showMemberSelect">Empfänger durch gespeicherte Abfrage</p>
<p v-else>Ausgewählte Empfänger</p>
<p>Ausgewählte Empfänger</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in selected"
v-for="member in showRecipientsByMode"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
class="flex flex-row gap-2 h-fit w-full border border-primary rounded-md bg-primary p-2 text-white items-center"
>
<div>
<ExclamationTriangleIcon v-if="member.sendNewsletter == null" class="w-7 h-7" />
<div class="grow">
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type ?? "---" }}</p>
</div>
<TrashIcon
v-if="can('create', 'club', 'newsletter')"
v-if="can('create', 'club', 'newsletter') && showMemberSelect"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)"
/>
</div>
</div>
<div v-if="countOfNoConfig != 0" class="flex flex-row items-center gap-2 pt-3">
<ExclamationTriangleIcon class="text-red-500 w-5 h-5" />
<p>{{ countOfNoConfig }} Mitglieder der Auswahl haben keinen Newsletter-Versand konfiguriert!</p>
</div>
</div>
</template>
@ -67,7 +71,7 @@ import {
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { ArchiveBoxIcon, ExclamationTriangleIcon, TrashIcon, UserPlusIcon } from "@heroicons/vue/24/outline";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
@ -77,6 +81,7 @@ import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import cloneDeep from "lodash.clonedeep";
import MemberSearchSelect from "@/components/admin/MemberSearchSelect.vue";
import type { FieldType } from "@/types/dynamicQueries";
</script>
<script lang="ts">
@ -84,22 +89,17 @@ export default defineComponent({
props: {
newsletterId: String,
},
watch: {
recipientsByQuery() {
this.loadQuery();
},
},
data() {
return {
query: "" as String,
queryResult: [] as Array<{ id: FieldType; [key: string]: FieldType }>,
members: [] as Array<MemberViewModel>,
showMemberSelect: false as boolean,
};
},
computed: {
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]),
...mapState(useQueryStoreStore, ["queries"]),
...mapState(useQueryBuilderStore, ["data"]),
...mapState(useAbilityStore, ["can"]),
selected(): Array<MemberViewModel> {
return this.members
@ -114,10 +114,10 @@ export default defineComponent({
},
queried(): Array<MemberViewModel> {
if (this.recipientsByQueryId == "def") return [];
let keys = Object.keys(this.data?.[0] ?? {});
let keys = Object.keys(this.queryResult?.[0] ?? {});
let memberKey = keys.find((k) => k.includes("member_id"));
return this.members.filter((m) =>
this.data
this.queryResult
.map((t) => ({
id: t.id,
...(memberKey ? { memberId: t[memberKey] } : {}),
@ -125,6 +125,17 @@ export default defineComponent({
.some((d) => (d.memberId ?? d.id) == m.id)
);
},
showRecipientsByMode() {
return (this.showMemberSelect ? this.selected : this.queried).sort((a, b) => {
const aHasConfig = a.sendNewsletter != null;
const bHasConfig = b.sendNewsletter != null;
if (aHasConfig === bHasConfig) return 0;
return aHasConfig ? -1 : 1;
});
},
countOfNoConfig() {
return this.showRecipientsByMode.filter((member) => member.sendNewsletter == null).length;
},
recipientsByQueryId: {
get() {
return this.activeNewsletterObj?.recipientsByQueryId ?? "def";
@ -133,17 +144,12 @@ export default defineComponent({
if (this.activeNewsletterObj == undefined) return;
if (val == "def") {
this.activeNewsletterObj.recipientsByQueryId = null;
this.activeNewsletterObj.recipientsByQuery = null;
} else if (this.queries.find((q) => q.id == val)) {
this.activeNewsletterObj.recipientsByQueryId = val;
this.activeNewsletterObj.recipientsByQuery = cloneDeep(this.queries.find((q) => q.id == val));
this.sendQuery(0, 0, this.recipientsByQuery?.query, true);
this.loadQuery();
}
},
},
recipientsByQuery() {
return this.activeNewsletterObj?.recipientsByQuery;
},
},
mounted() {
// this.fetchNewsletterRecipients();
@ -155,7 +161,7 @@ export default defineComponent({
...mapActions(useMemberStore, ["getAllMembers"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
...mapActions(useQueryStoreStore, ["fetchQueries"]),
...mapActions(useQueryBuilderStore, ["sendQuery"]),
...mapActions(useQueryBuilderStore, ["sendQueryByStoreId"]),
removeSelected(id: string) {
let index = this.recipients.findIndex((s) => s == id);
if (index != -1) {
@ -170,8 +176,12 @@ export default defineComponent({
.catch(() => {});
},
loadQuery() {
if (this.recipientsByQuery) {
this.sendQuery(0, 0, this.recipientsByQuery.query, true);
if (this.recipientsByQueryId != "def") {
this.sendQueryByStoreId(this.recipientsByQueryId, 0, 0, true)
.then((result) => {
this.queryResult = result.data.rows;
})
.catch(() => {});
}
},
},

View file

@ -53,8 +53,8 @@ import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import { useModalStore } from "@/stores/modal";
import NewsletterSyncing from "@/components/admin/club/newsletter/NewsletterSyncing.vue";
import { PrinterIcon } from "@heroicons/vue/24/outline";
import { useNewsletterDatesStore } from "../../../../stores/admin/club/newsletter/newsletterDates";
import { useNewsletterRecipientsStore } from "../../../../stores/admin/club/newsletter/newsletterRecipients";
import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients";
</script>
<script lang="ts">

View file

@ -70,14 +70,11 @@ import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useQueryBuilderStore, ["loading", "loadingData", "tableMetas", "data", "totalLength", "queryError"]),
...mapState(useQueryBuilderStore, ["loading", "loadingData", "data", "totalLength", "queryError"]),
...mapWritableState(useQueryBuilderStore, ["query"]),
},
mounted() {
this.fetchTableMetas();
},
methods: {
...mapActions(useQueryBuilderStore, ["fetchTableMetas", "sendQuery", "clearResults", "exportData"]),
...mapActions(useQueryBuilderStore, ["sendQuery", "clearResults", "exportData"]),
...mapActions(useQueryStoreStore, ["triggerSave"]),
},
});

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>

Some files were not shown because too many files have changed in this diff Show more