base structure
transfered from ff admin
This commit is contained in:
parent
efd7c40660
commit
f50dff99f3
122 changed files with 17537 additions and 2 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
|||
# NodeJs
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
5
.env.example
Normal file
5
.env.example
Normal 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
5
.env.production
Normal 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
15
.eslintrc.cjs
Normal 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
32
.gitignore
vendored
|
@ -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
5
.prettierrc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120
|
||||
}
|
25
Dockerfile
Normal file
25
Dockerfile
Normal 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" ]
|
75
README.md
75
README.md
|
@ -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
27
entrypoint.sh
Normal 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
1
env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
12
index.html
Normal file
12
index.html
Normal 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
16
nginx.conf
Normal 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
10399
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
76
package.json
Normal file
76
package.json
Normal 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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
public/Logo.png
Normal file
BIN
public/Logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 65 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
40
src/App.vue
Normal file
40
src/App.vue
Normal 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>
|
48
src/components/ContextMenu.vue
Normal file
48
src/components/ContextMenu.vue
Normal 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>
|
61
src/components/FailureXMark.vue
Normal file
61
src/components/FailureXMark.vue
Normal 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
43
src/components/Footer.vue
Normal 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>
|
18
src/components/FormBottomBar.vue
Normal file
18
src/components/FormBottomBar.vue
Normal 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
54
src/components/Header.vue
Normal 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
32
src/components/Modal.vue
Normal 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>
|
130
src/components/Notification.vue
Normal file
130
src/components/Notification.vue
Normal 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>
|
265
src/components/Pagination.vue
Normal file
265
src/components/Pagination.vue
Normal 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> -->
|
3
src/components/Spinner.vue
Normal file
3
src/components/Spinner.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<div class="w-5 h-5 border-y-2 rounded-full border-gray-700 animate-spin" />
|
||||
</template>
|
60
src/components/SuccessCheckmark.vue
Normal file
60
src/components/SuccessCheckmark.vue
Normal 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>
|
44
src/components/TextCopy.vue
Normal file
44
src/components/TextCopy.vue
Normal 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>
|
55
src/components/UserMenu.vue
Normal file
55
src/components/UserMenu.vue
Normal 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>
|
182
src/components/admin/ForceSearchSelect.vue
Normal file
182
src/components/admin/ForceSearchSelect.vue
Normal 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>
|
198
src/components/admin/Permission.vue
Normal file
198
src/components/admin/Permission.vue
Normal 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>
|
36
src/components/admin/RoutingLink.vue
Normal file
36
src/components/admin/RoutingLink.vue
Normal 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>
|
43
src/components/admin/TopLevelLink.vue
Normal file
43
src/components/admin/TopLevelLink.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
31
src/components/admin/configuration/force/ForceListItem.vue
Normal file
31
src/components/admin/configuration/force/ForceListItem.vue
Normal 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>
|
58
src/components/admin/management/backup/BackupListItem.vue
Normal file
58
src/components/admin/management/backup/BackupListItem.vue
Normal 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>
|
67
src/components/admin/management/backup/CreateBackupModal.vue
Normal file
67
src/components/admin/management/backup/CreateBackupModal.vue
Normal 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>
|
112
src/components/admin/management/backup/RestoreBackupModal.vue
Normal file
112
src/components/admin/management/backup/RestoreBackupModal.vue
Normal 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>
|
97
src/components/admin/management/backup/UploadBackupModal.vue
Normal file
97
src/components/admin/management/backup/UploadBackupModal.vue
Normal 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>
|
72
src/components/admin/management/role/CreateRoleModal.vue
Normal file
72
src/components/admin/management/role/CreateRoleModal.vue
Normal 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>
|
75
src/components/admin/management/role/DeleteRoleModal.vue
Normal file
75
src/components/admin/management/role/DeleteRoleModal.vue
Normal 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>
|
54
src/components/admin/management/role/RoleListItem.vue
Normal file
54
src/components/admin/management/role/RoleListItem.vue
Normal 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>
|
75
src/components/admin/management/user/DeleteUserModal.vue
Normal file
75
src/components/admin/management/user/DeleteUserModal.vue
Normal 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>
|
48
src/components/admin/management/user/InviteListItem.vue
Normal file
48
src/components/admin/management/user/InviteListItem.vue
Normal 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>
|
95
src/components/admin/management/user/InviteUserModal.vue
Normal file
95
src/components/admin/management/user/InviteUserModal.vue
Normal 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>
|
85
src/components/admin/management/user/UserListItem.vue
Normal file
85
src/components/admin/management/user/UserListItem.vue
Normal 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
15
src/config.ts
Normal 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,
|
||||
};
|
13
src/globalProperties.config.ts
Normal file
13
src/globalProperties.config.ts
Normal 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.
|
9
src/helpers/piniaReset.ts
Normal file
9
src/helpers/piniaReset.ts
Normal 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());
|
||||
};
|
8
src/helpers/quillConfig.ts
Normal file
8
src/helpers/quillConfig.ts
Normal 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"],
|
||||
];
|
7
src/layouts/FullContent.vue
Normal file
7
src/layouts/FullContent.vue
Normal 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
41
src/layouts/Sidebar.vue
Normal 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
151
src/main.css
Normal 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
21
src/main.ts
Normal 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");
|
6
src/router/accountGuard.ts
Normal file
6
src/router/accountGuard.ts
Normal 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
37
src/router/adminGuard.ts
Normal 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
83
src/router/authGuard.ts
Normal 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
19
src/router/backupGuard.ts
Normal 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
321
src/router/index.ts
Normal 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
16
src/router/setupGuard.ts
Normal 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
105
src/serverCom.ts
Normal 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
5
src/shims-vue.d.ts
vendored
Normal 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
81
src/stores/ability.ts
Normal 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
29
src/stores/account.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
89
src/stores/admin/configuration/forces.ts
Normal file
89
src/stores/admin/configuration/forces.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
57
src/stores/admin/management/backup.ts
Normal file
57
src/stores/admin/management/backup.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
41
src/stores/admin/management/invite.ts
Normal file
41
src/stores/admin/management/invite.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
57
src/stores/admin/management/role.ts
Normal file
57
src/stores/admin/management/role.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
60
src/stores/admin/management/user.ts
Normal file
60
src/stores/admin/management/user.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
118
src/stores/admin/navigation.ts
Normal file
118
src/stores/admin/navigation.ts
Normal 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
17
src/stores/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
32
src/stores/context-menu.ts
Normal file
32
src/stores/context-menu.ts
Normal 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
23
src/stores/modal.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
48
src/stores/notification.ts
Normal file
48
src/stores/notification.ts
Normal 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
74
src/templates/Main.vue
Normal 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
75
src/templates/Sidebar.vue
Normal 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
3
src/types/backupTypes.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type BackupSection = "base" | "user";
|
||||
|
||||
export const backupSections: Array<BackupSection> = ["base", "user"];
|
33
src/types/permissionTypes.ts
Normal file
33
src/types/permissionTypes.ts
Normal 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"],
|
||||
};
|
19
src/viewmodels/admin/configuration/force.models.ts
Normal file
19
src/viewmodels/admin/configuration/force.models.ts
Normal 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;
|
||||
}
|
8
src/viewmodels/admin/management/backup.models.ts
Normal file
8
src/viewmodels/admin/management/backup.models.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { BackupSection } from "../../../types/backupTypes";
|
||||
|
||||
export interface BackupRestoreViewModel {
|
||||
filename: string;
|
||||
partial: boolean;
|
||||
include: Array<BackupSection>;
|
||||
overwrite: boolean;
|
||||
}
|
13
src/viewmodels/admin/management/invite.models.ts
Normal file
13
src/viewmodels/admin/management/invite.models.ts
Normal 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;
|
||||
}
|
7
src/viewmodels/admin/management/role.models.ts
Normal file
7
src/viewmodels/admin/management/role.models.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { PermissionObject } from "@/types/permissionTypes";
|
||||
|
||||
export interface RoleViewModel {
|
||||
id: number;
|
||||
permissions: PermissionObject;
|
||||
role: string;
|
||||
}
|
29
src/viewmodels/admin/management/user.models.ts
Normal file
29
src/viewmodels/admin/management/user.models.ts
Normal 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;
|
||||
}
|
21
src/viewmodels/version.models.ts
Normal file
21
src/viewmodels/version.models.ts
Normal 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
92
src/views/Login.vue
Normal 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>
|
32
src/views/NoPermission.vue
Normal file
32
src/views/NoPermission.vue
Normal 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
7
src/views/RouterView.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
159
src/views/account/Administration.vue
Normal file
159
src/views/account/Administration.vue
Normal 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>
|
94
src/views/account/LoginData.vue
Normal file
94
src/views/account/LoginData.vue
Normal 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
126
src/views/account/Me.vue
Normal 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>
|
31
src/views/account/Permission.vue
Normal file
31
src/views/account/Permission.vue
Normal 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>
|
57
src/views/account/View.vue
Normal file
57
src/views/account/View.vue
Normal 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>
|
3
src/views/account/ViewSelect.vue
Normal file
3
src/views/account/ViewSelect.vue
Normal 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
73
src/views/admin/View.vue
Normal 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>
|
3
src/views/admin/ViewSelect.vue
Normal file
3
src/views/admin/ViewSelect.vue
Normal 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>
|
69
src/views/admin/configuration/force/Force.vue
Normal file
69
src/views/admin/configuration/force/Force.vue
Normal 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>
|
78
src/views/admin/management/backup/BackupRouting.vue
Normal file
78
src/views/admin/management/backup/BackupRouting.vue
Normal 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
Loading…
Add table
Reference in a new issue