base structure

transfered from ff admin
This commit is contained in:
Julian Krauser 2025-02-16 10:48:16 +01:00
parent efd7c40660
commit f50dff99f3
122 changed files with 17537 additions and 2 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
# NodeJs
node_modules/
dist/
.git/
.env

5
.env.example Normal file
View file

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

5
.env.production Normal file
View file

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

15
.eslintrc.cjs Normal file
View file

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

32
.gitignore vendored
View file

@ -9,3 +9,35 @@ docs/_book
# TODO: where does this rule come from?
test/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.env

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 120
}

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . /app
RUN npm run build-only
FROM nginx:stable-alpine AS prod
WORKDIR /app
COPY --from=build /app/dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
COPY ./entrypoint.sh /entrypoint.sh
RUN apk add --no-cache dos2unix
RUN dos2unix /entrypoint.sh && chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]

View file

@ -1,3 +1,74 @@
# ff-operation
# FF Operation
Einsatzverwaltung für Feuerwehren und Vereine.
Einsatzverwaltung für Feuerwehren und Vereine.
## Einleitung
Dieses Repository dient hauptsächlich zur Verwaltung Einsätzen oder Übungen der Feuerwehr oder Arbeitseinsätzen eines Vereins. Es ist ein Frontend-Client, der auf die Daten des [ff-operation-server Backends](https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation-server) zugreift. Die Webapp bietet eine Möglichkeit Anwesenheiten und Zeiten zu verwalten. Benutzer können eingeladen und Rollen zugewiesen werden.
Eine Demo dieser Seite finden Sie unter [https://operation-demo.ff-admin.de](https://operation-demo.ff-admin.de).
## Installation
### Docker Compose Setup
Um den Container hochzufahren, erstellen Sie eine `docker-compose.yml` Datei mit folgendem Inhalt:
```yaml
version: "3"
services:
ff-operation-app:
image: docker.registry.jk-effects.cloud/ehrenamt/ff-operation/app:latest
container_name: ff_operation
restart: unless-stopped
#environment:
# - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad
# - APPNAMEOVERWRITE=<appname> # ersetzt den Namen FF-operation 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.
Führen Sie dann den folgenden Befehl im Verzeichnis der compose-Datei aus, um den Container zu starten:
```sh
docker-compose up -d
```
### Manuelle Installation
Klonen Sie dieses Repository und installieren Sie die Abhängigkeiten:
```sh
git clone https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation.git
cd ff-operation
npm install
npm run build
npm run start
```
### Konfiguration
Ein eigenes Favicon und Logo kann über das verwenden Volume ausgetauscht werden. Es dürfen jedoch nur einzelne Dateien ausgetauscht werden.
## Einrichtung
1. **Admin Benutzer erstellen**: Erstellen Sie einen Admin Benutzer unter dem Pfad /setup, um auf die Einsatzverwaltung Zugriff zu erhalten. Nach der Erstellung des ersten Benutzers wird der Pfad automatisch geblockt.
2. **Rollen und Berechtigungen**: Unter `Benutzer > Rollen` können die Rollen und Berechtigungen für die Benutzer erstellt und angepasst werden.
3. **Nutzer einladen**: Unter `Benutzer > Benutzer` können weitere Nutzer eingeladen werden. Diese erhalten dann eine E-Mail mit einem Link, um ein TOTP zu erhalten.
## Fragen und Wünsche
Bei Fragen, Anregungen oder Wünschen können Sie sich gerne melden.\
Wir freuen uns über Ihr Feedback und helfen Ihnen gerne weiter.\
Schreiben Sie dafür eine Mail an julian.krauser@jk-effects.com.

27
entrypoint.sh Normal file
View file

@ -0,0 +1,27 @@
#!/bin/sh
keys="SERVERADDRESS APPNAMEOVERWRITE IMPRINTLINK PRIVACYLINK CUSTOMLOGINMESSAGE"
files="/usr/share/nginx/html/assets/config-*.js /usr/share/nginx/html/manifest.webmanifest"
# Replace env vars in files served by NGINX
for file in $files
do
echo "Processing $file ...";
for key in $keys
do
# Get environment variable
value=$(eval echo "\$$key")
# Set default value for APPNAMEOVERWRITE if empty
if [ "$key" = "APPNAMEOVERWRITE" ] && [ -z "$value" ]; then
value="FF Operation"
fi
echo "replace $key by $value"
# replace __[variable_name]__ value with environment variable
sed -i 's|__'"$key"'__|'"$value"'|g' $file
done
done
nginx -g 'daemon off;'

1
env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<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" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

16
nginx.conf Normal file
View file

@ -0,0 +1,16 @@
worker_processes 4;
events { worker_connections 1024; }
http {
include mime.types;
server {
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
}

10399
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

76
package.json Normal file
View file

@ -0,0 +1,76 @@
{
"name": "ff-operation",
"version": "0.0.0",
"description": "Feuerwehr/Verein Einsatzverwaltung UI",
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"bnp": "npm run build-only && npm run preview",
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/fw-wappen.png"
},
"repository": {
"type": "git",
"url": "https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation.git"
},
"keywords": [
"Feuerwehr"
],
"author": "JK Effects",
"license": "AGPL-3.0-only",
"dependencies": {
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9",
"jwt-decode": "^4.0.0",
"lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0",
"lodash.differencewith": "^4.5.0",
"lodash.isequal": "^4.5.0",
"nprogress": "^0.2.0",
"pinia": "^2.3.0",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"socket.io-client": "^4.5.0",
"uuid": "^9.0.0",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0",
"@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/node": "^20.14.5",
"@types/nprogress": "^0.2.0",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.11",
"@types/uuid": "^9.0.3",
"@vite-pwa/assets-generator": "^0.2.2",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"npm-run-all2": "^6.2.0",
"postcss": "^8.4.41",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.10",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vite-plugin-pwa": "^0.17.4",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.0.21"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

40
src/App.vue Normal file
View file

@ -0,0 +1,40 @@
<template>
<Modal />
<ContextMenu />
<Header @contextmenu.prevent />
<div class="grow overflow-x-hidden overflow-y-auto p-2 md:p-4" @contextmenu.prevent>
<RouterView />
</div>
<Footer @contextmenu.prevent />
<Notification />
</template>
<script setup lang="ts">
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 { 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";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),
},
mounted() {
if (!this.authCheck && localStorage.getItem("access_token")) {
isAuthenticatedPromise().catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
});
}
},
});
</script>

View file

@ -0,0 +1,48 @@
<template>
<div
ref="contextMenu"
class="absolute flex flex-col gap-1 border border-gray-400 bg-white rounded-md select-none text-left shadow-md z-50 p-1"
v-show="show"
:style="contextMenuStyle"
@contextmenu.prevent
@click="closeContextMenu"
>
<component :is="component_ref" :data="data" />
<!-- <template v-for="item in contextMenu" :key="item">
<hr v-if="item.separator" />
<div v-else class="flex flex-row gap-2 rounded-md p-1 px-2 items-center"
:class="typeof item.click == 'function' ? 'cursor-pointer hover:bg-gray-200' : ''" @click="item.click">
<font-awesome-icon v-if="item.icon" class="text-md" :icon="[item.stroke || 'far', item.icon]" />
<span class="font-normal">{{ item.title }}</span>
</div>
</template> -->
</div>
</template>
<script setup lang="ts">
import { mapState, mapActions } from "pinia";
import { useContextMenuStore } from "@/stores/context-menu";
</script>
<script lang="ts">
export default {
computed: {
...mapState(useContextMenuStore, ["show", "contextMenuStyle", "component_ref", "data"]),
},
methods: {
...mapActions(useContextMenuStore, ["closeContextMenu"]),
},
mounted() {
document.body.addEventListener("click", (event) => {
if (!(this.$refs.contextMenu as HTMLElement)?.contains(event.target as HTMLElement)) {
this.closeContextMenu();
}
});
// document.body.addEventListener("contextmenu", (event) => {
// if (!this.$refs.contextMenu?.contains(event.target)) {
// this.closeContextMenu();
// }
// });
},
};
</script>

View file

@ -0,0 +1,61 @@
<template>
<svg class="checkmark min-w-fit min-h-fit max-w-fit max-h-fit" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none" />
<path class="checkmark__check" fill="none" d="M 11 11 l 30 30 M 11 41 l 30 -30" />
<!-- <path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" /> -->
</svg>
</template>
<style scoped>
.checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #ff0000;
fill: none;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark {
width: 20px;
height: 20px;
border-radius: 50%;
display: block;
stroke-width: 5;
stroke: #fff;
stroke-miterlimit: 10;
margin: auto 0;
box-shadow: inset 0px 0px 0px #ff0000;
animation:
fill 0.4s ease-in-out 0.4s forwards,
scale 0.3s ease-in-out 0.9s both;
}
.checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes scale {
0%,
100% {
transform: none;
}
50% {
transform: scale3d(1.1, 1.1, 1);
}
}
@keyframes fill {
100% {
box-shadow: inset 0px 0px 0px 30px #ff0000;
}
}
</style>

43
src/components/Footer.vue Normal file
View file

@ -0,0 +1,43 @@
<template>
<footer
v-if="authCheck && (routeName.includes('admin-') || routeName.includes('account-') || routeName.includes('docs-'))"
class="md:hidden flex flex-row h-16 min-h-16 justify-center md:justify-normal p-1 bg-white"
>
<div class="w-full flex flex-row gap-2 h-full align-middle">
<TopLevelLink
v-if="routeName == 'admin' || routeName.includes('admin-')"
v-for="item in topLevel"
:key="item.key"
:link="item"
:disableSubLink="true"
/>
<TopLevelLink
v-else-if="
routeName == 'account' || routeName.includes('account-') || routeName == 'docs' || routeName.includes('docs-')
"
:link="{ key: 'operation', title: 'Zur Admin Oberfläche', levelDefault: '' }"
:disableSubLink="true"
/>
</div>
</footer>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import { useNavigationStore } from "@/stores/admin/navigation";
import TopLevelLink from "./admin/TopLevelLink.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),
...mapState(useNavigationStore, ["topLevel"]),
routeName() {
return typeof this.$route.name == "string" ? this.$route.name : "";
},
},
});
</script>

View file

@ -0,0 +1,18 @@
<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>
</div>
<p v-if="config.custom_login_message">{{ config.custom_login_message }}</p>
<p>
<a href="https://ff-admin.de/admin" target="_blank">FF Operation</a>
entwickelt von
<a href="https://jk-effects.com" target="_blank">JK Effects</a>
</p>
</div>
</template>
<script setup lang="ts">
import { config } from "@/config";
</script>

54
src/components/Header.vue Normal file
View file

@ -0,0 +1,54 @@
<template>
<header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-sm">
<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" />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">
{{ config.app_name_overwrite || "FF Operation" }}
</h1>
</RouterLink>
<div class="flex flex-row gap-2 items-center">
<div v-if="authCheck" class="hidden md:flex flex-row gap-2 h-full align-middle">
<TopLevelLink
v-if="routeName == 'admin' || routeName.includes('admin-')"
v-for="item in topLevel"
:key="item.key"
:link="item"
/>
<TopLevelLink
v-else-if="
routeName == 'account' ||
routeName.includes('account-') ||
routeName == 'docs' ||
routeName.includes('docs-')
"
:link="{ key: 'operation', title: 'Zur Admin Oberfläche', levelDefault: '' }"
:disable-sub-link="true"
/>
</div>
<UserMenu v-if="authCheck" />
</div>
</header>
</template>
<script setup lang="ts">
import { RouterLink } from "vue-router";
import { mapState } from "pinia";
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";
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),
...mapState(useNavigationStore, ["topLevel"]),
routeName() {
return typeof this.$route.name == "string" ? this.$route.name : "";
},
},
});
</script>

32
src/components/Modal.vue Normal file
View file

@ -0,0 +1,32 @@
<template>
<div
ref="contextMenu"
class="absolute inset-0 w-full h-full flex justify-center items-center bg-black/50 select-none z-50 p-2"
v-show="show"
@contextmenu.prevent
>
<!-- @click="closeModal" -->
<component
:is="component_ref"
:data="data"
@click.stop
class="p-4 bg-white rounded-lg max-h-[95%] overflow-y-auto"
/>
</div>
</template>
<script setup lang="ts">
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
</script>
<script lang="ts">
export default {
computed: {
...mapState(useModalStore, ["show", "component_ref", "data"]),
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
},
};
</script>

View file

@ -0,0 +1,130 @@
<template>
<div
class="fixed right-0 flex flex-col gap-4 p-2 w-full md:w-80 z-50"
:class="position == 'bottom' ? 'bottom-0' : 'top-0'"
>
<TransitionGroup
:enter-active-class="notifications.length > 1 ? [props.enter, props.moveDelay].join(' ') : props.enter"
:enter-from-class="props.enterFrom"
:enter-to-class="props.enterTo"
:leave-active-class="props.leave"
:leave-from-class="props.leaveFrom"
:leave-to-class="props.leaveTo"
:move-class="props.move"
>
<div
v-for="notification in sortedNotifications"
:key="notification.id"
class="relative p-2 bg-white flex flex-row gap-2 w-full overflow-hidden rounded-lg shadow-md"
:class="[
notification.type == 'error' ? 'border border-red-400' : '',
notification.type == 'warning' ? 'border border-red-400' : '',
notification.type == 'info' ? 'border border-gray-400' : '',
]"
>
<!-- @mouseover="hovering(notification.id, true)"
@mouseleave="hovering(notification.id, false)" -->
<ExclamationCircleIcon
v-if="notification.type == 'error'"
class="flex items-center justify-center min-w-12 w-12 h-12 bg-red-500 rounded-lg text-white p-1"
/>
<ExclamationTriangleIcon
v-if="notification.type == 'warning'"
class="flex items-center justify-center min-w-12 w-12 h-12 bg-red-500 rounded-lg text-white p-1"
/>
<InformationCircleIcon
v-if="notification.type == 'info'"
class="flex items-center justify-center min-w-12 w-12 h-12 bg-gray-500 rounded-lg text-white p-1"
/>
<div class="flex flex-col">
<span
class="font-semibold"
:class="[
notification.type == 'error' ? 'text-red-500' : '',
notification.type == 'warning' ? 'text-red-500' : '',
notification.type == 'info' ? 'text-gray-700' : '',
]"
>{{ notification.title }}</span
>
<p class="text-sm text-gray-600">{{ notification.text }}</p>
</div>
<XMarkIcon
@click="close(notification.id)"
class="absolute top-2 right-2 w-6 h-6 cursor-pointer text-gray-500"
/>
<div
class="absolute left-0 bottom-0 h-1 bg-gray-500 transition-[width] duration-[4900ms] ease-linear"
:class="notification.indicator ? 'w-0' : 'w-full'"
></div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { defineComponent, TransitionGroup } from "vue";
import { mapState, mapActions } from "pinia";
import { useNotificationStore } from "@/stores/notification";
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
InformationCircleIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
export interface Props {
maxNotifications?: number;
enter?: string;
enterFrom?: string;
enterTo?: string;
leave?: string;
leaveFrom?: string;
leaveTo?: string;
move?: string;
moveDelay?: string;
position?: string;
}
const props = withDefaults(defineProps<Props>(), {
maxNotifications: 10,
enter: "transform ease-out duration-300 transition",
enterFrom: "translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4",
enterTo: "translate-y-0 opacity-100 sm:translate-x-0",
leave: "transition ease-in duration-500",
leaveFrom: "opacity-100",
leaveTo: "opacity-0",
move: "transition duration-500",
moveDelay: "delay-300",
position: "bottom",
});
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useNotificationStore, ["notifications", "timeouts"]),
sortedNotifications() {
if (this.position === "bottom") {
return [...this.notifications];
}
return [...this.notifications].reverse();
},
},
methods: {
...mapActions(useNotificationStore, ["revoke"]),
close(id: string) {
this.revoke(id);
},
hovering(id: string, value: boolean, timeout?: number) {
if (value) {
clearTimeout(this.timeouts[id]);
} else {
this.timeouts[id] = setTimeout(() => {
this.revoke(id);
}, timeout);
}
},
},
});
</script>

View file

@ -0,0 +1,265 @@
<template>
<div class="grow flex flex-col gap-2 overflow-hidden">
<div v-if="useSearch" class="relative self-end flex flex-row items-center gap-2">
<Spinner v-if="deferingSearch" />
<input
type="text"
class="!max-w-64 !w-64 rounded-md shadow-sm relative block px-3 py-2 pr-5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Suche"
v-model="searchString"
/>
<XMarkIcon
class="absolute h-4 stroke-2 right-2 top-1/2 -translate-y-1/2 cursor-pointer z-10"
@click="searchString = ''"
/>
</div>
<div class="flex flex-col w-full grow gap-2 pr-2 overflow-y-scroll">
<div v-if="indicateLoading" class="flex flex-row justify-center items-center w-full p-1">
<Spinner />
</div>
<p v-if="visibleRows.length == 0" class="flex flex-row w-full gap-2 p-1">Kein Inhalt</p>
<slot
v-else
name="pageRow"
v-for="(item, index) in visibleRows"
:key="index"
:row="item"
@click="$emit('clickRow', item)"
>
<p>{{ item }}</p>
</slot>
</div>
<div class="flex flex-row w-full justify-between select-none">
<p class="text-sm font-normal text-gray-500">
Elemente <span class="font-semibold text-gray-900">{{ showingText }}</span> von
<span class="font-semibold text-gray-900">{{ entryCount }}</span>
</p>
<ul class="flex flex-row text-sm h-8">
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage > 0 ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50']"
@click="loadPage(currentPage - 1)"
>
<ChevronLeftIcon class="h-4" />
</li>
<li
v-for="page in displayedPagesNumbers"
:key="page"
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage == page ? 'font-bold border-primary' : '', page != '.' ? ' cursor-pointer' : '']"
@click="loadPage(page)"
>
{{ typeof page == "number" ? page + 1 : "..." }}
</li>
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[
currentPage + 1 < countOfPages ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50',
]"
@click="loadPage(currentPage + 1)"
>
<ChevronRightIcon class="h-4" />
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends { id: string | number }">
import { computed, ref, watch } from "vue";
import { ChevronRightIcon, ChevronLeftIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import Spinner from "./Spinner.vue";
const props = defineProps({
items: { type: Array<T>, default: [] },
maxEntriesPerPage: { type: Number, default: 25 },
totalCount: { type: Number, default: null },
config: { type: Array<{ key: string }>, default: [] },
useSearch: { type: Boolean, default: false },
enablePreSearch: { type: Boolean, default: false },
indicateLoading: { type: Boolean, default: false },
});
const slots = defineSlots<{
pageRow(props: { row: T; key: number }): void;
}>();
const timer = ref(undefined) as undefined | any;
const currentPage = ref(0);
const searchString = ref("");
const deferingSearch = ref(false);
watch(searchString, async () => {
deferingSearch.value = true;
clearTimeout(timer.value);
timer.value = setTimeout(() => {
currentPage.value = 0;
deferingSearch.value = false;
emit("search", searchString.value);
}, 600);
});
watch(
() => props.totalCount,
async () => {
currentPage.value = 0;
}
);
const emit = defineEmits({
submit(id: number) {
return typeof id == "number";
},
loadData(offset: number, count: number, searchString: string) {
return typeof offset == "number" && typeof offset == "number" && typeof searchString == "number";
},
search(search: string) {
return typeof search == "string";
},
clickRow(elem: T) {
return true;
},
});
const entryCount = computed(() => props.totalCount ?? props.items.length);
const showingStart = computed(() => currentPage.value * props.maxEntriesPerPage);
const showingEnd = computed(() => {
let max = currentPage.value * props.maxEntriesPerPage + props.maxEntriesPerPage;
if (max > entryCount.value) max = entryCount.value;
return max;
});
const showingText = computed(() => `${entryCount.value != 0 ? showingStart.value + 1 : 0} - ${showingEnd.value}`);
const countOfPages = computed(() => Math.ceil(entryCount.value / props.maxEntriesPerPage));
const displayedPagesNumbers = computed(() => {
let stateOfPush = false;
return [...new Array(countOfPages.value)].reduce((acc, curr, index) => {
if (
index <= 1 ||
index >= countOfPages.value - 2 ||
(currentPage.value - 1 <= index && index <= currentPage.value + 1)
) {
acc.push(index);
stateOfPush = false;
return acc;
}
if (stateOfPush == true) return acc;
acc.push(".");
stateOfPush = true;
return acc;
}, []);
});
const visibleRows = computed(() => filterData(props.items, searchString.value, showingStart.value, showingEnd.value));
const loadPage = (newPage: number | ".") => {
if (newPage == ".") return;
if (newPage < 0 || newPage >= countOfPages.value) return;
let pageStart = newPage * props.maxEntriesPerPage;
let pageEnd = newPage * props.maxEntriesPerPage + props.maxEntriesPerPage;
if (pageEnd > entryCount.value) pageEnd = entryCount.value;
let loadedElementCount = filterData(props.items, searchString.value, pageStart, pageEnd).length;
if (loadedElementCount < props.maxEntriesPerPage && (pageEnd != props.totalCount || loadedElementCount == 0))
emit("loadData", pageStart, props.maxEntriesPerPage, searchString.value);
currentPage.value = newPage;
};
const filterData = (array: Array<any>, searchString: string, start: number, end: number): Array<any> => {
return array
.filter(
(elem) =>
!props.enablePreSearch ||
searchString.trim() == "" ||
props.config.some(
(col) =>
typeof elem?.[col.key] == "string" &&
elem[col.key].toLowerCase().includes(searchString.trim().toLowerCase())
)
)
.filter((elem, index) => (elem?.tab_pos ?? index) >= start && (elem?.tab_pos ?? index) < end);
};
</script>
<!--
<script lang="ts">
export default defineComponent({
computed: {
entryCount() {
return this.totalCount ?? this.items.length;
},
showingStart() {
return this.currentPage * this.maxEntriesPerPage;
},
showingEnd() {
let max = this.currentPage * this.maxEntriesPerPage + this.maxEntriesPerPage;
if (max > this.entryCount) max = this.entryCount;
return max;
},
showingText() {
return `${this.entryCount != 0 ? this.showingStart + 1 : 0} - ${this.showingEnd}`;
},
countOfPages() {
return Math.ceil(this.entryCount / this.maxEntriesPerPage);
},
displayedPagesNumbers(): Array<number | "."> {
//indicate if "." or page number gets pushed
let stateOfPush = false;
return [...new Array(this.countOfPages)].reduce((acc, curr, index) => {
if (
// always display first 2 pages
index <= 1 ||
// always display last 2 pages
index >= this.countOfPages - 2 ||
// always display 1 pages around current page
(this.currentPage - 1 <= index && index <= this.currentPage + 1)
) {
acc.push(index);
stateOfPush = false;
return acc;
}
// abort if placeholder already added to array
if (stateOfPush == true) return acc;
// show placeholder if pagenumber is not actively rendered
acc.push(".");
stateOfPush = true;
return acc;
}, []);
},
visibleRows() {
return this.filterData(this.items, this.searchString, this.showingStart, this.showingEnd);
},
},
methods: {
loadPage(newPage: number | ".") {
if (newPage == ".") return;
if (newPage < 0 || newPage >= this.countOfPages) return;
let pageStart = newPage * this.maxEntriesPerPage;
let pageEnd = newPage * this.maxEntriesPerPage + this.maxEntriesPerPage;
if (pageEnd > this.entryCount) pageEnd = this.entryCount;
let loadedElementCount = this.filterData(this.items, this.searchString, pageStart, pageEnd).length;
if (loadedElementCount < this.maxEntriesPerPage)
this.$emit("loadData", { offset: pageStart, count: this.maxEntriesPerPage, search: this.searchString });
this.currentPage = newPage;
},
filterData(array: Array<any>, searchString: string, start: number, end: number): Array<any> {
return array
.filter(
(elem) =>
!this.enablePreSearch ||
searchString.trim() == "" ||
this.config.some((col) => typeof elem?.[col.key] == "string" && elem[col.key].includes(searchString.trim()))
)
.filter((elem, index) => (elem?.tab_pos ?? index) >= start && (elem?.tab_pos ?? index) < end);
},
},
});
</script> -->

View file

@ -0,0 +1,3 @@
<template>
<div class="w-5 h-5 border-y-2 rounded-full border-gray-700 animate-spin" />
</template>

View file

@ -0,0 +1,60 @@
<template>
<svg class="checkmark min-w-fit min-h-fit max-w-fit max-h-fit" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none" />
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
</svg>
</template>
<style scoped>
.checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #7ac142;
fill: none;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark {
width: 20px;
height: 20px;
border-radius: 50%;
display: block;
stroke-width: 5;
stroke: #fff;
stroke-miterlimit: 10;
margin: auto 0;
box-shadow: inset 0px 0px 0px #7ac142;
animation:
fill 0.4s ease-in-out 0.4s forwards,
scale 0.3s ease-in-out 0.9s both;
}
.checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes scale {
0%,
100% {
transform: none;
}
50% {
transform: scale3d(1.1, 1.1, 1);
}
}
@keyframes fill {
100% {
box-shadow: inset 0px 0px 0px 30px #7ac142;
}
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="flex relative">
<input type="text" :value="copyText" />
<ClipboardIcon
class="w-5 h-5 p-2 box-content absolute right-1 top-1/2 -translate-y-1/2 bg-white cursor-pointer"
@click="copyToClipboard"
/>
<div v-if="copySuccess" class="absolute w-5 h-5 right-3 top-[10px]">
<SuccessCheckmark />
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import { ClipboardIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
copyText: {
type: String,
default: "",
},
},
data() {
return {
timeoutCopy: undefined as any,
copySuccess: false,
};
},
methods: {
copyToClipboard() {
navigator.clipboard.writeText(this.copyText ?? "");
this.copySuccess = true;
this.timeoutCopy = setTimeout(() => {
this.copySuccess = false;
}, 2000);
},
},
});
</script>

View file

@ -0,0 +1,55 @@
<template>
<Menu as="div" class="relative inline-block text-left self-center">
<MenuButton class="cursor-pointer flex flex-row gap-2 p-1 w-fit h-fit box-content self-center">
<UserIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</MenuButton>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute right-0 mt-2 w-56 z-20 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div class="px-3 py-1 pt-2">
<p class="text-xs">Angemeldet als</p>
<p class="font-bold leading-4 text-base">{{ firstname + " " + lastname }}</p>
</div>
<div class="px-1 py-1 w-full flex flex-col gap-2">
<MenuItem v-slot="{ close }">
<RouterLink to="/account/me">
<button button primary @click="close">Mein Account</button>
</RouterLink>
</MenuItem>
<MenuItem>
<span>
<button primary-outline @click="logoutAccount">ausloggen</button>
</span>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
import { mapState, mapActions } from "pinia";
import { UserIcon } from "@heroicons/vue/24/outline";
import { useAccountStore } from "@/stores/account";
</script>
<script lang="ts">
export default {
computed: {
...mapState(useAccountStore, ["firstname", "lastname"]),
},
methods: {
...mapActions(useAccountStore, ["logoutAccount"]),
},
};
</script>

View file

@ -0,0 +1,182 @@
<template>
<div class="w-full">
<Combobox v-model="selected" :disabled="disabled" multiple>
<ComboboxLabel>{{ title }}</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-sm 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-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="loading || deferingSearch" as="template" disabled>
<li class="flex flex-row gap-2 text-text relative cursor-default select-none py-2 pl-3 pr-4">
<Spinner />
<span class="font-normal block truncate">suche</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="filtered.length === 0 && query == ''" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">tippe, um zu suchen...</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">Keine Auswahl gefunden.</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="!(loading || deferingSearch)"
v-for="force in filtered"
as="template"
:key="force.id"
:value="force.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ force.firstname }} {{ force.lastname }} {{ force.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useForceStore } from "@/stores/admin/configuration/forces";
import type { ForceViewModel } from "@/viewmodels/admin/configuration/force.models";
import difference from "lodash.difference";
import Spinner from "../Spinner.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
modelValue: {
type: Array as PropType<Array<string>>,
default: [],
},
title: String,
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:model-value", "add:difference", "remove:difference", "add:force", "add:forceByArray"],
watch: {
modelValue() {
if (this.initialLoaded) return;
this.initialLoaded = true;
this.loadForcesInitial();
},
query() {
this.deferingSearch = true;
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.deferingSearch = false;
this.search();
}, 600);
},
},
data() {
return {
initialLoaded: false as boolean,
loading: false as boolean,
deferingSearch: false as boolean,
timer: undefined as any,
query: "" as string,
filtered: [] as Array<ForceViewModel>,
};
},
computed: {
selected: {
get() {
return this.modelValue;
},
set(val: Array<string>) {
this.$emit("update:model-value", val);
if (this.modelValue.length < val.length) {
let diff = difference(val, this.modelValue);
if (diff.length != 1) return;
this.$emit("add:difference", diff[0]);
this.$emit("add:force", this.getForceFromSearch(diff[0]));
} else {
let diff = difference(this.modelValue, val);
if (diff.length != 1) return;
this.$emit("remove:difference", diff[0]);
}
},
},
},
mounted() {
this.loadForcesInitial();
},
methods: {
...mapActions(useForceStore, ["searchForces", "getForcesByIds"]),
search() {
this.filtered = [];
if (this.query == "") return;
this.loading = true;
this.searchForces(this.query)
.then((res) => {
this.filtered = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
getForceFromSearch(id: string) {
return this.filtered.find((f) => f.id == id);
},
loadForcesInitial() {
if (this.modelValue.length == 0) return;
this.getForcesByIds(this.modelValue)
.then((res) => {
this.$emit("add:forceByArray", res.data);
})
.catch(() => {});
},
},
});
</script>

View file

@ -0,0 +1,198 @@
<template>
<div
class="flex flex-col gap-2 max-w-2xl mx-auto w-full select-none"
:class="disableEdit ? ' pointer-events-none opacity-60 bg-gray-100/50' : ''"
>
<div class="flex flex-row gap-2 h-fit w-full border border-gray-300 rounded-md p-2">
<input type="checkbox" name="admin" id="admin" class="cursor-pointer" :checked="isAdmin" @change="toggleAdmin" />
<label for="admin" class="cursor-pointer">Administratorrecht</label>
</div>
<div
v-for="section in sections"
:key="section"
class="flex flex-col gap-2 h-fit w-full border border-primary rounded-md"
:class="isAdmin && !disableEdit ? ' pointer-events-none opacity-60 bg-gray-100' : ''"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>Abschnitt: {{ section }}</p>
<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' : ''"
@click="togglePermission('read', section)"
/>
<PlusIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(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' : ''"
@click="togglePermission('update', section)"
/>
<TrashIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'delete', section) ? 'bg-success' : ''"
@click="togglePermission('delete', section)"
/>
</div>
</div>
<div
v-for="modul in permissionStructure[section]"
:key="modul"
class="p-1 px-2 flex flex-row justify-between items-center"
>
<p>Modul: {{ modul }}</p>
<div class="flex flex-row border border-gray-300 rounded-md overflow-hidden">
<EyeIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'read', section, modul) ? 'bg-success' : ''"
@click="togglePermission('read', section, modul)"
/>
<PlusIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'create', section, modul) ? 'bg-success' : ''"
@click="togglePermission('create', section, modul)"
/>
<PencilIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'update', section, modul) ? 'bg-success' : ''"
@click="togglePermission('update', section, modul)"
/>
<TrashIcon
class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'delete', section, modul) ? 'bg-success' : ''"
@click="togglePermission('delete', section, modul)"
/>
</div>
</div>
</div>
<div v-if="!disableEdit" class="flex flex-row gap-2 self-end pt-4">
<button primary-outline class="!w-fit" @click="reset" :disabled="canSaveOrReset">verwerfen</button>
<button primary class="!w-fit" @click="submit" :disabled="status == 'loading' || canSaveOrReset">
speichern
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import type { PropType } from "vue";
import type {
PermissionModule,
PermissionObject,
PermissionSection,
PermissionType,
SectionsAndModulesObject,
} from "@/types/permissionTypes";
import { sectionsAndModules, permissionSections, permissionTypes } from "@/types/permissionTypes";
import { mapState, mapActions } from "pinia";
import { EyeIcon, PencilIcon, PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
permissions: {
type: Object as PropType<PermissionObject>,
default: {},
},
status: {
type: [Object, String, null] as PropType<null | "loading" | { status: "success" | "failed"; message?: string }>,
default: null,
},
disableEdit: {
type: Boolean,
default: false,
},
},
watch: {
permissions() {
this.permissionUpdate = cloneDeep(this.permissions);
},
},
emits: ["savePermissions"],
data() {
return {
isAdmin: false,
sections: [] as Array<PermissionSection>,
permissionStructure: {} as SectionsAndModulesObject,
permissionUpdate: {} as PermissionObject,
};
},
computed: {
...mapState(useAbilityStore, ["_can"]),
canSaveOrReset(): boolean {
return isEqual(this.permissions, this.permissionUpdate);
},
},
mounted() {
this.sections = permissionSections;
this.permissionStructure = sectionsAndModules;
this.permissionUpdate = cloneDeep(this.permissions);
this.isAdmin = this.permissions.admin ?? false;
},
methods: {
toggleAdmin(e: Event) {
const target = e.target as HTMLInputElement;
this.isAdmin = target.checked ?? false;
this.permissionUpdate.admin = this.isAdmin;
if (!this.isAdmin) {
delete this.permissionUpdate.admin;
}
},
togglePermission(type: PermissionType, section: PermissionSection, modul?: PermissionModule) {
let permissions = [] as Array<PermissionType> | "*";
if (!modul) {
permissions = this.permissionUpdate[section]?.all ?? [];
} else {
permissions = this.permissionUpdate[section]?.[modul] ?? [];
}
if (permissions == "*") {
permissions = permissionTypes;
}
if (permissions.includes(type)) {
let add = permissions.slice(-1)[0] == type ? 0 : 1;
let whatToRemove = permissionTypes.slice(permissionTypes.indexOf(type) + add);
permissions = permissions.filter((permission) => !whatToRemove.includes(permission));
} else {
let whatToAdd = permissionTypes.slice(0, permissionTypes.indexOf(type) + 1);
permissions = whatToAdd;
}
if (!modul) {
if (!this.permissionUpdate[section]) {
this.permissionUpdate[section] = {};
}
this.permissionUpdate[section]!.all = permissions;
} else {
if (!this.permissionUpdate[section]) {
this.permissionUpdate[section] = {};
}
this.permissionUpdate[section]![modul] = permissions;
}
},
reset() {
this.permissionUpdate = cloneDeep(this.permissions);
this.isAdmin = this.permissions.admin ?? false;
},
submit() {
this.$emit("savePermissions", this.permissionUpdate);
},
},
});
</script>

View file

@ -0,0 +1,36 @@
<template>
<RouterLink v-if="link" :to="link">
<p
class="cursor-pointer w-full px-2 py-3"
:class="active ? 'rounded-r-lg bg-red-200 border-l-4 border-l-primary' : 'pl-3 hover:bg-red-200 rounded-lg'"
>
{{ title }}
</p>
</RouterLink>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { RouterLink } from "vue-router";
import { mapState, mapActions } from "pinia";
import { useNavigationStore, type navigationLinkModel } from "@/stores/admin/navigation";
</script>
<script lang="ts">
export default defineComponent({
props: {
title: {
type: String,
default: "LINK",
},
link: {
type: Object as PropType<string | { name: string, params?:{[key:string]:string} }>,
default: "/",
},
active: {
type: Boolean,
default: false,
},
},
});
</script>

View file

@ -0,0 +1,43 @@
<template>
<RouterLink
v-if="link"
:to="{ name: `admin-${link.key}-${!disableSubLink ? link.levelDefault : 'default'}` }"
class="cursor-pointer w-full flex items-center justify-center self-center"
>
<p
class="cursor-pointer w-full flex flex-col md:flex-row items-center md:gap-2 justify-center p-1 md:rounded-full md:px-3 font-medium text-center text-base self-center"
:class="
activeNavigation == link.key
? 'text-primary md:bg-primary md:text-white'
: 'text-gray-700 hover:text-accent md:hover:bg-accent md:hover:text-white'
"
>
{{ link.title }}
</p>
</RouterLink>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { RouterLink } from "vue-router";
import { useNavigationStore, type topLevelNavigationModel } from "@/stores/admin/navigation";
import { mapState } from "pinia";
</script>
<script lang="ts">
export default defineComponent({
props: {
link: {
type: Object as PropType<topLevelNavigationModel>,
default: null,
},
disableSubLink: {
type: Boolean,
default: false,
},
},
computed: {
...mapState(useNavigationStore, ["activeNavigation"]),
},
});
</script>

View file

@ -0,0 +1,88 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Kraft erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<label for="firstname">Vorname</label>
<input type="text" id="firstname" required />
</div>
<div>
<label for="lastname">Nachname</label>
<input type="text" id="lastname" required />
</div>
<div>
<label for="nameaffix">Nameaffix (optional)</label>
<input type="text" id="nameaffix" />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useForceStore } from "@/stores/admin/configuration/forces";
import type { CreateForceViewModel } from "@/viewmodels/admin/configuration/force.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useForceStore, ["createForce"]),
triggerCreate(e: any) {
let formData = e.target.elements;
let createForce: CreateForceViewModel = {
firstname: formData.firstname.value,
lastname: formData.lastname.value,
nameaffix: formData.nameaffix.value,
};
this.status = "loading";
this.createForce(createForce)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,87 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Mitglied löschen</p>
</div>
<br />
<p class="text-center">
Mitglied {{ force?.lastname }}, {{ force?.firstname }}
{{ force?.nameaffix ? `- ${force.nameaffix}` : "" }} löschen?
</p>
<br />
<div class="flex flex-row gap-2">
<button
primary
type="submit"
:disabled="status == 'loading' || status?.status == 'success'"
@click="triggerDelete"
>
löschen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useForceStore } from "@/stores/admin/configuration/forces";
import type { CreateForceViewModel } from "@/viewmodels/admin/configuration/force.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useForceStore, ["forces"]),
force() {
return this.forces.find((m) => m.id == this.data);
},
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useForceStore, ["deleteForce"]),
triggerDelete() {
this.status = "loading";
this.deleteForce(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.$router.push({ name: "admin-club-force" });
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,31 @@
<template>
<RouterLink
:to="{ name: 'admin-club-force-overview', params: { forceId: force.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ force.lastname }}, {{ force.firstname }} {{ force.nameaffix ? `- ${force.nameaffix}` : "" }}</p>
</div>
<div class="p-2">
<p>Daten</p>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import type { ForceViewModel } from "@/viewmodels/admin/configuration/force.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
force: { type: Object as PropType<ForceViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
});
</script>

View file

@ -0,0 +1,58 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ backup }}</p>
<div class="flex flex-row">
<div @click="downloadBackup">
<ArrowDownTrayIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
<div v-if="can('admin', 'management', 'backup')" @click="openRestoreModal">
<BarsArrowUpIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineAsyncComponent, markRaw, type PropType } from "vue";
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";
</script>
<script lang="ts">
export default defineComponent({
props: {
backup: { type: String, default: "" },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useBackupStore, ["fetchBackupById"]),
openRestoreModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/management/backup/RestoreBackupModal.vue"))),
this.backup
);
},
downloadBackup() {
this.fetchBackupById(this.backup)
.then((response) => {
const fileURL = window.URL.createObjectURL(new Blob([JSON.stringify(response.data, null, 2)]));
const fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", this.backup);
document.body.appendChild(fileLink);
fileLink.click();
fileLink.remove();
})
.catch(() => {});
},
},
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Backup erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreateBackup">
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
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";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useBackupStore, ["triggerBackupCreate"]),
triggerCreateBackup(e: any) {
this.status = "loading";
this.triggerBackupCreate()
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,112 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Backup {{ data }} laden</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreateBackup">
<!-- <div class="flex flex-row items-center gap-2">
<input type="checkbox" id="partial" v-model="partial" />
<label for="partial">Backup vollständig laden</label>
</div>
<div v-if="!partial">
<label for="sections">Module zur Wiederherstellung auswählen:</label>
<select id="sections" multiple>
<option v-for="section in backupSections" :value="section">{{ section }}</option>
</select>
</div>
<div v-if="!partial" class="flex flex-row items-center gap-2">
<input type="checkbox" id="overwrite" checked />
<label for="overwrite">Daten entfernen und importieren</label>
</div> -->
<p>Backups ersetzen den aktuellen Stand vollständig.</p>
<br />
<!-- <p class="flex">
<InformationCircleIcon class="min-h-5 h-5 min-w-5 w-5" />Je nach Auswahl, werden die entsprechenden
Bestandsdaten ersetzt. Dadurch können Daten, die seit diesem Backup erstellt wurden, verloren gehen.
</p>
<p class="flex">Das Laden eines vollständigen Backups wird zur Vermeidung von Inkonsistenzen empfohlen.</p> -->
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">
Backup laden
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
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 { InformationCircleIcon } from "@heroicons/vue/24/outline";
import { backupSections, type BackupSection } from "../../../../types/backupTypes";
</script>
<script lang="ts">
export default defineComponent({
props: {
data: { type: String, default: "" },
},
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
partial: true,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useBackupStore, ["restoreBackup"]),
triggerCreateBackup(e: any) {
let formData = e.target.elements;
let restoreBackup: BackupRestoreViewModel = {
filename: this.data,
partial: false,
include: [],
overwrite: false,
// partial: !formData.partial.checked,
// include: Array.from(formData?.sections?.selectedOptions ?? []).map(
// (t) => (t as HTMLOptionElement).value
// ) as Array<BackupSection>,
// overwrite: !formData?.overwrite.checked,
};
this.status = "loading";
this.restoreBackup(restoreBackup)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,97 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Backup hochladen</p>
</div>
<br />
<div
class="hidden md:flex flex-col gap-2 py-7 bg-gray-200 justify-center items-center w-full grow rounded-lg"
@drop.prevent="fileDrop"
@dragover.prevent
>
<p class="text-lg text-dark-gray">Datei hierher ziehen</p>
</div>
<p class="hidden md:block text-center">oder</p>
<div class="flex flex-row gap-2 items-center">
<input
class="!hidden"
type="file"
ref="fileSelect"
accept="application/JSON"
@change="
(e) => {
uploadFile((e.target as HTMLInputElement)?.files?.[0]);
(e.target as HTMLInputElement).value = '';
}
"
multiple
/>
<button primary @click="openFileSelect">Datei auswählen</button>
</div>
<div class="flex flex-row gap-2 pt-4 items-center justify-center">
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
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";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useBackupStore, ["uploadBackup"]),
openFileSelect(event: Event) {
(this.$refs.fileSelect as HTMLInputElement).click();
},
fileDrop(event: DragEvent) {
const file = event.dataTransfer?.files[0];
if (file?.type.toLocaleLowerCase() != "application/json") return;
this.uploadFile(file);
},
uploadFile(file?: File) {
if (!file) return;
this.status = "loading";
this.uploadBackup(file)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,72 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Rolle erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreateRole">
<div>
<label for="role">Rollenbezeichnung</label>
<input type="text" id="role" required />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useRoleStore } from "@/stores/admin/management/role";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useRoleStore, ["createRole"]),
triggerCreateRole(e: any) {
let formData = e.target.elements;
this.status = "loading";
this.createRole(formData.role.value)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,75 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Rolle {{ role?.role }} löschen?</p>
</div>
<br />
<div class="flex flex-row gap-2">
<button primary :disabled="status == 'loading' || status?.status == 'success'" @click="triggerDeleteRole">
unwiederuflich löschen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useRoleStore } from "@/stores/admin/management/role";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useRoleStore, ["roles"]),
role() {
return this.roles.find((r) => r.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useRoleStore, ["deleteRole"]),
triggerDeleteRole() {
this.status = "loading";
this.deleteRole(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,54 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ role.role }} <small v-if="role.permissions.admin">(Admin)</small></p>
<div class="flex flex-row">
<RouterLink
v-if="can('admin', 'management', 'role')"
:to="{ name: 'admin-management-role-permission', params: { id: role.id } }"
>
<WrenchScrewdriverIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<RouterLink
v-if="can('update', 'management', 'role')"
:to="{ name: 'admin-management-role-edit', params: { id: role.id } }"
>
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<div v-if="can('delete', 'management', 'role')" @click="openDeleteModal">
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineAsyncComponent, markRaw, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { PencilIcon, WrenchScrewdriverIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { RoleViewModel } from "@/viewmodels/admin/management/role.models";
import { RouterLink } from "vue-router";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
</script>
<script lang="ts">
export default defineComponent({
props: {
role: { type: Object as PropType<RoleViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),
openDeleteModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/management/role/DeleteRoleModal.vue"))),
this.role.id
);
},
},
});
</script>

View file

@ -0,0 +1,75 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Nutzer {{ user?.username }} löschen?</p>
</div>
<br />
<div class="flex flex-row gap-2">
<button primary :disabled="status == 'loading' || status?.status == 'success'" @click="triggerDeleteUser">
unwiederuflich löschen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useUserStore } from "@/stores/admin/management/user";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useUserStore, ["users"]),
user() {
return this.users.find((u) => u.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useUserStore, ["deleteUser"]),
triggerDeleteUser() {
this.status = "loading";
this.deleteUser(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,48 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ invite.firstname }} {{ invite.lastname }}</p>
<div class="flex flex-row">
<div v-if="can('delete', 'management', 'user')" @click="triggerDeleteInvite">
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
</div>
</div>
<div class="flex flex-col p-2">
<div class="flex flex-row gap-2">
<p class="min-w-16">Benutzer:</p>
<p class="grow overflow-hidden">{{ invite.username }}</p>
</div>
<div class="flex flex-row gap-2">
<p class="min-w-16">Mail:</p>
<p class="grow overflow-hidden">{{ invite.mail }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import type { InviteViewModel } from "@/viewmodels/admin/management/invite.models";
import { PencilIcon, UserGroupIcon, WrenchScrewdriverIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import { useInviteStore } from "@/stores/admin/management/invite";
</script>
<script lang="ts">
export default defineComponent({
props: {
invite: { type: Object as PropType<InviteViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useInviteStore, ["deleteInvite"]),
triggerDeleteInvite() {
this.deleteInvite(this.invite.mail);
},
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Nutzer einladen?</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="invite">
<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 primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">
Nutzer einladen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useInviteStore } from "@/stores/admin/management/invite";
import type { CreateInviteViewModel } from "@/viewmodels/admin/management/invite.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
computed: {
...mapState(useModalStore, ["data"]),
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useInviteStore, ["createInvite"]),
invite(e: any) {
let formData = e.target.elements;
let createInvite: CreateInviteViewModel = {
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
};
this.status = "loading";
this.createInvite(createInvite)
.then((result) => {
this.status = { status: "success" };
setTimeout(() => {
this.closeModal();
}, 2000);
})
.catch((err) => {
this.status = { status: "failed", reason: err.response.data };
});
},
},
});
</script>

View file

@ -0,0 +1,85 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>
{{ user.firstname }} {{ user.lastname }} <small v-if="user.permissions_total.admin">(Admin)</small
><small v-if="user.isOwner"> (Owner)</small>
</p>
<div class="flex flex-row">
<RouterLink
v-if="can('admin', 'management', 'user')"
:to="{ name: 'admin-management-user-roles', params: { id: user.id } }"
>
<UserGroupIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<RouterLink
v-if="can('admin', 'management', 'user')"
:to="{ name: 'admin-management-user-permission', params: { id: user.id } }"
>
<WrenchScrewdriverIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<RouterLink
v-if="can('update', 'management', 'user')"
:to="{ name: 'admin-management-user-edit', params: { id: user.id } }"
>
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<div
v-if="can('delete', 'management', 'user')"
:class="user.isOwner ? 'opacity-75 pointer-events-none' : ''"
@click="openDeleteModal"
>
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
</div>
</div>
<div class="flex flex-col p-2">
<div class="flex flex-row gap-2">
<p class="min-w-16">Benutzer:</p>
<p class="grow overflow-hidden">{{ user.username }}</p>
</div>
<div class="flex flex-row gap-2">
<p class="min-w-16">Mail:</p>
<p class="grow overflow-hidden">{{ user.mail }}</p>
</div>
<div class="flex flex-row gap-2">
<p class="min-w-16">Rollen:</p>
<div class="flex flex-row gap-2 flex-wrap grow">
<p v-for="role in user.roles" :key="role.id" class="px-1 border border-gray-300 rounded-md">
{{ role.role }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineAsyncComponent, markRaw, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import type { UserViewModel } from "@/viewmodels/admin/management/user.models";
import { PencilIcon, UserGroupIcon, WrenchScrewdriverIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
</script>
<script lang="ts">
export default defineComponent({
props: {
user: { type: Object as PropType<UserViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),
openDeleteModal() {
if (this.user.isOwner) return;
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/management/user/DeleteUserModal.vue"))),
this.user.id
);
},
},
});
</script>

15
src/config.ts Normal file
View file

@ -0,0 +1,15 @@
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,13 @@
import type { AxiosInstance } from "axios";
import type { RouteLocationNormalizedLoaded, Router } from "vue-router";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
$dev: boolean;
$http: AxiosInstance;
$router: Router;
$route: RouteLocationNormalizedLoaded;
}
}
export {}; // Important! See note.

View file

@ -0,0 +1,9 @@
import { getActivePinia, type Pinia, type Store } from "pinia";
interface ExtendedPinia extends Pinia {
_s: Map<string, Store>;
}
export const resetAllPiniaStores = () => {
(getActivePinia() as ExtendedPinia)?._s?.forEach((store: Store) => store.$reset());
};

View file

@ -0,0 +1,8 @@
export const toolbarOptions = [
[{ header: [1, 2, 3, 4, false] }, { font: [] }],
//[{ header: 1 }, { header: 2 }],
["bold", "italic", "underline", "strike"],
["blockquote", "code-block", "link"],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
["clean"],
];

View file

@ -0,0 +1,7 @@
<template>
<div class="w-full h-full flex flex-row gap-4">
<div class="max-w-full flex grow gap-4 flex-col">
<slot name="main"></slot>
</div>
</div>
</template>

41
src/layouts/Sidebar.vue Normal file
View file

@ -0,0 +1,41 @@
<template>
<div class="w-full h-full flex flex-row gap-4">
<div
v-if="showSidebar"
class="flex-col gap-4 md:min-w-72 lg:min-w-96"
:class="defaultRoute && defaultSidebar ? 'flex w-full md:w-72 lg:w-96' : 'hidden md:flex'"
>
<slot name="sidebar"></slot>
</div>
<div class="max-w-full grow flex-col gap-2" :class="defaultRoute && defaultSidebar ? 'hidden md:flex' : 'flex'">
<slot name="main"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useNavigationStore } from "@/stores/admin/navigation";
</script>
<script lang="ts">
export default defineComponent({
props: {
defaultSidebar: {
type: Boolean,
default: true,
},
showSidebar: {
type: Boolean,
default: true,
},
},
computed: {
...mapState(useNavigationStore, ["activeLink", "activeTopLevelObject"]),
defaultRoute() {
return ((this.$route?.name as string) ?? "").includes("-default");
},
},
});
</script>

151
src/main.css Normal file
View file

@ -0,0 +1,151 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary: #990b00;
--secondary: #0c6672;
--accent: #bb1e10;
--error: #9a0d55;
--warning: #bb6210;
--info: #388994;
--success: #73ad0f;
}
.dark {
--primary: #ff0d00;
--secondary: #0f9aa9;
--accent: #bb1e10;
--error: #9a0d55;
--warning: #bb6210;
--info: #4ccbda;
--success: #73ad0f;
}
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #c9c9c9 transparent;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent; /*f1f1f1;*/
}
*::-webkit-scrollbar-thumb {
background-color: #c9c9c9;
border-radius: 12px;
border: 0px solid #ffffff;
}
html,
body {
@apply h-full w-screen m-0 p-0 overflow-hidden bg-gray-100;
height: 100svh;
}
#app {
@apply w-full h-full overflow-hidden flex flex-col;
}
/*:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"])*/
button:not([class*="ql"] *):not([class*="fc"]):not([id*="headlessui-combobox"]),
a[button] {
@apply relative box-border h-10 w-full flex justify-center py-2 px-4 text-sm font-medium rounded-md focus:outline-none focus:ring-0;
}
button[primary]:not([primary="false"]),
a[button][primary]:not([primary="false"]) {
@apply border border-transparent text-white bg-primary hover:bg-primary;
}
button[primary-outline]:not([primary-outline="false"]),
a[button][primary-outline]:not([primary-outline="false"]) {
@apply border-2 border-primary text-black hover:bg-primary;
}
button:disabled,
a[button]:disabled,
a[button].disabled {
@apply opacity-75 pointer-events-none;
}
input:not([type="checkbox"]),
textarea,
select {
@apply rounded-md shadow-sm 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-none focus:ring-0 focus:z-10 sm:text-sm resize-none;
}
input[readonly],
textarea[readonly],
select[readonly] {
@apply select-none;
/* pointer-events-none; */
}
input[disabled],
textarea[disabled],
select[disabled] {
@apply opacity-75 pointer-events-none;
}
details {
user-select: none;
& summary svg[indicator] {
transform: rotate(90deg);
}
}
details[open] {
& summary svg[indicator] {
transform: rotate(-90deg);
}
}
details[open] summary ~ * {
animation: ease-opacity-t-b 0.5s ease;
}
summary {
cursor: pointer;
}
summary > svg {
transition: all 0.3s;
}
summary::-webkit-details-marker {
display: none;
}
.fc-button-primary {
@apply !bg-primary !border-primary !outline-none !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;
}
}

21
src/main.ts Normal file
View file

@ -0,0 +1,21 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import NProgress from "nprogress";
import "../node_modules/nprogress/nprogress.css";
import { http } from "./serverCom";
import "./main.css";
NProgress.configure({ showSpinner: false });
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.config.globalProperties.$http = http;
app.config.globalProperties.$progress = NProgress;
app.mount("#app");

View file

@ -0,0 +1,6 @@
import { useAccountStore } from "@/stores/account";
export async function loadAccountData(to: any, from: any, next: any) {
const account = useAccountStore();
next();
}

37
src/router/adminGuard.ts Normal file
View file

@ -0,0 +1,37 @@
import NProgress from "nprogress";
import { useAbilityStore } from "@/stores/ability";
import { useNavigationStore } from "@/stores/admin/navigation";
export async function abilityAndNavUpdate(to: any, from: any, next: any) {
NProgress.start();
const ability = useAbilityStore();
const navigation = useNavigationStore();
let admin = to.meta.admin || false;
let type = to.meta.type;
let section = to.meta.section;
let module = to.meta.module;
if ((admin && ability.isAdmin()) || ability.can(type, section, module)) {
NProgress.done();
navigation.activeNavigation = to.name.split("-")[1];
navigation.activeLink = to.name.split("-")[2];
next();
} else {
NProgress.done();
next({ name: "admin-default" });
}
}
export async function isOwner(to: any, from: any, next: any) {
NProgress.start();
const ability = useAbilityStore();
if (ability.isOwner) {
NProgress.done();
next();
} else {
NProgress.done();
next(false);
}
}

83
src/router/authGuard.ts Normal file
View file

@ -0,0 +1,83 @@
import NProgress from "nprogress";
import { useAuthStore } from "@/stores/auth";
import { useAccountStore } from "@/stores/account";
import { jwtDecode, type JwtPayload } from "jwt-decode";
import { refreshToken } from "@/serverCom";
import type { PermissionObject } from "@/types/permissionTypes";
import { useAbilityStore } from "@/stores/ability";
export type Payload = JwtPayload & {
userId: string;
username: string;
firstname: string;
lastname: string;
mail: string;
isOwner: boolean;
permissions: PermissionObject;
};
export async function isAuthenticated(to: any, from: any, next: any) {
const auth = useAuthStore();
NProgress.start();
if (auth.authCheck && localStorage.getItem("access_token") && localStorage.getItem("refresh_token")) {
NProgress.done();
next();
return;
}
await isAuthenticatedPromise()
.then(async (result: Payload) => {
NProgress.done();
next();
})
.catch((err: string) => {
NProgress.done();
next({ name: err ?? "login" });
});
}
export async function isAuthenticatedPromise(forceRefresh: boolean = false): Promise<Payload> {
return new Promise<Payload>(async (resolve, reject) => {
const auth = useAuthStore();
const account = useAccountStore();
const ability = useAbilityStore();
let decoded: Payload | string = "";
try {
decoded = jwtDecode<Payload>(localStorage.getItem("accessToken") ?? "");
} catch (error) {
auth.setFailed();
reject("login");
}
if (typeof decoded == "string" || !decoded) {
auth.setFailed();
reject("login");
} else {
// check jwt expiry
const exp = decoded.exp ?? 0;
const correctedLocalTime = new Date().getTime();
if (exp < Math.floor(correctedLocalTime / 1000) || forceRefresh) {
await refreshToken()
.then(() => {
console.log("fetched new token");
})
.catch((err: string) => {
console.log("expired");
auth.setFailed();
reject(err);
});
}
var { userId, firstname, lastname, mail, username, permissions, isOwner } = decoded;
if (Object.keys(permissions ?? {}).length === 0 && !isOwner) {
auth.setFailed();
reject("nopermissions");
}
auth.setSuccess();
account.setAccountData(userId, firstname, lastname, mail, username);
ability.setAbility(permissions, isOwner);
resolve(decoded);
}
});
}

19
src/router/backupGuard.ts Normal file
View file

@ -0,0 +1,19 @@
import { useBackupStore } from "../stores/admin/management/backup";
export async function setBackupPage(to: any, from: any, next: any) {
const backup = useBackupStore();
let uploadPage = to.name.includes("uploaded");
if (uploadPage) {
backup.page = "uploaded";
backup.backups = [];
} else {
backup.page = "generated";
backup.backups = [];
}
backup.fetchBackups();
next();
}

321
src/router/index.ts Normal file
View file

@ -0,0 +1,321 @@
import { createRouter, createWebHistory } from "vue-router";
import Login from "@/views/Login.vue";
import { isAuthenticated } from "./authGuard";
import { isSetup } from "./setupGuard";
import { abilityAndNavUpdate } from "./adminGuard";
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
import { config } from "../config";
import { setBackupPage } from "./backupGuard";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
redirect: { name: "admin" },
},
{
path: "/login",
name: "login",
component: Login,
},
{
path: "/setup",
name: "setup",
component: () => import("@/views/RouterView.vue"),
beforeEnter: [isSetup],
children: [
{
path: "",
name: "setup-create",
component: () => import("@/views/setup/Setup.vue"),
},
{
path: "verify",
name: "setup-verify",
component: () => import("@/views/setup/Verify.vue"),
props: (route) => ({ mail: route.query.mail, token: route.query.token }),
},
],
},
{
path: "/reset",
name: "reset",
component: () => import("@/views/RouterView.vue"),
children: [
{
path: "",
name: "reset-start",
component: () => import("@/views/reset/Start.vue"),
},
{
path: "reset",
name: "reset-reset",
component: () => import("@/views/reset/Reset.vue"),
props: (route) => ({ mail: route.query.mail, token: route.query.token }),
},
],
},
{
path: "/invite",
name: "invite",
component: () => import("@/views/RouterView.vue"),
children: [
{
path: "verify",
name: "invite-verify",
component: () => import("@/views/invite/Verify.vue"),
props: (route) => ({ mail: route.query.mail, token: route.query.token }),
},
],
},
{
path: "/admin",
name: "admin",
component: () => import("@/views/admin/View.vue"),
beforeEnter: [isAuthenticated],
children: [
{
path: "",
name: "admin-default",
component: () => import("@/views/admin/ViewSelect.vue"),
},
{
path: "operation",
name: "admin-operation",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "operation" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-operation-default",
redirect: { name: "admin-operation-mission" },
},
{
path: "mission",
name: "admin-operation-mission",
component: () => import("@/views/admin/ViewSelect.vue"),
meta: { type: "read", section: "operation", module: "mission" },
beforeEnter: [abilityAndNavUpdate],
},
],
},
{
path: "configuration",
name: "admin-configuration",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "configuration" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-configuration-default",
component: () => import("@/views/admin/ViewSelect.vue"),
meta: { type: "read", section: "configuration" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: "force",
name: "admin-configuration-force",
component: () => import("@/views/admin/ViewSelect.vue"),
meta: { type: "read", section: "configuration", module: "force" },
beforeEnter: [abilityAndNavUpdate],
},
],
},
{
path: "management",
name: "admin-management",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "management" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-management-default",
component: () => import("@/views/admin/ViewSelect.vue"),
meta: { type: "read", section: "management" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: "user",
name: "admin-management-user-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "management", module: "user" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-management-user",
component: () => import("@/views/admin/management/user/User.vue"),
},
{
path: "invites",
name: "admin-management-user-invites",
component: () => import("@/views/admin/management/user/Invite.vue"),
},
{
path: ":id/edit",
name: "admin-management-user-edit",
component: () => import("@/views/admin/management/user/UserEdit.vue"),
meta: { type: "update", section: "management", module: "user" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
{
path: ":id/permission",
name: "admin-management-user-permission",
component: () => import("@/views/admin/management/user/UserEditPermission.vue"),
meta: { type: "update", section: "management", module: "user" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
{
path: ":id/roles",
name: "admin-management-user-roles",
component: () => import("@/views/admin/management/user/UserEditRoles.vue"),
meta: { type: "update", section: "management", module: "user" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
],
},
{
path: "role",
name: "admin-management-role-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "management", module: "role" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-management-role",
component: () => import("@/views/admin/management/role/Role.vue"),
},
{
path: ":id/edit",
name: "admin-management-role-edit",
component: () => import("@/views/admin/management/role/RoleEdit.vue"),
meta: { type: "update", section: "management", module: "role" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
{
path: ":id/permission",
name: "admin-management-role-permission",
component: () => import("@/views/admin/management/role/RoleEditPermission.vue"),
meta: { type: "update", section: "management", module: "role" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
],
},
{
path: "backup",
name: "admin-management-backup-route",
component: () => import("@/views/admin/management/backup/BackupRouting.vue"),
meta: { type: "read", section: "management", module: "backup" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-management-backup",
redirect: { name: "admin-management-backup-generated" },
},
{
path: "generated",
name: "admin-management-backup-generated",
component: () => import("@/views/admin/management/backup/GeneratedBackup.vue"),
beforeEnter: [setBackupPage],
},
{
path: "uploads",
name: "admin-management-backup-uploaded",
component: () => import("@/views/admin/management/backup/UploadedBackup.vue"),
beforeEnter: [setBackupPage],
},
],
},
{
path: "version",
name: "admin-management-version",
component: () => import("@/views/admin/management/version/VersionDisplay.vue"),
meta: { admin: true },
beforeEnter: [abilityAndNavUpdate],
},
],
},
{
path: ":pathMatch(.*)*",
name: "admin-404",
component: () => import("@/views/notFound.vue"),
},
],
},
{
path: "/account",
name: "account",
component: () => import("@/views/account/View.vue"),
beforeEnter: [isAuthenticated],
children: [
{
path: "",
name: "account-default",
component: () => import("@/views/account/ViewSelect.vue"),
},
{
path: "me",
name: "account-me",
component: () => import("@/views/account/Me.vue"),
},
{
path: "logindata",
name: "account-logindata",
component: () => import("@/views/account/LoginData.vue"),
},
{
path: "permission",
name: "account-permission",
component: () => import("@/views/account/Permission.vue"),
},
{
path: "administration",
name: "account-administration",
component: () => import("@/views/account/Administration.vue"),
},
{
path: ":pathMatch(.*)*",
name: "account-404",
component: () => import("@/views/notFound.vue"),
},
],
},
{
path: "/nopermissions",
name: "nopermissions",
component: () => import("@/views/NoPermission.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: () => import("@/views/notFound.vue"),
},
],
});
router.afterEach((to, from) => {
document.title = config.app_name_overwrite || "FF Operation";
});
export default router;
declare module "vue-router" {
interface RouteMeta {
admin?: boolean;
type?: PermissionType | "admin";
section?: PermissionSection;
module?: PermissionModule;
}
}

16
src/router/setupGuard.ts Normal file
View file

@ -0,0 +1,16 @@
import NProgress from "nprogress";
import { http } from "@/serverCom";
export async function isSetup(to: any, from: any, next: any) {
NProgress.start();
await http
.get("/setup")
.then(() => {
NProgress.done();
next();
})
.catch(() => {
NProgress.done();
next({ name: "login" });
});
}

105
src/serverCom.ts Normal file
View file

@ -0,0 +1,105 @@
import axios from "axios";
import { isAuthenticatedPromise, type Payload } from "./router/authGuard";
import router from "./router";
import { useNotificationStore } from "./stores/notification";
import { config } from "./config";
let devMode = process.env.NODE_ENV === "development";
let host = devMode ? "localhost:5000" : (config.server_address ?? "").replace(/(^\w+:|^)\/\//, "");
let url = devMode ? "http://" + host : config.server_address;
const http = axios.create({
baseURL: url + "/api",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
Expires: "0",
},
});
http.interceptors.request.use(
(config) => {
const token = localStorage.getItem("accessToken");
if (token) {
if (config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
const isPWA =
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches;
if (isPWA) {
if (config.headers) {
config.headers["X-PWA-Client"] = isPWA ? "true" : "false";
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
http.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
if (!error.config.url.includes("/admin") && !error.config.url.includes("/user")) {
return Promise.reject(error);
}
const originalRequest = error.config;
// Handle token expiration and retry the request with a refreshed token
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
return await refreshToken()
.then(() => {
return http(originalRequest);
})
.catch(() => {});
}
const notificationStore = useNotificationStore();
if (error.toString().includes("Network Error")) {
notificationStore.push("Netzwerkfehler", "Server nicht erreichbar!", "error");
} else {
notificationStore.push("Fehler", error.response.data, "error");
}
return Promise.reject(error);
}
);
export async function refreshToken(): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
await http
.post(`/auth/refresh`, {
accessToken: localStorage.getItem("accessToken"),
refreshToken: localStorage.getItem("refreshToken"),
})
.then(async (response) => {
const { accessToken, refreshToken } = response.data;
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
await isAuthenticatedPromise().catch((err: string) => {
router.push({ name: err ?? "login" });
reject(err);
});
resolve();
})
.catch((error) => {
console.error("Error refreshing token:", error);
reject("login");
});
});
}
export { http, host };

5
src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

81
src/stores/ability.ts Normal file
View file

@ -0,0 +1,81 @@
import { defineStore } from "pinia";
import type { PermissionModule, PermissionObject, PermissionSection, PermissionType } from "@/types/permissionTypes";
export const useAbilityStore = defineStore("ability", {
state: () => {
return {
permissions: {} as PermissionObject,
isOwner: false as boolean,
};
},
getters: {
can:
(state) =>
(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 (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type)
)
return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return true;
return false;
},
canSection:
(state) =>
(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 (
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) ||
permissions[section] != undefined
)
return true;
return false;
},
isAdmin: (state) => (): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
return permissions?.admin ?? false;
},
_can:
() =>
(
permissions: PermissionObject,
type: PermissionType | "admin",
section: PermissionSection,
module?: PermissionModule
): boolean => {
// ignores ownership
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type)
)
return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return true;
return false;
},
},
actions: {
setAbility(permissions: PermissionObject, isOwner: boolean) {
this.permissions = permissions;
this.isOwner = isOwner;
},
},
});

29
src/stores/account.ts Normal file
View file

@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import type { PermissionObject } from "@/types/permissionTypes";
import { useAbilityStore } from "./ability";
export const useAccountStore = defineStore("account", {
state: () => {
return {
id: "" as string,
firstname: "" as string,
lastname: "" as string,
mail: "" as string,
alias: "" as string,
};
},
actions: {
logoutAccount() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.open("/login", "_self");
},
setAccountData(id: string, firstname: string, lastname: string, mail: string, alias: string) {
this.id = id;
this.firstname = firstname;
this.lastname = lastname;
this.mail = mail;
this.alias = alias;
},
},
});

View file

@ -0,0 +1,89 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type {
ForceViewModel,
CreateForceViewModel,
UpdateForceViewModel,
} from "../../../viewmodels/admin/configuration/force.models";
export const useForceStore = defineStore("force", {
state: () => {
return {
forces: [] as Array<ForceViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchForces(offset = 0, count = 25, search = "", clear = false) {
if (clear) this.forces = [];
this.loading = "loading";
http
.get(`/admin/force?offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`)
.then((result) => {
this.totalCount = result.data.total;
result.data.forces
.filter((elem: ForceViewModel) => this.forces.findIndex((m) => m.id == elem.id) == -1)
.map((elem: ForceViewModel, index: number): ForceViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: ForceViewModel & { tab_pos: number }) => {
this.forces.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
async getAllForces(): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/force?noLimit=true`).then((res) => {
return { ...res, data: res.data.forces };
});
},
async getForcesByIds(ids: Array<string>): Promise<AxiosResponse<any, any>> {
return await http
.post(`/admin/force/ids`, {
ids,
})
.then((res) => {
return { ...res, data: res.data.forces };
});
},
async searchForces(search: string): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/force?search=${search}&noLimit=true`).then((res) => {
return { ...res, data: res.data.forces };
});
},
fetchForceById(id: string) {
return http.get(`/admin/force/${id}`);
},
async createForce(force: CreateForceViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/force`, {
firstname: force.firstname,
lastname: force.lastname,
nameaffix: force.nameaffix,
});
this.fetchForces();
return result;
},
async updateActiveForce(force: UpdateForceViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/force/${force.id}`, {
firstname: force.firstname,
lastname: force.lastname,
nameaffix: force.nameaffix,
});
this.fetchForces();
return result;
},
async deleteForce(force: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/force/${force}`);
this.fetchForces();
return result;
},
},
});

View file

@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse, AxiosProgressEvent } from "axios";
import type { BackupRestoreViewModel } from "../../../viewmodels/admin/management/backup.models";
export const useBackupStore = defineStore("backup", {
state: () => {
return {
backups: [] as Array<string>,
loading: null as null | "loading" | "success" | "failed",
page: "generated" as "generated" | "uploaded",
};
},
actions: {
fetchBackups() {
this.loading = "loading";
http
.get(`/admin/backup/${this.page}`)
.then((result) => {
this.backups = result.data;
this.loading = "success";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchBackupById(filename: string): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/backup/${this.page}/${filename}`);
},
async restoreBackup(backup: BackupRestoreViewModel): Promise<AxiosResponse<any, any>> {
return await http.post(`/admin/backup/${this.page}/restore`, backup);
},
async triggerBackupCreate(): Promise<AxiosResponse<any, any>> {
const result = await http.post("/admin/backup");
this.fetchBackups();
return result;
},
async uploadBackup(file: File): Promise<AxiosResponse<any, any>> {
const formData = new FormData();
formData.append("file", file);
const options = {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
const { loaded, total = 1 } = progressEvent;
console.log("progress", Math.floor((loaded * 100) / total));
},
};
const result = await http.post("/admin/backup/upload", formData, options);
this.fetchBackups();
return result;
},
},
});

View file

@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import type { CreateInviteViewModel, InviteViewModel } from "@/viewmodels/admin/management/invite.models";
import { http } from "@/serverCom";
import type { PermissionObject } from "@/types/permissionTypes";
import type { AxiosResponse } from "axios";
export const useInviteStore = defineStore("invite", {
state: () => {
return {
invites: [] as Array<InviteViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchInvites() {
this.loading = "loading";
http
.get("/admin/invite")
.then((result) => {
this.invites = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
createInvite(createInvite: CreateInviteViewModel): Promise<AxiosResponse<any, any>> {
return http.post(`/admin/invite`, {
username: createInvite.username,
mail: createInvite.mail,
firstname: createInvite.firstname,
lastname: createInvite.lastname,
});
},
async deleteInvite(mail: string): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/invite/${mail}`);
this.fetchInvites();
return result;
},
},
});

View file

@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import type { RoleViewModel } from "@/viewmodels/admin/management/role.models";
import { http } from "@/serverCom";
import type { PermissionObject } from "@/types/permissionTypes";
import type { AxiosResponse } from "axios";
export const useRoleStore = defineStore("role", {
state: () => {
return {
roles: [] as Array<RoleViewModel>,
loading: null as null | "loading" | "success" | "failed",
};
},
actions: {
fetchRoles() {
this.loading = "loading";
http
.get("/admin/role")
.then((result) => {
this.roles = result.data;
this.loading = "success";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchRoleById(id: number): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/role/${id}`);
},
async createRole(role: string): Promise<AxiosResponse<any, any>> {
const result = await http.post("/admin/role", {
role: role,
});
this.fetchRoles();
return result;
},
async updateActiveRole(id: number, role: string): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/role/${id}`, {
role: role,
});
this.fetchRoles();
return result;
},
async updateActiveRolePermissions(role: number, permission: PermissionObject): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/role/${role}/permissions`, {
permissions: permission,
});
this.fetchRoles();
return result;
},
async deleteRole(role: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/role/${role}`);
this.fetchRoles();
return result;
},
},
});

View file

@ -0,0 +1,60 @@
import { defineStore } from "pinia";
import type { UpdateUserViewModel, UserViewModel } from "@/viewmodels/admin/management/user.models";
import { http } from "@/serverCom";
import type { PermissionObject } from "@/types/permissionTypes";
import type { AxiosResponse } from "axios";
export const useUserStore = defineStore("user", {
state: () => {
return {
users: [] as Array<UserViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchUsers() {
this.loading = "loading";
http
.get("/admin/user")
.then((result) => {
this.users = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchUserById(id: string): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/user/${id}`);
},
async updateActiveUser(user: UpdateUserViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/user/${user.id}`, {
username: user.username,
firstname: user.firstname,
lastname: user.lastname,
mail: user.mail,
});
this.fetchUsers();
return result;
},
async updateActiveUserPermissions(userId: string, permission: PermissionObject): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/user/${userId}/permissions`, {
permissions: permission,
});
this.fetchUsers();
return result;
},
async updateActiveUserRoles(userId: string, roles: Array<number>): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/user/${userId}/roles`, {
roleIds: roles,
});
this.fetchUsers();
return result;
},
async deleteUser(userId: string): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/user/${userId}`);
this.fetchUsers();
return result;
},
},
});

View file

@ -0,0 +1,118 @@
import { defineStore } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import router from "@/router";
import type { PermissionSection } from "../../types/permissionTypes";
export type navigationModel = {
[key in topLevelNavigationType]: navigationSplitModel;
};
export interface navigationSplitModel {
topTitle?: string;
top?: Array<navigationLinkModel>;
mainTitle: string;
main: Array<navigationLinkModel>;
}
export type topLevelNavigationType = PermissionSection;
export interface topLevelNavigationModel {
key: topLevelNavigationType;
title: string;
levelDefault: string;
showSidebar?: boolean;
}
export interface navigationLinkModel {
key: string;
title: string;
}
export const useNavigationStore = defineStore("navigation", {
state: () => {
return {
activeNavigation: "operation" as topLevelNavigationType,
activeLink: null as null | string,
topLevel: [] as Array<topLevelNavigationModel>,
navigation: {} as navigationModel,
};
},
getters: {
activeNavigationObject: (state) => (state.navigation[state.activeNavigation] ?? {}) as navigationSplitModel,
activeTopLevelObject: (state) =>
(state.topLevel.find((elem) => elem.key == state.activeNavigation) ?? {}) as topLevelNavigationModel,
},
actions: {
resetNavigation() {
this.$reset();
},
updateTopLevel() {
const abilityStore = useAbilityStore();
this.topLevel = [
...(abilityStore.canSection("read", "operation")
? [
{
key: "operation",
title: "Einsätze",
levelDefault: "mission",
showSidebar: false,
} as topLevelNavigationModel,
]
: []),
...(abilityStore.canSection("read", "configuration")
? [
{
key: "configuration",
title: "Konfiguration",
levelDefault: "force",
showSidebar: true,
} as topLevelNavigationModel,
]
: []),
...(abilityStore.canSection("read", "management")
? [
{
key: "management",
title: "Verwaltung",
levelDefault: "user",
showSidebar: true,
} as topLevelNavigationModel,
]
: []),
];
if (this.topLevel.findIndex((e) => e.key == this.activeNavigation) == -1) {
this.activeNavigation = this.topLevel[0]?.key ?? "operation";
router.push({ name: `admin-${this.topLevel[0]?.key ?? "operation"}-default` });
}
},
updateNavigation() {
const abilityStore = useAbilityStore();
this.navigation = {
operation: {
mainTitle: "Einsätze",
main: [...(abilityStore.can("read", "operation", "mission") ? [{ key: "mission", title: "Einsätze" }] : [])],
},
configuration: {
mainTitle: "Konfiguration",
main: [...(abilityStore.can("read", "configuration", "force") ? [{ key: "force", title: "Kräfte" }] : [])],
},
management: {
mainTitle: "Verwaltung",
main: [
...(abilityStore.can("read", "management", "user") ? [{ key: "user", title: "Benutzer" }] : []),
...(abilityStore.can("read", "management", "role") ? [{ key: "role", title: "Rollen" }] : []),
...(abilityStore.can("read", "management", "backup") ? [{ key: "backup", title: "Backups" }] : []),
...(abilityStore.isAdmin() ? [{ key: "version", title: "Version" }] : []),
],
},
} as navigationModel;
if (
this.activeNavigationObject.main.findIndex((e) => e.key == this.activeLink) == -1 ||
this.activeLink == "default"
) {
let link = this.activeNavigationObject.main[0].key;
router.push({ name: `admin-${this.activeNavigation}-${link}` });
}
},
},
});

17
src/stores/auth.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineStore } from "pinia";
export const useAuthStore = defineStore("auth", {
state: () => {
return {
authCheck: false,
};
},
actions: {
setSuccess() {
this.authCheck = true;
},
setFailed() {
this.authCheck = false;
},
},
});

View file

@ -0,0 +1,32 @@
import { defineStore } from "pinia";
export const useContextMenuStore = defineStore("context-menu", {
state: () => {
return {
contextX: 0,
contextY: 0,
show: false,
component_ref: null as any,
data: null as any,
};
},
getters: {
contextMenuStyle: (state) => {
return `left: ${state.contextX}px; top: ${state.contextY}px`;
},
},
actions: {
openContextMenu(e: MouseEvent, content: { component_ref: any; data: any }) {
this.component_ref = content.component_ref;
this.data = content.data;
this.contextX = e.pageX;
this.contextY = e.pageY;
this.show = true;
},
closeContextMenu() {
this.component_ref = null;
this.data = null;
this.show = false;
},
},
});

23
src/stores/modal.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineStore } from "pinia";
export const useModalStore = defineStore("modal", {
state: () => {
return {
show: false,
component_ref: null as any,
data: null as any,
};
},
actions: {
openModal(component_ref: any, data?: any) {
this.component_ref = component_ref;
this.data = data;
this.show = true;
},
closeModal() {
this.component_ref = null;
this.data = null;
this.show = false;
},
},
});

View file

@ -0,0 +1,48 @@
import { defineStore } from "pinia";
export interface Notification {
id: string;
title: string;
text: string;
type: NotificationType;
indicator: boolean;
}
export type NotificationType = "info" | "warning" | "error";
export const useNotificationStore = defineStore("notification", {
state: () => {
return {
notifications: [] as Array<Notification>,
timeouts: {} as { [key: string]: any },
};
},
actions: {
push(title: string, text: string, type: NotificationType, timeout: number = 5000) {
let id = `${Date.now()}_${Math.random()}`;
this.notifications.push({
id,
title,
text,
type,
indicator: false,
});
if (timeout != 0) {
setTimeout(() => {
this.notifications[this.notifications.findIndex((n) => n.id === id)].indicator = true;
}, 100);
this.timeouts[id] = setTimeout(() => {
this.revoke(id);
}, timeout);
}
},
revoke(id: string) {
this.notifications.splice(
this.notifications.findIndex((n) => n.id === id),
1
);
clearTimeout(this.timeouts[id]);
delete this.timeouts[id];
},
},
});

74
src/templates/Main.vue Normal file
View file

@ -0,0 +1,74 @@
<template>
<div v-if="!defaultRoute && showBack" class="flex md:hidden flex-row items-baseline">
<RouterLink
v-if="!defaultRoute && showBack"
:to="{
name:
overviewFullOverwrite ??
`${rootRoute}${useStagedOverviewLink ? '-' + (overviewOverwrite ?? activeNavigation) : ''}-default`,
}"
class="mid:hidden text-primary"
>
zur Übersicht
</RouterLink>
</div>
<slot v-if="headerInsert" name="headerInsert"></slot>
<div
class="max-w-full w-full grow flex flex-col divide-y-2 divide-gray-300 bg-white rounded-lg justify-center overflow-hidden"
>
<slot name="topBar"></slot>
<div class="flex flex-col gap-2 grow py-5 overflow-hidden">
<slot name="diffMain"></slot>
<div v-if="!diffMain" class="flex flex-col gap-2 grow px-7 overflow-y-scroll">
<slot name="main"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useNavigationStore } from "@/stores/admin/navigation";
</script>
<script lang="ts">
export default defineComponent({
props: {
overviewFullOverwrite: {
type: String,
default: null,
},
overviewOverwrite: {
type: String,
default: null,
},
useStagedOverviewLink: {
type: Boolean,
default: true,
},
showBack: {
type: Boolean,
default: true,
},
},
computed: {
...mapState(useNavigationStore, ["activeLink", "activeNavigation", "activeTopLevelObject"]),
defaultRoute() {
return ((this.$route?.name as string) ?? "").includes("-default");
},
rootRoute() {
return ((this.$route?.name as string) ?? "").split("-")[0];
},
diffMain() {
return this.$slots.diffMain;
},
headerInsert() {
return this.$slots.headerInsert;
},
mid() {
return window.matchMedia("(min-width: 800px)").matches;
},
},
});
</script>

75
src/templates/Sidebar.vue Normal file
View file

@ -0,0 +1,75 @@
<template>
<div v-if="topButtonsPassed" class="flex flex-row gap-2 empty:contents">
<slot name="topButtons"></slot>
</div>
<div
v-if="showTopList"
class="w-full h-fit max-h-1/2 flex flex-col divide-y-2 divide-gray-300 bg-white rounded-lg justify-center overflow-hidden"
>
<div v-if="topSearchPassed" class="flex flex-row gap-1 justify-end items-center pt-5 pb-3 px-7">
<slot name="searchTop"></slot>
</div>
<div class="flex flex-col gap-2 grow overflow-hidden" :class="topTitlePassed ? 'pb-5 pt-2' : ' py-5'">
<p v-if="topTitlePassed" class="px-2">{{ topTitle }}</p>
<div class="flex flex-col gap-2 h-full px-7 overflow-y-auto">
<slot name="topList"></slot>
</div>
</div>
</div>
<div class="w-full grow flex flex-col divide-y-2 divide-gray-300 bg-white rounded-lg justify-center overflow-hidden">
<div v-if="searchPassed" class="flex flex-row gap-1 justify-end items-center pt-5 pb-3 px-7">
<slot name="search"></slot>
</div>
<div class="flex flex-col gap-2 grow overflow-hidden" :class="titlePassed ? 'pb-5 pt-2' : ' py-5'">
<p v-if="titlePassed" class="px-2">{{ mainTitle }}</p>
<div class="flex flex-col gap-2 h-full px-7 overflow-y-auto">
<slot name="list"></slot>
</div>
</div>
</div>
<div v-if="bottomButtonsPassed" class="flex flex-col gap-2 empty:contents">
<slot name="bottomButtons"></slot>
</div>
</template>
<script lang="ts">
export default {
props: {
topTitle: {
type: String,
default: null,
},
mainTitle: {
type: String,
default: null,
},
showTopList: {
type: Boolean,
default: false,
},
},
computed: {
defaultRoute() {
return ((this.$route?.name as string) ?? "").includes("-default");
},
topButtonsPassed() {
return !!this.$slots.topButtons;
},
topTitlePassed() {
return !!this.topTitle;
},
topSearchPassed() {
return !!this.$slots.searchTop;
},
titlePassed() {
return !!this.mainTitle;
},
searchPassed() {
return !!this.$slots.search;
},
bottomButtonsPassed() {
return !!this.$slots.bottomButtons;
},
},
};
</script>

3
src/types/backupTypes.ts Normal file
View file

@ -0,0 +1,3 @@
export type BackupSection = "base" | "user";
export const backupSections: Array<BackupSection> = ["base", "user"];

View file

@ -0,0 +1,33 @@
export type PermissionSection = "operation" | "configuration" | "management";
export type PermissionModule = "mission" | "force" | "user" | "role" | "backup";
export type PermissionType = "read" | "create" | "update" | "delete";
export type PermissionString =
| `${PermissionSection}.${PermissionModule}.${PermissionType}` // für spezifische Berechtigungen
| `${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
| "*"; // für Admin
export type PermissionObject = {
[section in PermissionSection]?: {
[module in PermissionModule]?: Array<PermissionType> | "*";
} & { all?: Array<PermissionType> | "*" };
} & {
admin?: boolean;
};
export type SectionsAndModulesObject = {
[section in PermissionSection]: Array<PermissionModule>;
};
export const permissionSections: Array<PermissionSection> = ["operation", "configuration", "management"];
export const permissionModules: Array<PermissionModule> = ["mission", "force", "user", "role", "backup"];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
operation: ["mission"],
configuration: ["force"],
management: ["user", "role", "backup"],
};

View file

@ -0,0 +1,19 @@
export interface ForceViewModel {
id: string;
firstname: string;
lastname: string;
nameaffix: string;
}
export interface CreateForceViewModel {
firstname: string;
lastname: string;
nameaffix: string;
}
export interface UpdateForceViewModel {
id: string;
firstname: string;
lastname: string;
nameaffix: string;
}

View file

@ -0,0 +1,8 @@
import type { BackupSection } from "../../../types/backupTypes";
export interface BackupRestoreViewModel {
filename: string;
partial: boolean;
include: Array<BackupSection>;
overwrite: boolean;
}

View file

@ -0,0 +1,13 @@
export interface InviteViewModel {
username: string;
mail: string;
firstname: string;
lastname: string;
}
export interface CreateInviteViewModel {
username: string;
mail: string;
firstname: string;
lastname: string;
}

View file

@ -0,0 +1,7 @@
import type { PermissionObject } from "@/types/permissionTypes";
export interface RoleViewModel {
id: number;
permissions: PermissionObject;
role: string;
}

View file

@ -0,0 +1,29 @@
import type { PermissionObject } from "@/types/permissionTypes";
import type { RoleViewModel } from "./role.models";
export interface UserViewModel {
id: string;
username: string;
mail: string;
firstname: string;
lastname: string;
isOwner: boolean;
permissions: PermissionObject;
roles: Array<RoleViewModel>;
permissions_total: PermissionObject;
}
export interface CreateUserViewModel {
username: string;
mail: string;
firstname: string;
lastname: string;
}
export interface UpdateUserViewModel {
id: string;
username: string;
mail: string;
firstname: string;
lastname: string;
}

View file

@ -0,0 +1,21 @@
export interface Release {
creator: string;
title: string;
link: string;
pubDate: string;
author: string;
"content:encoded": string;
"content:encodedSnippet": string;
content: string;
contentSnippet: string;
guid: string;
isoDate: string;
}
export interface Releases {
items: Release[];
title: string;
description: string;
pubDate: string;
link: string;
}

92
src/views/Login.vue Normal file
View file

@ -0,0 +1,92 @@
<template>
<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" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">
{{ config.app_name_overwrite || "FF Operation" }}
</h2>
</div>
<form class="flex flex-col gap-2" @submit.prevent="login">
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="!rounded-b-none" />
</div>
<div>
<input
id="totp"
name="totp"
type="text"
required
placeholder="TOTP"
class="!rounded-t-none"
autocomplete="off"
/>
</div>
</div>
<RouterLink :to="{ name: 'reset-start' }" class="w-fit self-end text-primary">TOTP verloren</RouterLink>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="loginStatus == 'loading' || loginStatus == 'success'">
anmelden
</button>
<Spinner v-if="loginStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="loginStatus == 'success'" />
<FailureXMark v-else-if="loginStatus == 'failed'" />
</div>
<p v-if="loginError" class="text-center">{{ loginError }}</p>
</form>
<FormBottomBar />
</div>
</div>
</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 { resetAllPiniaStores } from "@/helpers/piniaReset";
import FormBottomBar from "@/components/FormBottomBar.vue";
import { config } from "@/config";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
loginStatus: undefined as undefined | "loading" | "success" | "failed",
loginError: "" as string,
};
},
mounted() {
resetAllPiniaStores();
},
methods: {
login(e: any) {
let formData = e.target.elements;
this.loginStatus = "loading";
this.loginError = "";
this.$http
.post(`/auth/login`, {
username: formData.username.value,
totp: formData.totp.value,
})
.then((result) => {
this.loginStatus = "success";
localStorage.setItem("accessToken", result.data.accessToken);
localStorage.setItem("refreshToken", result.data.refreshToken);
setTimeout(() => {
this.$router.push(`/admin`);
}, 1000);
})
.catch((err) => {
this.loginStatus = "failed";
this.loginError = err.response?.data;
});
},
},
});
</script>

View file

@ -0,0 +1,32 @@
<template>
<div class="flex flex-col items-center">
<br />
<h1 class="w-full p-4 text-center font-bold text-3xl">Kein Zugriff</h1>
<br />
<p class="w-full text-center">
Sie haben keine Berechtigungen. <br />
Um Zugriff auf das Admin-Portal zu erhalten, wenden Sie sich an einen Administrator.
</p>
<br />
<button primary class="!w-fit" @click="refetch">Zum Admin-Portal</button>
</div>
</template>
<script lang="ts" setup>
import { refreshToken } from "@/serverCom";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
methods: {
async refetch() {
await refreshToken()
.then(() => {
this.$router.push({ name: "admin" });
})
.catch(() => {});
},
},
});
</script>

7
src/views/RouterView.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>

View file

@ -0,0 +1,159 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<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">Administration übertragen</h1>
</div>
</template>
<template #main>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
<form v-else class="flex flex-col gap-4 py-2 w-full max-w-xl mx-auto" @submit.prevent="triggerTransfer">
<div class="w-full">
<Combobox v-model="selected">
<ComboboxLabel>Nutzer suchen</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-sm 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-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
:displayValue="
(person) => (person as UserViewModel)?.firstname + ' ' + (person as UserViewModel)?.lastname
"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">Keine Auswahl</span>
</li>
</ComboboxOption>
<ComboboxOption
v-for="user in filtered"
as="template"
:key="user.id"
:value="user"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ user.firstname }} {{ user.lastname }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="!w-fit" @click="selected = undefined">abbrechen</button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || selected == undefined">
übertragen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } 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 { useUserStore } from "@/stores/admin/management/user";
import { isAuthenticatedPromise } from "@/router/authGuard";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import type { UserViewModel } from "@/viewmodels/admin/management/user.models";
import { useAccountStore } from "@/stores/account";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
query: "" as String,
selected: undefined as UserViewModel | undefined,
};
},
computed: {
...mapState(useUserStore, ["users", "loading"]),
...mapState(useAccountStore, ["id"]),
filtered(): Array<UserViewModel> {
return (
this.query === ""
? this.users
: this.users.filter((user) =>
(user.firstname + " " + user.lastname)
.toLowerCase()
.replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, ""))
)
).filter((u) => u.id != this.id);
},
},
mounted() {
this.fetchUsers();
},
methods: {
...mapActions(useUserStore, ["fetchUsers"]),
triggerTransfer(e: any) {
if (this.selected == undefined) return;
this.status = "loading";
this.$http
.put(`/user/transferOwner`, {
toId: this.selected.id,
})
.then(() => {
isAuthenticatedPromise(true).catch(() => {});
this.status = { status: "success" };
setTimeout(() => {
this.$router.push({ name: "account-default" });
}, 2000);
})
.catch((err) => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,94 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<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">Meine Anmeldedaten</h1>
</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" />
</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>
</div>
</template>
</MainTemplate>
</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>

126
src/views/account/Me.vue Normal file
View file

@ -0,0 +1,126 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<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">Mein Account</h1>
</div>
</template>
<template #main>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
<form
v-else-if="user != null"
class="flex flex-col gap-4 py-2 w-full max-w-xl mx-auto"
@submit.prevent="triggerUpdateUser"
>
<div>
<label for="username">Nutzername</label>
<input type="text" id="username" required v-model="user.username" />
</div>
<div>
<label for="firstname">Vorname</label>
<input type="text" id="firstname" required v-model="user.firstname" />
</div>
<div>
<label for="lastname">Nachname</label>
<input type="text" id="lastname" required v-model="user.lastname" />
</div>
<div>
<label for="mail">Mailadresse</label>
<input type="email" id="mail" required v-model="user.mail" />
</div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm">
verwerfen
</button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || canSaveOrReset">
speichern
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia";
import type { UserViewModel } from "@/viewmodels/admin/management/user.models";
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 cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
loading: "loading" as "loading" | "fetched" | "failed",
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
origin: null as null | UserViewModel,
user: null as null | UserViewModel,
timeout: null as any,
};
},
computed: {
canSaveOrReset(): boolean {
return isEqual(this.origin, this.user);
},
},
mounted() {
this.fetchItem();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
resetForm() {
this.user = cloneDeep(this.origin);
},
fetchItem() {
this.$http
.get(`/user/me`)
.then((result) => {
this.loading = "fetched";
this.user = result.data;
this.origin = cloneDeep(result.data);
})
.catch((err) => {
this.loading = "failed";
});
},
triggerUpdateUser(e: any) {
if (this.user == null) return;
let formData = e.target.elements;
this.status = "loading";
this.$http
.patch(`/user/me`, {
username: formData.username.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
mail: formData.mail.value,
})
.then(() => {
this.fetchItem();
this.status = { status: "success" };
})
.catch((err) => {
this.status = { status: "failed" };
})
.finally(() => {
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,31 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<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">Meine Berechtigungen</h1>
</div>
</template>
<template #main>
<Permission :permissions="permissions" :disableEdit="true" />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Permission from "@/components/admin/Permission.vue";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {};
},
computed: {
...mapState(useAbilityStore, ["permissions"]),
},
});
</script>

View file

@ -0,0 +1,57 @@
<template>
<SidebarLayout>
<template #sidebar>
<SidebarTemplate
mainTitle="Mein Account"
:topTitle="config.app_name_overwrite || 'FF Operation'"
:showTopList="isOwner"
>
<template v-if="isOwner" #topList>
<RoutingLink
title="Administration"
:link="{ name: 'account-administration' }"
:active="activeRouteName == 'account-administration'"
/>
</template>
<template #list>
<RoutingLink title="Mein Account" :link="{ name: 'account-me' }" :active="activeRouteName == 'account-me'" />
<RoutingLink
title="Anmeldedaten"
:link="{ name: 'account-logindata' }"
:active="activeRouteName == 'account-logindata'"
/>
<RoutingLink
title="Meine Berechtigungen"
:link="{ name: 'account-permission' }"
:active="activeRouteName == 'account-permission'"
/>
</template>
</SidebarTemplate>
</template>
<template #main>
<RouterView />
</template>
</SidebarLayout>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import SidebarLayout from "@/layouts/Sidebar.vue";
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";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAbilityStore, ["isOwner"]),
activeRouteName() {
return this.$route.name;
},
},
});
</script>

View file

@ -0,0 +1,3 @@
<template>
<div class="w-full h-full bg-white rounded-md flex items-center justify-center">bitte auswählen</div>
</template>

73
src/views/admin/View.vue Normal file
View file

@ -0,0 +1,73 @@
<template>
<SidebarLayout :show-sidebar="activeTopLevelObject.showSidebar">
<template #sidebar>
<SidebarTemplate
:mainTitle="activeNavigationObject.mainTitle"
:topTitle="activeNavigationObject.topTitle"
:showTopList="activeNavigationObject.top != null"
>
<template #topList>
<RoutingLink
v-for="item in activeNavigationObject.top"
:key="item.key"
:title="item.title"
:link="{ name: `admin-${activeNavigation}-${item.key}` }"
:active="activeLink == item.key"
/>
</template>
<template #list>
<div v-for="item in activeNavigationObject.main" :key="item.key">
<RoutingLink
v-if="!item.key.includes('divider')"
:title="item.title"
:link="{ name: `admin-${activeNavigation}-${item.key}` }"
:active="activeLink == item.key"
/>
<p v-else class="pt-4 border-b border-gray-300">{{ item.title }}</p>
</div>
</template>
</SidebarTemplate>
</template>
<template #main>
<RouterView />
</template>
</SidebarLayout>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useNavigationStore } from "@/stores/admin/navigation";
import SidebarLayout from "@/layouts/Sidebar.vue";
import SidebarTemplate from "@/templates/Sidebar.vue";
import RoutingLink from "@/components/admin/RoutingLink.vue";
import { useAbilityStore } from "@/stores/ability";
import { RouterView } from "vue-router";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useNavigationStore, [
"activeNavigationObject",
"activeTopLevelObject",
"activeLink",
"activeNavigation",
]),
},
created() {
useAbilityStore().$subscribe(() => {
this.updateTopLevel();
this.updateNavigation();
});
this.updateTopLevel();
this.updateNavigation();
},
beforeUnmount() {
this.resetNavigation();
},
methods: {
...mapActions(useNavigationStore, ["resetNavigation", "updateTopLevel", "updateNavigation"]),
},
});
</script>

View file

@ -0,0 +1,3 @@
<template>
<div class="w-full h-full bg-white rounded-md flex items-center justify-center">bitte auswählen</div>
</template>

View file

@ -0,0 +1,69 @@
<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">Kräfte</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination
:items="forces"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
:useSearch="true"
@load-data="(offset, count, search) => fetchForces(offset, count, search)"
@search="(search) => fetchForces(0, maxEntriesPerPage, search, true)"
>
<template #pageRow="{ row }: { row: ForceViewModel }">
<ForceListItem :force="row" />
</template>
</Pagination>
<div class="flex flex-row gap-4">
<button v-if="can('create', 'operation', 'force')" primary class="!w-fit" @click="openCreateModal">
Mitglied erstellen
</button>
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useForceStore } from "@/stores/admin/configuration/forces";
import ForceListItem from "@/components/admin/club/force/ForceListItem.vue";
import { useModalStore } from "@/stores/modal";
import Pagination from "@/components/Pagination.vue";
import type { ForceViewModel } from "@/viewmodels/admin/configuration/force.models";
import { useAbilityStore } from "@/stores/ability";
import { DocumentTextIcon, PencilIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
currentPage: 0,
maxEntriesPerPage: 25,
};
},
computed: {
...mapState(useForceStore, ["forces", "totalCount", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchForces(0, this.maxEntriesPerPage, "", true);
},
methods: {
...mapActions(useForceStore, ["fetchForces"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(markRaw(defineAsyncComponent(() => import("@/components/admin/club/force/CreateForceModal.vue"))));
},
},
});
</script>

View file

@ -0,0 +1,78 @@
<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">Backups</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="flex flex-col grow gap-2 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isActive }"
:to="{ name: tab.route }"
class="w-1/2 p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-none',
isActive ? 'bg-red-200 shadow border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useBackupStore } from "@/stores/admin/management/backup";
import BackupListItem from "@/components/admin/management/backup/BackupListItem.vue";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
tabs: [
{ route: "admin-management-backup-generated", title: "Erstellt" },
{ route: "admin-management-backup-uploaded", title: "Uploads" },
],
};
},
computed: {
...mapState(useBackupStore, ["backups"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchBackups();
},
methods: {
...mapActions(useBackupStore, ["fetchBackups"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/management/backup/CreateBackupModal.vue")))
);
},
openUploadModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/management/backup/UploadBackupModal.vue")))
);
},
},
});
</script>

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