diff --git a/index.html b/index.html index 87432ad..d832af0 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - Fireportal + Mitgliederverwaltung
diff --git a/package-lock.json b/package-lock.json index 8a81714..520c856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "GPL-3.0-only", "dependencies": { "@headlessui/vue": "^1.7.13", + "@heroicons/vue": "^1.0.6", "axios": "^0.26.1", + "jwt-decode": "^4.0.0", "nprogress": "^0.2.0", "pdf-dist": "^1.0.0", "pinia": "^2.1.7", @@ -2417,6 +2419,15 @@ "vue": "^3.2.0" } }, + "node_modules/@heroicons/vue": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-1.0.6.tgz", + "integrity": "sha512-ng2YcCQrdoQWEFpw+ipFl2rZo8mZ56v0T5+MyfQQvNqfKChwgP6DMloZLW+rl17GEcHkE3H82UTAMKBKZr4+WA==", + "license": "MIT", + "peerDependencies": { + "vue": ">= 3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -6500,6 +6511,15 @@ "node": ">=0.10.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 177bc25..e94b600 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "license": "GPL-3.0-only", "dependencies": { "@headlessui/vue": "^1.7.13", + "@heroicons/vue": "^1.0.6", "axios": "^0.26.1", + "jwt-decode": "^4.0.0", "nprogress": "^0.2.0", "pdf-dist": "^1.0.0", "pinia": "^2.1.7", diff --git a/public/FFW-Logo.svg b/public/FFW-Logo.svg new file mode 100644 index 0000000..97abd6f --- /dev/null +++ b/public/FFW-Logo.svg @@ -0,0 +1 @@ +FreiwilligeMerchingFeuerwehr \ No newline at end of file diff --git a/public/FW-Wappen.ico b/public/FW-Wappen.ico new file mode 100644 index 0000000..8019175 Binary files /dev/null and b/public/FW-Wappen.ico differ diff --git a/public/FW-Wappen.png b/public/FW-Wappen.png new file mode 100644 index 0000000..5e07171 Binary files /dev/null and b/public/FW-Wappen.png differ diff --git a/public/FW-Wappen.svg b/public/FW-Wappen.svg new file mode 100644 index 0000000..65094dd --- /dev/null +++ b/public/FW-Wappen.svg @@ -0,0 +1 @@ +MERCHINGFREIWILLIGEFEUERWEHR \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 2446940..bd8a4af 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,32 @@ + + - + diff --git a/src/components/FailureXMark.vue b/src/components/FailureXMark.vue new file mode 100644 index 0000000..3c1a1ff --- /dev/null +++ b/src/components/FailureXMark.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/components/Footer.vue b/src/components/Footer.vue new file mode 100644 index 0000000..ffd7fe5 --- /dev/null +++ b/src/components/Footer.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/components/Header.vue b/src/components/Header.vue new file mode 100644 index 0000000..c47cbdc --- /dev/null +++ b/src/components/Header.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/components/Spinner.vue b/src/components/Spinner.vue new file mode 100644 index 0000000..acb32d6 --- /dev/null +++ b/src/components/Spinner.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/SuccessCheckmark.vue b/src/components/SuccessCheckmark.vue new file mode 100644 index 0000000..ae5516f --- /dev/null +++ b/src/components/SuccessCheckmark.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/components/UserMenu.vue b/src/components/UserMenu.vue new file mode 100644 index 0000000..8be4d08 --- /dev/null +++ b/src/components/UserMenu.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/components/admin/RoutingLink.vue b/src/components/admin/RoutingLink.vue new file mode 100644 index 0000000..a8c38da --- /dev/null +++ b/src/components/admin/RoutingLink.vue @@ -0,0 +1,38 @@ + + + + + +@/stores/contest/viewManager diff --git a/src/components/admin/TopLevelLink.vue b/src/components/admin/TopLevelLink.vue new file mode 100644 index 0000000..df0afe8 --- /dev/null +++ b/src/components/admin/TopLevelLink.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/globalProperties.config.ts b/src/globalProperties.config.ts new file mode 100644 index 0000000..8bd0344 --- /dev/null +++ b/src/globalProperties.config.ts @@ -0,0 +1,15 @@ +import type { AxiosInstance } from "axios"; +import type { NProgress } from "nprogress"; +import type { Router } from "vue-router"; + +declare module "@vue/runtime-core" { + interface ComponentCustomProperties { + $dev: boolean; + $http: AxiosInstance; + $progress: NProgress; + $router: Router; + $route: any; + } +} + +export {}; // Important! See note. diff --git a/src/layouts/FullContent.vue b/src/layouts/FullContent.vue new file mode 100644 index 0000000..ae0f638 --- /dev/null +++ b/src/layouts/FullContent.vue @@ -0,0 +1,7 @@ + diff --git a/src/layouts/Sidebar.vue b/src/layouts/Sidebar.vue new file mode 100644 index 0000000..2bffff5 --- /dev/null +++ b/src/layouts/Sidebar.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/main.css b/src/main.css index 33230c6..6d54f3e 100644 --- a/src/main.css +++ b/src/main.css @@ -2,6 +2,27 @@ @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 */ * { @@ -27,10 +48,34 @@ html, body { - @apply h-full w-screen m-0 p-0 overflow-hidden bg-white; + @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; } + +button:not([headlessui]), +a[button]:not([headlessui]) { + @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 { + @apply opacity-75 pointer-events-none; +} + +input:not([type="checkbox"]), +textarea { + @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; +} diff --git a/src/main.ts b/src/main.ts index 341b931..fdb7f03 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,19 @@ 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"); diff --git a/src/router/accountGuard.ts b/src/router/accountGuard.ts new file mode 100644 index 0000000..0ab0074 --- /dev/null +++ b/src/router/accountGuard.ts @@ -0,0 +1,9 @@ +import { useAccountStore } from "@/stores/account"; + +export async function loadAccountData(to: any, from: any, next: any) { + const account = useAccountStore(); + account.fetchAccountContests(); + account.fetchAccountInvites(); + account.fetchAccountLicense(); + next(); +} diff --git a/src/router/authGuards.ts b/src/router/authGuards.ts new file mode 100644 index 0000000..07bc5df --- /dev/null +++ b/src/router/authGuards.ts @@ -0,0 +1,41 @@ +import NProgress from "nprogress"; +import { useAuthStore } from "@/stores/auth"; +import { useAccountStore } from "@/stores/account"; +import { jwtDecode, type JwtPayload } from "jwt-decode"; + +type Payload = JwtPayload & { userId: number; username: string; firstname: string; lastname: string; mail: string }; + +export async function isAuthenticated(to: any, from: any, next: any) { + const auth = useAuthStore(); + NProgress.start(); + if (auth.authCheck && localStorage.getItem("access_token")) { + NProgress.done(); + next(); + return; + } + await isAuthenticatedPromise() + .then(async (result: any) => { + NProgress.done(); + next(); + }) + .catch((err: Error) => { + NProgress.done(); + next({ name: "login" }); + }); +} + +export async function isAuthenticatedPromise(): Promise { + return new Promise((resolve, reject) => { + const auth = useAuthStore(); + const account = useAccountStore(); + let decoded = jwtDecode(localStorage.getItem("accessToken") ?? ""); + + auth.setSuccess(); + if (typeof decoded == "string" || !decoded) { + reject("jwt failed"); + } + var { firstname, lastname, mail, username } = decoded; + account.setAccountData(firstname, lastname, mail, username); + resolve(decoded); + }); +} diff --git a/src/router/index.ts b/src/router/index.ts index c843786..0ade275 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,13 +1,31 @@ -import { createRouter, createWebHistory } from "vue-router"; -import HomeView from "../views/HomeView.vue"; +import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"; +import Login from "../views/Login.vue"; + +import { isAuthenticated } from "./authGuards"; +import { loadAccountData } from "./accountGuard"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: "/", - name: "home", - component: HomeView, + redirect: { name: "admin" }, + }, + { + path: "/login", + name: "login", + component: Login, + }, + { + path: "/admin", + name: "admin", + component: () => import("../views/admin/View.vue"), + beforeEnter: [isAuthenticated], + }, + { + path: "/:pathMatch(.*)*", + name: "404", + component: () => import("../views/notFound.vue"), }, ], }); diff --git a/src/serverCom.ts b/src/serverCom.ts index 43e37c8..a58b0a3 100644 --- a/src/serverCom.ts +++ b/src/serverCom.ts @@ -1,7 +1,7 @@ import axios from "axios"; const http = axios.create({ - baseURL: "https://localhost:5000", + baseURL: "http://localhost:5000", headers: { "Cache-Control": "no-cache", Pragma: "no-cache", @@ -30,6 +30,10 @@ http.interceptors.response.use( return response; }, async (error) => { + if (error.config.url.includes("/auth")) { + return Promise.reject(error); + } + const originalRequest = error.config; // Handle token expiration and retry the request with a refreshed token diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts new file mode 100644 index 0000000..d77b62b --- /dev/null +++ b/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/src/stores/account.ts b/src/stores/account.ts new file mode 100644 index 0000000..8c11fd9 --- /dev/null +++ b/src/stores/account.ts @@ -0,0 +1,25 @@ +import { defineStore } from "pinia"; + +export const useAccountStore = defineStore("account", { + state: () => { + return { + 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(firstname: string, lastname: string, mail: string, alias: string) { + this.firstname = firstname; + this.lastname = lastname; + this.mail = mail; + this.alias = alias; + }, + }, +}); diff --git a/src/stores/admin/navigation.ts b/src/stores/admin/navigation.ts new file mode 100644 index 0000000..4275d90 --- /dev/null +++ b/src/stores/admin/navigation.ts @@ -0,0 +1,151 @@ +import { defineStore } from "pinia"; +import { shallowRef, defineAsyncComponent } from "vue"; + +export interface navigationModel { + club: navigationSplitModel; + settings: navigationSplitModel; + user: navigationSplitModel; +} + +export interface navigationSplitModel { + topTitle?: string; + top?: Array; + mainTitle: string; + main: Array; +} + +export type topLevelNavigationType = "club" | "settings" | "user"; + +export interface topLevelNavigationModel { + key: topLevelNavigationType; + title: string; + levelDefault: string; +} + +export interface navigationLinkModel { + key: string; + title: string; + component: any; +} + +export const useNavigationStore = defineStore("navigation", { + state: () => { + return { + activeNavigation: "club" as topLevelNavigationType, + activeLink: null as null | navigationLinkModel, + topLevel: [ + { + key: "club", + title: "Verein", + levelDefault: "#members", + }, + { + key: "settings", + title: "Einstellungen", + levelDefault: "#qualification", + }, + ] as Array, + navigation: { + club: { + mainTitle: "Verein", + main: [ + { + key: "#members", + title: "Mitglieder", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#calendar", + title: "Termine", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#newsletter", + title: "Newsletter", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#protocol", + title: "Prookolle", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + ], + }, + settings: { + mainTitle: "Einstellungen", + main: [ + { + key: "#qualification", + title: "Qualifikationen", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#award", + title: "Auszeichnungen", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#executive_position", + title: "Vereinsämter", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#communication", + title: "Mitgliederdaten", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + ], + }, + user: { + mainTitle: "Benutzer", + main: [ + { + key: "#user", + title: "Benutzer", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + { + key: "#roles", + title: "Rollen", + component: shallowRef(defineAsyncComponent(() => import("@/views/admin/members/Overview.vue"))), + }, + ], + }, + } as navigationModel, + }; + }, + getters: { + activeNavigationObject: (state) => (state.navigation[state.activeNavigation] ?? {}) as navigationSplitModel, + activeTopLevelObject: (state) => + (state.topLevel.find((elem) => elem.key == state.activeNavigation) ?? {}) as topLevelNavigationModel, + }, + actions: { + setTopLevel(key: topLevelNavigationType, disableSubLink: boolean = true) { + let level = this.topLevel.find((e) => e.key == key) ?? null; + if (!level) { + this.activeNavigation = "club"; + if (!disableSubLink) this.setLink(this.topLevel.find((e) => e.key == "club")?.levelDefault ?? null); + else this.setLink(null); + } else { + this.activeNavigation = level.key; + if (!disableSubLink) this.setLink(level.levelDefault); + else this.setLink(null); + } + }, + setLink(key: string | null) { + let nav = this.navigation[this.activeNavigation]; + if (!nav) { + this.activeLink = null; + return; + } + let links = [...Object.values(nav.main), ...Object.values(nav.top ?? {})]; + this.activeLink = links.find((e) => e.key == key) ?? null; + }, + setTopLevelNav(topLeveLinks: Array) { + this.topLevel = topLeveLinks; + }, + resetNavigation() { + this.$reset(); + }, + }, +}); diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..fdaf4d2 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,14 @@ +import { defineStore } from "pinia"; + +export const useAuthStore = defineStore("auth", { + state: () => { + return { + authCheck: false, + }; + }, + actions: { + setSuccess() { + this.authCheck = true; + }, + }, +}); diff --git a/src/templates/Main.vue b/src/templates/Main.vue new file mode 100644 index 0000000..540ff88 --- /dev/null +++ b/src/templates/Main.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/templates/Sidebar.vue b/src/templates/Sidebar.vue new file mode 100644 index 0000000..a5f6a16 --- /dev/null +++ b/src/templates/Sidebar.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue deleted file mode 100644 index 142ab8f..0000000 --- a/src/views/HomeView.vue +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..e3decc3 --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/views/RouterView.vue b/src/views/RouterView.vue new file mode 100644 index 0000000..543cfdb --- /dev/null +++ b/src/views/RouterView.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/views/admin/View.vue b/src/views/admin/View.vue new file mode 100644 index 0000000..795aa52 --- /dev/null +++ b/src/views/admin/View.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/views/admin/members/Overview.vue b/src/views/admin/members/Overview.vue new file mode 100644 index 0000000..1dd4ee8 --- /dev/null +++ b/src/views/admin/members/Overview.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/src/views/notFound.vue b/src/views/notFound.vue new file mode 100644 index 0000000..1a4e66c --- /dev/null +++ b/src/views/notFound.vue @@ -0,0 +1,282 @@ + diff --git a/tailwind.config.js b/tailwind.config.js index 45ce112..59560d6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,7 +2,17 @@ export default { content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], theme: { - extend: {}, + extend: { + colors: { + primary: "var(--primary)", + secondary: "var(--secondary)", + accent: "var(--accent)", + error: "var(--error)", + warning: "var(--warning)", + info: "var(--info)", + success: "var(--success)", + }, + }, }, plugins: [], };