Compare commits
	
		
			12 Commits
		
	
	
		
			develop
			...
			feature/ba
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 744fe73f07 | |
|  | f98b3d72fc | |
|  | d2044975db | |
|  | ea01742e00 | |
|  | 7395b1f288 | |
|  | 57e468f1f4 | |
|  | 5e8f4bc86a | |
|  | ca9e4bdbcb | |
|  | 561025d646 | |
|  | 955942ac8d | |
|  | dc58fa8e1d | |
|  | a83ba72cfa | 
							
								
								
									
										31
									
								
								.eslintrc.js
								
								
								
								
							
							
						
						|  | @ -17,11 +17,11 @@ module.exports = { | |||
|     project: resolve(__dirname, './tsconfig.json'), | ||||
|     tsconfigRootDir: __dirname, | ||||
|     ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features
 | ||||
|     sourceType: 'module', // Allows for the use of imports
 | ||||
|     sourceType: 'module' // Allows for the use of imports
 | ||||
|   }, | ||||
| 
 | ||||
|   env: { | ||||
|     browser: true, | ||||
|     browser: true | ||||
|   }, | ||||
| 
 | ||||
|   // Rules order is important, please avoid shuffling them
 | ||||
|  | @ -44,7 +44,7 @@ module.exports = { | |||
| 
 | ||||
|     // https://github.com/prettier/eslint-config-prettier#installation
 | ||||
|     // usage with Prettier, provided by 'eslint-config-prettier'.
 | ||||
|     'plugin:prettier/recommended', | ||||
|     'prettier', //'plugin:prettier/recommended'
 | ||||
|   ], | ||||
| 
 | ||||
|   plugins: [ | ||||
|  | @ -54,6 +54,10 @@ module.exports = { | |||
|     // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file
 | ||||
|     // required to lint *.vue files
 | ||||
|     'vue', | ||||
| 
 | ||||
|     // https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
 | ||||
|     // Prettier has not been included as plugin to avoid performance impact
 | ||||
|     // add it as an extension for your IDE
 | ||||
|   ], | ||||
| 
 | ||||
|   globals: { | ||||
|  | @ -66,26 +70,19 @@ module.exports = { | |||
|     __QUASAR_SSR_PWA__: true, | ||||
|     process: true, | ||||
|     Capacitor: true, | ||||
|     chrome: true, | ||||
|     chrome: true | ||||
|   }, | ||||
| 
 | ||||
|   // add your custom rules here
 | ||||
|   rules: { | ||||
|     // VueStuff
 | ||||
|     // Defaults to error on eslint-plugin-vue 8.0.3, but let us be not too strict with names
 | ||||
|     'vue/multi-word-component-names': 'off', | ||||
|     'prefer-promise-reject-errors': 'off', | ||||
| 
 | ||||
|     // Rejects on promises should always be of the Error type (and allow empty rejects as well)
 | ||||
|     'prefer-promise-reject-errors': ['error', { allowEmptyReject: true }], | ||||
| 
 | ||||
|     // Allow " if ' is contained inside the string, so we can avoid escaping
 | ||||
|     quotes: ['error', 'single', { avoidEscape: true }], | ||||
| 
 | ||||
|     // TypeScript, let us be not too strict
 | ||||
|     // TypeScript
 | ||||
|     quotes: ['warn', 'single', { avoidEscape: true }], | ||||
|     '@typescript-eslint/explicit-function-return-type': 'off', | ||||
|     '@typescript-eslint/explicit-module-boundary-types': 'off', | ||||
| 
 | ||||
|     // allow debugger during development only
 | ||||
|     'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', | ||||
|   }, | ||||
| }; | ||||
|     'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ node_modules | |||
| 
 | ||||
| # We use yarn, so ignore npm | ||||
| package-lock.json | ||||
| yarn.lock | ||||
| 
 | ||||
| # Quasar core related directories | ||||
| .quasar | ||||
|  | @ -18,8 +17,6 @@ yarn.lock | |||
| 
 | ||||
| # Capacitor related directories and files | ||||
| /src-capacitor/www | ||||
| /src-capacitor/android | ||||
| /src-capacitor/ios | ||||
| /src-capacitor/node_modules | ||||
| 
 | ||||
| # BEX related directories and files | ||||
|  |  | |||
|  | @ -0,0 +1,4 @@ | |||
| [submodule "deps/quasar-ui-qcalendar"] | ||||
| 	path = deps/quasar-ui-qcalendar | ||||
| 	url = https://github.com/susnux/quasar-ui-qcalendar | ||||
| 	branch = quasar2 | ||||
|  | @ -3,6 +3,6 @@ | |||
| module.exports = { | ||||
|   plugins: [ | ||||
|     // to edit target browsers: use "browserslist" field in package.json
 | ||||
|     require('autoprefixer'), | ||||
|   ], | ||||
| }; | ||||
|     require('autoprefixer') | ||||
|   ] | ||||
| } | ||||
|  |  | |||
|  | @ -1,16 +0,0 @@ | |||
| pipeline: | ||||
|   install: | ||||
|     image: node:lts-alpine | ||||
|     commands: | ||||
|       - yarn install | ||||
|   lint: | ||||
|     image: node:lts-alpine | ||||
|     commands: | ||||
|       - yarn lint | ||||
| 
 | ||||
|   build: | ||||
|     image: node:lts-alpine | ||||
|     commands: | ||||
|       - yarn quasar build | ||||
| 
 | ||||
| branches: [main, develop] | ||||
							
								
								
									
										106
									
								
								README.md
								
								
								
								
							
							
						
						|  | @ -1,88 +1,62 @@ | |||
| # Flaschengeist (frontend) | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| Modular student club administration system, licensed under the MIT license. | ||||
| 
 | ||||
| ## Installation | ||||
| 
 | ||||
| ### Requirements | ||||
| 
 | ||||
| ``` | ||||
|  "engines": { | ||||
|     "node": ">= 14.18.1", | ||||
|     "npm": ">= 6.14.12", | ||||
|     "yarn": ">= 1.22.0" | ||||
|  } | ||||
| ``` | ||||
| 
 | ||||
| So on debian (buster and bullseye) you will need to install node.js and yarn beside the debian packages to meet the needed versions. | ||||
| 
 | ||||
| ```bash | ||||
| pushd ~/opt | ||||
| wget https://nodejs.org/dist/latest-v16.x/node-v16.13.0-linux-x64.tar.xz | ||||
| tar -xJf node-v16.13.0-linux-x64.tar.xz | ||||
| export PATH="$(pwd)/node-v16.13.0-linux-x64/bin":"$PATH" | ||||
| npm i -g yarn | ||||
| npm i -g @quasar/cli | ||||
| popd | ||||
| ``` | ||||
| 
 | ||||
| ### Install the dependencies | ||||
| 
 | ||||
| ```bash | ||||
| yarn install | ||||
| ``` | ||||
| 
 | ||||
| Be aware npm might not work. | ||||
| 
 | ||||
| ### Configure Plugins | ||||
| 
 | ||||
| #### Installing a plugin | ||||
| 
 | ||||
| Simply add it as a dependency and install it, for example installing the `pricelist`-plugin: | ||||
| 
 | ||||
| ```sh | ||||
| yarn add '@flaschengeist/pricelist' | ||||
| yarn install | ||||
| ``` | ||||
| 
 | ||||
| #### Enable / Disable a plugin | ||||
| 
 | ||||
| After installing a plugin you will have to enable it, | ||||
| this is done by adding it to the `plugin.config.js` file. | ||||
| For the example above the file should look like: | ||||
| 
 | ||||
| ```js | ||||
| module.exports = [ | ||||
|   // pricelist plugin: | ||||
|   '@flaschengeist/pricelist', | ||||
| ]; | ||||
| ``` | ||||
| 
 | ||||
| Remember to rebuild the project | ||||
| 
 | ||||
| ### Configure Backend | ||||
| 
 | ||||
| The application is using the API of [the backend](https://flaschengeist.dev/Flaschengeist/flaschengeist) | ||||
| This access needs to be configured in `src/config.ts'->config.baseURL | ||||
| 
 | ||||
| - either you do have a proxy webserver that maps the '/api' to the backend (http://localhost:5000) or | ||||
| - you do directly configure the backend there:`baseURL: 'http://localhost:5000'`. Be aware not committing this configuration. | ||||
| You can activate and deactive Plugins in `src/boot/plugins.ts`. | ||||
| You have to set the name of the Plugin into `config.loadModules`. | ||||
| 
 | ||||
| ### Build the application | ||||
| 
 | ||||
| ```sh | ||||
| ```bash | ||||
| yarn quasar build | ||||
| ``` | ||||
| 
 | ||||
| ### Notes on mobile apps (Cordova) | ||||
| 
 | ||||
| For mobile applications older web engines should or must be supported, | ||||
| as manufaturer often do not update their phones, so for building cordova apps set the `BROWSERSLIST_ENV` environment variable to | ||||
| `BROWSERSLIST_ENV=cordova`. | ||||
| This will produce ECDMAscript compatible with iOS 13+ and Android Webview 76 (relased October 2019). | ||||
| 
 | ||||
| ## Development | ||||
| 
 | ||||
| Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). | ||||
| ### Icons used | ||||
| 
 | ||||
| We are using the `mdi-v5` icon set, so feel free to use any icon from it. | ||||
| A list can be found [here](https://materialdesignicons.com/) | ||||
| 
 | ||||
| ### Commands useful for development | ||||
| 
 | ||||
| #### Start the app in development mode | ||||
| 
 | ||||
| Provides hot-code reloading, error reporting, etc. | ||||
| 
 | ||||
| ```bash | ||||
| yarn quasar dev | ||||
| ``` | ||||
| 
 | ||||
| #### File linting | ||||
| 
 | ||||
| ```bash | ||||
| yarn run lint | ||||
| ``` | ||||
| 
 | ||||
| ### Plugins | ||||
| 
 | ||||
| #### Build a Plugin | ||||
| 
 | ||||
| A Flaschengeist-Frontend-Plugin should be placed in `src/plugins`. | ||||
| It needs a `plugin.ts` File which exports a plugin with the following interface: | ||||
| 
 | ||||
| ``` | ||||
| name: string; | ||||
| mainRoutes?: PluginRouteConfig[]; | ||||
| outRoutes?: PluginRouteConfig[]; | ||||
| requiredModules: string[]; | ||||
| version: string; | ||||
| ``` | ||||
| 
 | ||||
| You have to import `FG_Plugin` from `plugins.d.ts`. | ||||
|  |  | |||
|  | @ -1,46 +0,0 @@ | |||
| <template> | ||||
|   <q-avatar> | ||||
|     <slot :avatarURL="avatarURL(modelValue)"> | ||||
|       <q-img :src="avatarURL(modelValue)" style="min-width: 100%; min-height: 100%"> | ||||
|         <template #error> | ||||
|           <img :src="fallback" style="height: 100%" /> | ||||
|         </template> | ||||
|       </q-img> | ||||
|     </slot> | ||||
|   </q-avatar> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { PropType, defineComponent } from 'vue'; | ||||
| import { avatarURL } from '@flaschengeist/api'; | ||||
| 
 | ||||
| /** | ||||
|  * Display an avatar for an user | ||||
|  * | ||||
|  * Slots: | ||||
|  *  default - scope: {avatarURL} | ||||
|  */ | ||||
| export default defineComponent({ | ||||
|   name: 'UserAvatar', | ||||
|   props: { | ||||
|     modelValue: { | ||||
|       type: [Object, String] as PropType<FG.User | string>, | ||||
|       required: true, | ||||
|     }, | ||||
|     showZoom: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     fallback: { | ||||
|       type: String, | ||||
|       default: 'no-image.svg', | ||||
|     }, | ||||
|   }, | ||||
|   emits: ['error'], | ||||
|   setup() { | ||||
|     return { | ||||
|       avatarURL, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | @ -1,5 +0,0 @@ | |||
| import IsoDateInput from './IsoDateInput.vue'; | ||||
| import PasswordInput from './PasswordInput.vue'; | ||||
| import UserAvatar from './UserAvatar.vue'; | ||||
| 
 | ||||
| export { IsoDateInput, PasswordInput, UserAvatar }; | ||||
							
								
								
									
										10
									
								
								api/index.ts
								
								
								
								
							
							
						
						|  | @ -1,10 +0,0 @@ | |||
| export { api, pinia } from './src/internal'; | ||||
| 
 | ||||
| export * from './src/stores/'; | ||||
| 
 | ||||
| export * from './src/utils/datetime'; | ||||
| export * from './src/utils/permission'; | ||||
| export * from './src/utils/persistent'; | ||||
| export * from './src/utils/user'; | ||||
| export * from './src/utils/validators'; | ||||
| export * from './src/utils/misc'; | ||||
|  | @ -1,28 +0,0 @@ | |||
| { | ||||
|   "license": "MIT", | ||||
|   "version": "1.0.0-alpha.7", | ||||
|   "name": "@flaschengeist/api", | ||||
|   "author": "Tim Gröger <flaschengeist@wu5.de>", | ||||
|   "homepage": "https://flaschengeist.dev/Flaschengeist", | ||||
|   "description": "Modular student club administration system", | ||||
|   "bugs": { | ||||
|     "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" | ||||
|   }, | ||||
|   "main": "./src/index.ts", | ||||
|   "peerDependencies": { | ||||
|     "@quasar/app": "^3.2.4", | ||||
|     "flaschengeist": "^2.0.0-alpha.1", | ||||
|     "pinia": "^2.0.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@flaschengeist/types": "^1.0.0-alpha.10", | ||||
|     "@types/node": "^14.18.00", | ||||
|     "typescript": "^4.5.2" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "singleQuote": true, | ||||
|     "semi": true, | ||||
|     "printWidth": 100, | ||||
|     "arrowParens": "always" | ||||
|   } | ||||
| } | ||||
|  | @ -1,6 +0,0 @@ | |||
| //https://github.com/vuejs/vue-next/issues/3130
 | ||||
| declare module '*.vue' { | ||||
|   import { ComponentOptions } from 'vue'; | ||||
|   const component: ComponentOptions; | ||||
|   export default component; | ||||
| } | ||||
|  | @ -1,6 +0,0 @@ | |||
| import axios from 'axios'; | ||||
| import { createPinia } from 'pinia'; | ||||
| 
 | ||||
| export const api = axios.create(); | ||||
| 
 | ||||
| export const pinia = createPinia(); | ||||
|  | @ -1,23 +0,0 @@ | |||
| import { AxiosError } from 'axios'; | ||||
| 
 | ||||
| /** | ||||
|  * Check if error is an AxiosError, and optional if a specific status was returned | ||||
|  * | ||||
|  * @param error Thrown error to check | ||||
|  * @param status If set, check if this error has set thouse status code | ||||
|  */ | ||||
| export function isAxiosError(error: unknown, status?: number) { | ||||
|   // Check if it is an axios error (with axios 1.0 `error instanceof AxiosError` will be possible)
 | ||||
|   if (typeof error !== 'object' || !error || !('isAxiosError' in error)) return false; | ||||
|   // Check status code if status was given
 | ||||
|   if (status !== undefined) | ||||
|     return ( | ||||
|       (<AxiosError>error).response !== undefined && (<AxiosError>error).response?.status === status | ||||
|     ); | ||||
| 
 | ||||
|   return true; | ||||
| } | ||||
| 
 | ||||
| export * from './main'; | ||||
| export * from './session'; | ||||
| export * from './user'; | ||||
|  | @ -1,71 +0,0 @@ | |||
| import { AxiosResponse } from 'axios'; | ||||
| import { defineStore } from 'pinia'; | ||||
| import { api } from '../internal'; | ||||
| import { isAxiosError, useMainStore } from '.'; | ||||
| 
 | ||||
| export function fixSession(s?: FG.Session) { | ||||
|   return !s ? s : Object.assign(s, { expires: new Date(s.expires) }); | ||||
| } | ||||
| 
 | ||||
| export const useSessionStore = defineStore({ | ||||
|   id: 'sessions', | ||||
| 
 | ||||
|   state: () => ({}), | ||||
| 
 | ||||
|   getters: {}, | ||||
| 
 | ||||
|   actions: { | ||||
|     async getSession(token: string) { | ||||
|       return await api | ||||
|         .get(`/auth/${token}`) | ||||
|         .then(({ data }: AxiosResponse<FG.Session>) => data) | ||||
|         .catch(() => undefined); | ||||
|     }, | ||||
| 
 | ||||
|     async getSessions() { | ||||
|       try { | ||||
|         const { data } = await api.get<FG.Session[]>('/auth'); | ||||
|         data.forEach(fixSession); | ||||
| 
 | ||||
|         const mainStore = useMainStore(); | ||||
|         const currentSession = data.find((session) => { | ||||
|           return session.token === mainStore.session?.token; | ||||
|         }); | ||||
|         if (currentSession) { | ||||
|           mainStore.session = currentSession; | ||||
|         } | ||||
|         return data; | ||||
|       } catch (error) { | ||||
|         return [] as FG.Session[]; | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     async deleteSession(token: string) { | ||||
|       const mainStore = useMainStore(); | ||||
|       if (token === mainStore.session?.token) return mainStore.logout(); | ||||
| 
 | ||||
|       try { | ||||
|         await api.delete(`/auth/${token}`); | ||||
|         return true; | ||||
|       } catch (error) { | ||||
|         // Ignore 401, as this means we are already logged out, throw all other
 | ||||
|         if (!isAxiosError(error, 401)) throw error; | ||||
|       } | ||||
|       return false; | ||||
|     }, | ||||
| 
 | ||||
|     async updateSession(lifetime: number, token: string) { | ||||
|       try { | ||||
|         const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime }); | ||||
|         fixSession(data); | ||||
| 
 | ||||
|         const mainStore = useMainStore(); | ||||
|         if (mainStore.session?.token == data.token) mainStore.session = data; | ||||
| 
 | ||||
|         return true; | ||||
|       } catch (error) { | ||||
|         return false; | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
|  | @ -1,211 +0,0 @@ | |||
| import { defineStore } from 'pinia'; | ||||
| import { api } from '../internal'; | ||||
| import { isAxiosError, useMainStore } from '.'; | ||||
| 
 | ||||
| export function fixUser(u?: FG.User) { | ||||
|   return !u ? u : Object.assign(u, { birthday: u.birthday ? new Date(u.birthday) : undefined }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Check if state is outdated / dirty | ||||
|  * Value is considered outdated after 15 minutes | ||||
|  * @param updated Time of last updated (in milliseconds see Date.now()) | ||||
|  * @returns True if outdated, false otherwise | ||||
|  */ | ||||
| function isDirty(updated: number) { | ||||
|   return Date.now() - updated > 15 * 60 * 1000; | ||||
| } | ||||
| 
 | ||||
| export const useUserStore = defineStore({ | ||||
|   id: 'users', | ||||
| 
 | ||||
|   state: () => ({ | ||||
|     roles: [] as FG.Role[], | ||||
|     permissions: [] as FG.Permission[], | ||||
|     // list of all users, include deleted ones, use `users` getter for list of active ones
 | ||||
|     _users: [] as FG.User[], | ||||
|     // Internal flags for deciding if lists need to force-loaded
 | ||||
|     _dirty_users: 0, | ||||
|     _dirty_roles: 0, | ||||
|   }), | ||||
| 
 | ||||
|   getters: { | ||||
|     users(state) { | ||||
|       return state._users.filter((u) => !u.deleted); | ||||
|     }, | ||||
|   }, | ||||
| 
 | ||||
|   actions: { | ||||
|     /** Simply filter all users by ID */ | ||||
|     findUser(userid: string) { | ||||
|       return this._users.find((user) => user.userid === userid); | ||||
|     }, | ||||
| 
 | ||||
|     /** Retrieve user by ID | ||||
|      * @param userid ID of user to retrieve | ||||
|      * @param force If set to true the user is loaded from backend even when a local copy is available | ||||
|      * @returns Retrieved user (Promise) or raise an error | ||||
|      * @throws Probably an AxiosError if loading failed | ||||
|      */ | ||||
|     async getUser(userid: string, force = false) { | ||||
|       const idx = this._users.findIndex((user) => user.userid === userid); | ||||
|       if (force || idx === -1 || isDirty(this._dirty_users)) { | ||||
|         try { | ||||
|           const { data } = await api.get<FG.User>(`/users/${userid}`); | ||||
|           fixUser(data); | ||||
|           if (idx === -1) this._users.push(data); | ||||
|           else this._users[idx] = data; | ||||
|           return data; | ||||
|         } catch (error) { | ||||
|           // Ignore 404, throw all other
 | ||||
|           if (!isAxiosError(error, 404)) throw error; | ||||
|         } | ||||
|       } else { | ||||
|         return this._users[idx]; | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     /** Retrieve list of all users | ||||
|      * @param force If set to true a fresh users list is loaded from backend even when a local copy is available | ||||
|      * @returns Array of retrieved users (Promise) | ||||
|      * @throws Probably an AxiosError if loading failed | ||||
|      */ | ||||
|     async getUsers(force = false) { | ||||
|       if (force || isDirty(this._dirty_users)) { | ||||
|         const { data } = await api.get<FG.User[]>('/users'); | ||||
|         data.forEach(fixUser); | ||||
|         this._users = data; | ||||
|         this._dirty_users = Date.now(); | ||||
|       } | ||||
|       return this._users; | ||||
|     }, | ||||
| 
 | ||||
|     /** Save modifications of user on backend | ||||
|      * @param user Modified user to save | ||||
|      * @throws Probably an AxiosError if request failed (404 = Invalid userid, 400 = Invalid data) | ||||
|      */ | ||||
|     async updateUser(user: FG.User) { | ||||
|       await api.put(`/users/${user.userid}`, user); | ||||
|       // Modifcation accepted by backend
 | ||||
|       // Save modifications back to our users list
 | ||||
|       const idx = this._users.findIndex((u) => u.userid === user.userid); | ||||
|       if (idx > -1) this._users[idx] = user; | ||||
|       // If user was current user, save modifications back to the main store
 | ||||
|       const mainStore = useMainStore(); | ||||
|       if (user.userid === mainStore.user?.userid) mainStore.user = user; | ||||
|     }, | ||||
| 
 | ||||
|     /** Register a new user | ||||
|      * @param user User to register (id not set) | ||||
|      * @returns The registered user (id set) | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async createUser(user: FG.User) { | ||||
|       const { data } = await api.post<FG.User>('/users', user); | ||||
|       this._users.push(<FG.User>fixUser(data)); | ||||
|       return data; | ||||
|     }, | ||||
| 
 | ||||
|     /** Delete an user | ||||
|      * Throws if failed and resolves void if succeed | ||||
|      * | ||||
|      * @param user User or ID of user to delete | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async deleteUser(user: FG.User | string) { | ||||
|       if (typeof user === 'object') user = user.userid; | ||||
| 
 | ||||
|       await api.delete(`/users/${user}`); | ||||
|       this._users = this._users.filter((u) => u.userid != user); | ||||
|     }, | ||||
| 
 | ||||
|     /** Upload an avatar for an user | ||||
|      * Throws if failed and resolves void if succeed | ||||
|      * | ||||
|      * @param user User or ID of user | ||||
|      * @param file Avatar file to upload | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async uploadAvatar(user: FG.User | string, file: string | File) { | ||||
|       if (typeof user === 'object') user = user.userid; | ||||
| 
 | ||||
|       const formData = new FormData(); | ||||
|       formData.append('file', file); | ||||
|       await api.post(`/users/${user}/avatar`, formData, { | ||||
|         headers: { | ||||
|           'Content-Type': 'multipart/form-data', | ||||
|         }, | ||||
|       }); | ||||
|     }, | ||||
| 
 | ||||
|     /** Delete avatar of an user | ||||
|      * @param user User or ID of user | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async deleteAvatar(user: FG.User | string) { | ||||
|       if (typeof user === 'object') user = user.userid; | ||||
| 
 | ||||
|       await api.delete(`/users/${user}/avatar`); | ||||
|     }, | ||||
| 
 | ||||
|     /** Retrieve list of all permissions | ||||
|      * @param force If set to true a fresh list is loaded from backend even when a local copy is available | ||||
|      * @returns Array of retrieved permissions (Promise) | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async getPermissions(force = false) { | ||||
|       if (force || this.permissions.length === 0) { | ||||
|         const { data } = await api.get<FG.Permission[]>('/roles/permissions'); | ||||
|         this.permissions = data; | ||||
|       } | ||||
|       return this.permissions; | ||||
|     }, | ||||
| 
 | ||||
|     /** Retrieve list of all roles | ||||
|      * @param force If set to true a fresh list is loaded from backend even when a local copy is available | ||||
|      * @returns Array of retrieved roles (Promise) | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async getRoles(force = false) { | ||||
|       if (force || isDirty(this._dirty_roles)) { | ||||
|         const { data } = await api.get<FG.Role[]>('/roles'); | ||||
|         this.roles = data; | ||||
|         this._dirty_roles = Date.now(); | ||||
|       } | ||||
|       return this.roles; | ||||
|     }, | ||||
| 
 | ||||
|     /** Save modifications of role on the backend | ||||
|      * @param role role to save | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async updateRole(role: FG.Role) { | ||||
|       await api.put(`/roles/${role.id}`, role); | ||||
| 
 | ||||
|       const idx = this.roles.findIndex((r) => r.id === role.id); | ||||
|       if (idx != -1) this.roles[idx] = role; | ||||
|       else this._dirty_roles = 0; | ||||
|     }, | ||||
| 
 | ||||
|     /** Create a new role | ||||
|      * @param role Role to create (ID not set) | ||||
|      * @returns Created role (ID set) | ||||
|      * @throws Probably an AxiosError if request failed | ||||
|      */ | ||||
|     async newRole(role: FG.Role) { | ||||
|       const { data } = await api.post<FG.Role>('/roles', role); | ||||
|       this.roles.push(data); | ||||
|       return data; | ||||
|     }, | ||||
| 
 | ||||
|     /** Delete a role | ||||
|      * @param role Role or ID of role to delete | ||||
|      * @throws Probably an AxiosError if request failed (409 if role still in use) | ||||
|      */ | ||||
|     async deleteRole(role: FG.Role | number) { | ||||
|       if (typeof role === 'object') role = role.id; | ||||
|       await api.delete(`/roles/${role}`); | ||||
|       this.roles = this.roles.filter((r) => r.id !== role); | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
|  | @ -1,3 +0,0 @@ | |||
| export function clone<T>(o: T): T { | ||||
|   return <T>JSON.parse(JSON.stringify(o)); | ||||
| } | ||||
|  | @ -1,35 +0,0 @@ | |||
| import { LocalStorage, Platform } from 'quasar'; | ||||
| import { Storage } from '@capacitor/storage'; | ||||
| 
 | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
| type PersitentTypes = Date | RegExp | number | boolean | string | object; | ||||
| 
 | ||||
| export class PersistentStorage { | ||||
|   static clear() { | ||||
|     if (Platform.is.capacitor) return Storage.clear(); | ||||
|     else return Promise.resolve(LocalStorage.clear()); | ||||
|   } | ||||
| 
 | ||||
|   static remove(key: string) { | ||||
|     if (Platform.is.capacitor) return Storage.remove({ key: key }); | ||||
|     else return Promise.resolve(LocalStorage.remove(key)); | ||||
|   } | ||||
| 
 | ||||
|   static set(key: string, value: PersitentTypes) { | ||||
|     if (Platform.is.capacitor) return Storage.set({ key, value: JSON.stringify(value) }); | ||||
|     else return Promise.resolve(LocalStorage.set(key, value)); | ||||
|   } | ||||
| 
 | ||||
|   static get<T extends PersitentTypes>(key: string) { | ||||
|     if (Platform.is.capacitor) | ||||
|       return Storage.get({ key }).then((v) => | ||||
|         v.value === null ? null : (JSON.parse(v.value) as T) | ||||
|       ); | ||||
|     else return Promise.resolve(LocalStorage.getItem<T>(key)); | ||||
|   } | ||||
| 
 | ||||
|   static keys() { | ||||
|     if (Platform.is.capacitor) return Storage.keys().then((v) => v.keys); | ||||
|     else return Promise.resolve(LocalStorage.getAllKeys()); | ||||
|   } | ||||
| } | ||||
|  | @ -1,6 +0,0 @@ | |||
| import { api } from '../internal'; | ||||
| 
 | ||||
| export function avatarURL(user: FG.User | string, thumbnail = true) { | ||||
|   if (typeof user === 'object') user = user.userid; | ||||
|   return `${api.defaults?.baseURL || ''}/users/${user}/avatar${thumbnail ? '?thumbnail' : ''}`; | ||||
| } | ||||
|  | @ -1,16 +0,0 @@ | |||
| { | ||||
|   "extends": "@quasar/app/tsconfig-preset", | ||||
|   "target": "esnext", | ||||
|   "compilerOptions": { | ||||
|     "baseUrl": "./", | ||||
|     "lib": [ | ||||
|       "es2020", | ||||
|       "dom" | ||||
|     ], | ||||
|     "types": [ | ||||
|       "@flaschengeist/types", | ||||
|       "@quasar/app", | ||||
|       "node" | ||||
|     ] | ||||
|  } | ||||
| } | ||||
|  | @ -1,4 +1,6 @@ | |||
| /* eslint-env node */ | ||||
| module.exports = { | ||||
|   presets: ['@quasar/babel-preset-app'], | ||||
| }; | ||||
|   presets: [ | ||||
|     '@quasar/babel-preset-app' | ||||
|   ] | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1 @@ | |||
| Subproject commit f245cb8b16c855c059d9170611797028c600696a | ||||
							
								
								
									
										87
									
								
								package.json
								
								
								
								
							
							
						
						|  | @ -2,45 +2,42 @@ | |||
|   "private": true, | ||||
|   "license": "MIT", | ||||
|   "version": "2.0.0-alpha.1", | ||||
|   "productName": "flaschengeist-frontend", | ||||
|   "name": "flaschengeist", | ||||
|   "productName": "Flaschengeist", | ||||
|   "name": "flaschengeist-frontend", | ||||
|   "author": "Tim Gröger <flaschengeist@wu5.de>", | ||||
|   "homepage": "https://flaschengeist.dev/Flaschengeist", | ||||
|   "description": "Modular student club administration system", | ||||
|   "bugs": { | ||||
|     "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" | ||||
|     "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist-frontend/issues" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "format": "prettier --config ./package.json  --write '{,!(node_modules|dist|.*)/**/}*.{js,ts,vue}'", | ||||
|     "lint": "eslint --ext .js,.ts,.vue ./src ./api" | ||||
|     "format": "prettier --config ./package.json  --write '{,!(node_modules)/**/}*.ts'", | ||||
|     "lint": "eslint --ext .js,.ts,.vue ./src" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@flaschengeist/api": "file:./api", | ||||
|     "@flaschengeist/users": "^1.0.0-alpha.3", | ||||
|     "axios": "^0.24.0", | ||||
|     "pinia": "^2.0.6", | ||||
|     "quasar": "^2.3.3" | ||||
|     "axios": "^0.21.1", | ||||
|     "cordova": "^10.0.0", | ||||
|     "pinia": "^2.0.0-alpha.10", | ||||
|     "quasar": "^2.0.0-beta.12", | ||||
|     "vuedraggable": "^4.0.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@capacitor/core": "^3.3.2", | ||||
|     "@capacitor/storage": "^1.2.3", | ||||
|     "@flaschengeist/types": "^1.0.0-alpha.10", | ||||
|     "@quasar/app": "^3.2.4", | ||||
|     "@quasar/extras": "^1.12.2", | ||||
|     "@types/node": "^14.18.0", | ||||
|     "@types/webpack": "^5.28.0", | ||||
|     "@types/webpack-env": "^1.16.3", | ||||
|     "@typescript-eslint/eslint-plugin": "^5.5.0", | ||||
|     "@typescript-eslint/parser": "^5.5.0", | ||||
|     "eslint": "^8.4.0", | ||||
|     "eslint-config-prettier": "^8.3.0", | ||||
|     "eslint-plugin-prettier": "^4.0.0", | ||||
|     "eslint-plugin-vue": "^8.1.1", | ||||
|     "eslint-webpack-plugin": "^3.1.1", | ||||
|     "modify-source-webpack-plugin": "^3.0.0", | ||||
|     "prettier": "^2.5.1", | ||||
|     "typescript": "^4.5.2", | ||||
|     "vuedraggable": "^4.1.0" | ||||
|     "@quasar/app": "^3.0.0-beta.13", | ||||
|     "@quasar/extras": "^1.10.2", | ||||
|     "@quasar/quasar-app-extension-qcalendar": "file:deps/quasar-ui-qcalendar/app-extension", | ||||
|     "@types/node": "^12.20.7", | ||||
|     "@types/webpack": "^4.41.27", | ||||
|     "@types/webpack-env": "^1.16.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^4.20.0", | ||||
|     "@typescript-eslint/parser": "^4.20.0", | ||||
|     "electron": "^12.0.4", | ||||
|     "electron-packager": "^14.1.1", | ||||
|     "eslint": "^7.23.0", | ||||
|     "eslint-config-prettier": "^8.1.0", | ||||
|     "eslint-plugin-vue": "^7.8.0", | ||||
|     "eslint-webpack-plugin": "^2.5.3", | ||||
|     "prettier": "^2.2.1", | ||||
|     "typescript": "^4.2.3" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "singleQuote": true, | ||||
|  | @ -48,25 +45,19 @@ | |||
|     "printWidth": 100, | ||||
|     "arrowParens": "always" | ||||
|   }, | ||||
|   "browserslist": { | ||||
|     "defaults": [ | ||||
|       "Firefox esr", | ||||
|       "last 6 Chrome versions", | ||||
|       "last 4 Firefox versions", | ||||
|       "last 4 Edge versions", | ||||
|       "last 4 Safari versions", | ||||
|       "last 4 ChromeAndroid versions", | ||||
|       "last 1 FirefoxAndroid versions" | ||||
|     ], | ||||
|     "cordova": [ | ||||
|       "iOS >= 13.0", | ||||
|       "Android >= 76", | ||||
|       "ChromeAndroid >= 76" | ||||
|     ] | ||||
|   }, | ||||
|   "browserslist": [ | ||||
|     "last 10 Chrome versions", | ||||
|     "last 10 Firefox versions", | ||||
|     "last 4 Edge versions", | ||||
|     "last 4 Safari versions", | ||||
|     "last 8 Android versions", | ||||
|     "last 1 ChromeAndroid versions", | ||||
|     "last 1 FirefoxAndroid versions", | ||||
|     "last 6 iOS versions" | ||||
|   ], | ||||
|   "engines": { | ||||
|     "node": ">= 14.18.1", | ||||
|     "npm": ">= 6.14.12", | ||||
|     "yarn": ">= 1.22.0" | ||||
|     "node": ">= 12.0.0", | ||||
|     "npm": ">= 6.13.4", | ||||
|     "yarn": ">= 1.21.1" | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +0,0 @@ | |||
| // You can add your plugins here
 | ||||
| module.exports = [ | ||||
| //  '@flaschengeist/balance',
 | ||||
| //  '@flaschengeist/schedule',
 | ||||
| //  '@flaschengeist/pricelist',
 | ||||
| ] | ||||
							
								
								
									
										101
									
								
								quasar.conf.js
								
								
								
								
							
							
						
						|  | @ -8,12 +8,12 @@ | |||
| 
 | ||||
| /* eslint-env node */ | ||||
| /* eslint-disable @typescript-eslint/no-var-requires */ | ||||
| const ESLintPlugin = require('eslint-webpack-plugin'); | ||||
| const { ModifySourcePlugin } = require('modify-source-webpack-plugin'); | ||||
| const ESLintPlugin = require('eslint-webpack-plugin') | ||||
| const { configure } = require('quasar/wrappers'); | ||||
|   | ||||
| module.exports = configure(function (/* ctx */) { | ||||
|   return { | ||||
|     // https://quasar.dev/quasar-cli/supporting-ts
 | ||||
|     // https://quasar.dev/quasar-cli/supporting-ts
 | ||||
|     supportTS: { | ||||
|       tsCheckerConfig: { | ||||
|  | @ -21,7 +21,7 @@ module.exports = configure(function (/* ctx */) { | |||
|           enabled: true, | ||||
|           files: './src/**/*.{ts,tsx,js,jsx,vue}', | ||||
|         }, | ||||
|       }, | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     // https://quasar.dev/quasar-cli/prefetch-feature
 | ||||
|  | @ -30,7 +30,7 @@ module.exports = configure(function (/* ctx */) { | |||
|     // app boot file (/src/boot)
 | ||||
|     // --> boot files are part of "main.js"
 | ||||
|     // https://quasar.dev/quasar-cli/boot-files
 | ||||
|     boot: ['axios', 'store', 'plugins', 'login', 'init'], | ||||
|     boot: ['axios', 'store', 'plugins', 'loading', 'login'], | ||||
| 
 | ||||
|     // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
 | ||||
|     css: ['app.scss'], | ||||
|  | @ -39,10 +39,10 @@ module.exports = configure(function (/* ctx */) { | |||
|     extras: [ | ||||
|       // 'eva-icons',
 | ||||
|       // 'fontawesome-v5',
 | ||||
|       // 'ionicons-v5',
 | ||||
|       // 'ionicons-v4',
 | ||||
|       // 'line-awesome',
 | ||||
|       // 'material-icons',
 | ||||
|       'mdi-v6', | ||||
|       'mdi-v5', | ||||
|       // 'themify',
 | ||||
|    | ||||
|       // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
 | ||||
|  | @ -60,8 +60,10 @@ module.exports = configure(function (/* ctx */) { | |||
|       // Applies only if "transpile" is set to true.
 | ||||
|       // transpileDependencies: [],
 | ||||
| 
 | ||||
|       // rtl: false,
 | ||||
| 
 | ||||
|       // rtl: false, // https://quasar.dev/options/rtl-support
 | ||||
|       // preloadChunks: true,
 | ||||
|       // showProgress: false,
 | ||||
|       // gzip: true,
 | ||||
|       // analyze: true,
 | ||||
| 
 | ||||
|       // Options below are automatically set depending on the env, set them if you want to override
 | ||||
|  | @ -69,59 +71,34 @@ module.exports = configure(function (/* ctx */) { | |||
| 
 | ||||
|       // https://quasar.dev/quasar-cli/handling-webpack
 | ||||
|       // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
 | ||||
|       chainWebpack(chain) { | ||||
|         chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [ | ||||
|           { | ||||
|             extensions: ['ts', 'js', 'vue'], | ||||
|             exclude: ['node_modules', 'src-capacitor'], | ||||
|           }, | ||||
|         ]); | ||||
|         chain.plugin('modify-source-webpack-plugin').use(ModifySourcePlugin, [ | ||||
|           { | ||||
|             rules: [ | ||||
|               { | ||||
|                 test: /plugins\.ts$/, | ||||
|                 modify: (src, filename) => { | ||||
|                   const custom_plgns = require('./plugin.config.js'); | ||||
|                   const required_plgns = require('./src/vendor-plugin.config.js'); | ||||
|                   return src.replace( | ||||
|                     /\/\* *INSERT_PLUGIN_LIST *\*\//, | ||||
|                     [...custom_plgns, ...required_plgns] | ||||
|                       .map((v) => `import("${v}").catch(() => "${v}")`) | ||||
|                       .join(',') | ||||
|                   ); | ||||
|                 }, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|         ]); | ||||
|         chain.merge({ | ||||
|           snapshot: { | ||||
|             managedPaths: [], | ||||
|           }, | ||||
|         }); | ||||
|       }, | ||||
|       chainWebpack (chain) { | ||||
|         chain.plugin('eslint-webpack-plugin') | ||||
|           .use(ESLintPlugin, [{ | ||||
|             extensions: [ 'ts', 'js', 'vue' ], | ||||
|             exclude: 'node_modules' | ||||
|           }]) | ||||
|         }, | ||||
|        | ||||
|     }, | ||||
| 
 | ||||
|     // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
 | ||||
|     devServer: { | ||||
|       https: false, | ||||
|       port: 8080, | ||||
|       open: false, // opens browser window automatically
 | ||||
|       watchFiles: { paths: ['/node_modules/@flaschengeist/**/*'] }, | ||||
|       open: false // opens browser window automatically
 | ||||
|     }, | ||||
| 
 | ||||
|     // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
 | ||||
|     framework: { | ||||
|       iconSet: 'mdi-v6', // Quasar icon set
 | ||||
|       iconSet: 'mdi-v5', // Quasar icon set
 | ||||
|       lang: 'de', // Quasar language pack
 | ||||
|       config: { | ||||
|         dark: 'auto', | ||||
|         loadingBar: { | ||||
|           position: 'top', | ||||
|           color: 'warning', | ||||
|           size: '5px', | ||||
|         }, | ||||
|           size: '5px' | ||||
|         } | ||||
|       }, | ||||
| 
 | ||||
|       // For special cases outside of where the auto-import stategy can have an impact
 | ||||
|  | @ -132,7 +109,13 @@ module.exports = configure(function (/* ctx */) { | |||
|       // directives: [],
 | ||||
| 
 | ||||
|       // Quasar plugins
 | ||||
|       plugins: ['LocalStorage', 'SessionStorage', 'Dialog', 'Loading', 'Notify', 'LoadingBar'], | ||||
|       plugins: [ | ||||
|         'LocalStorage', | ||||
|         'SessionStorage', | ||||
|         'Loading', | ||||
|         'Notify', | ||||
|         'LoadingBar' | ||||
|       ] | ||||
|     }, | ||||
| 
 | ||||
|     // animations: 'all', // --- includes all animations
 | ||||
|  | @ -141,7 +124,7 @@ module.exports = configure(function (/* ctx */) { | |||
| 
 | ||||
|     // https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr
 | ||||
|     ssr: { | ||||
|       pwa: false, | ||||
|       pwa: false | ||||
|     }, | ||||
| 
 | ||||
|     // https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa
 | ||||
|  | @ -160,20 +143,20 @@ module.exports = configure(function (/* ctx */) { | |||
|           { | ||||
|             src: 'flaschengeist-logo.svg', | ||||
|             sizes: 'any', | ||||
|             type: 'image/svg+xml', | ||||
|             type: 'image/svg+xml' | ||||
|           }, | ||||
|           { | ||||
|             src: 'favicon-128x128.png', | ||||
|             sizes: '128x128', | ||||
|             type: 'image/png', | ||||
|             type: 'image/png' | ||||
|           }, | ||||
|           { | ||||
|             src: 'favicon-256x256.png', | ||||
|             sizes: '256x256', | ||||
|             type: 'image/png', | ||||
|             type: 'image/png' | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     // Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
 | ||||
|  | @ -183,7 +166,7 @@ module.exports = configure(function (/* ctx */) { | |||
| 
 | ||||
|     // Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
 | ||||
|     capacitor: { | ||||
|       hideSplashscreen: true, | ||||
|       hideSplashscreen: true | ||||
|     }, | ||||
| 
 | ||||
|     // Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
 | ||||
|  | @ -192,11 +175,13 @@ module.exports = configure(function (/* ctx */) { | |||
| 
 | ||||
|       packager: { | ||||
|         // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
 | ||||
| 
 | ||||
|         // OS X / Mac App Store
 | ||||
|         // appBundleId: '',
 | ||||
|         // appCategoryType: '',
 | ||||
|         // osxSign: '',
 | ||||
|         // protocol: 'myapp://path',
 | ||||
| 
 | ||||
|         // Windows only
 | ||||
|         // win32metadata: { ... }
 | ||||
|       }, | ||||
|  | @ -204,16 +189,16 @@ module.exports = configure(function (/* ctx */) { | |||
|       builder: { | ||||
|         // https://www.electron.build/configuration/configuration
 | ||||
| 
 | ||||
|         appId: 'flaschengeist-frontend', | ||||
|         appId: 'flaschengeist-frontend' | ||||
|       }, | ||||
| 
 | ||||
|       // More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration
 | ||||
|       nodeIntegration: true, | ||||
| 
 | ||||
|       extendWebpack(/* cfg */) { | ||||
|       extendWebpack (/* cfg */) { | ||||
|         // do something with Electron main process Webpack cfg
 | ||||
|         // chainWebpack also available besides this extendWebpack
 | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,3 @@ | |||
| { | ||||
|   "@quasar/qcalendar": {} | ||||
| } | ||||
|  | @ -1,10 +0,0 @@ | |||
| { | ||||
|   "appId": "dev.flaschengeist", | ||||
|   "appName": "flaschengeist-frontend", | ||||
|   "bundledWebRuntime": false, | ||||
|   "npmClient": "yarn", | ||||
|   "webDir": "www", | ||||
|   "ios": { | ||||
|     "allowsLinkPreview": false | ||||
|   } | ||||
| } | ||||
|  | @ -1,16 +0,0 @@ | |||
| { | ||||
|   "name": "flaschengeist", | ||||
|   "version": "2.0.0-alpha.1", | ||||
|   "description": "Modular student club administration system", | ||||
|   "author": "Tim Gröger <flaschengeist@wu5.de>", | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@capacitor/android": "^3.3.2", | ||||
|     "@capacitor/app": "^1.0.0", | ||||
|     "@capacitor/cli": "^3.0.0", | ||||
|     "@capacitor/core": "^3.0.0", | ||||
|     "@capacitor/ios": "^3.0.0-beta.0", | ||||
|     "@capacitor/splash-screen": "^1.0.0", | ||||
|     "@capacitor/storage": "^1.2.3" | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,8 @@ | |||
| .DS_Store | ||||
| 
 | ||||
| # Generated by package manager | ||||
| node_modules/ | ||||
| 
 | ||||
| # Generated by Cordova | ||||
| /plugins/ | ||||
| /platforms/ | ||||
|  | @ -0,0 +1,76 @@ | |||
| <?xml version='1.0' encoding='utf-8'?> | ||||
| <widget id="de.wu5.flaschengeist" version="2.0.0-alpha.1" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0"> | ||||
|     <name>Flaschengeist</name> | ||||
|     <description>Modular student club administration system</description> | ||||
|     <author email="dev@cordova.apache.org" href="http://cordova.io"> | ||||
|         Apache Cordova Team | ||||
|     </author> | ||||
|     <content src="index.html" /> | ||||
|     <access origin="*" /> | ||||
|     <allow-intent href="http://*/*" /> | ||||
|     <allow-intent href="https://*/*" /> | ||||
|     <allow-intent href="tel:*" /> | ||||
|     <allow-intent href="sms:*" /> | ||||
|     <allow-intent href="mailto:*" /> | ||||
|     <allow-intent href="geo:*" /> | ||||
|     <platform name="android"> | ||||
|         <allow-intent href="market:*" /> | ||||
|         <icon density="ldpi" src="res/android/ldpi.png" /> | ||||
|         <icon density="mdpi" src="res/android/mdpi.png" /> | ||||
|         <icon density="hdpi" src="res/android/hdpi.png" /> | ||||
|         <icon density="xhdpi" src="res/android/xhdpi.png" /> | ||||
|         <icon density="xxhdpi" src="res/android/xxhdpi.png" /> | ||||
|         <icon density="xxxhdpi" src="res/android/xxxhdpi.png" /> | ||||
|         <splash density="land-ldpi" src="res/screen/android/splash-land-ldpi.png" /> | ||||
|         <splash density="port-ldpi" src="res/screen/android/splash-port-ldpi.png" /> | ||||
|         <splash density="land-mdpi" src="res/screen/android/splash-land-mdpi.png" /> | ||||
|         <splash density="port-mdpi" src="res/screen/android/splash-port-mdpi.png" /> | ||||
|         <splash density="land-hdpi" src="res/screen/android/splash-land-hdpi.png" /> | ||||
|         <splash density="port-hdpi" src="res/screen/android/splash-port-hdpi.png" /> | ||||
|         <splash density="land-xhdpi" src="res/screen/android/splash-land-xhdpi.png" /> | ||||
|         <splash density="port-xhdpi" src="res/screen/android/splash-port-xhdpi.png" /> | ||||
|         <splash density="land-xxhdpi" src="res/screen/android/splash-land-xxhdpi.png" /> | ||||
|         <splash density="port-xxhdpi" src="res/screen/android/splash-port-xxhdpi.png" /> | ||||
|         <splash density="land-xxxhdpi" src="res/screen/android/splash-land-xxxhdpi.png" /> | ||||
|         <splash density="port-xxxhdpi" src="res/screen/android/splash-port-xxxhdpi.png" /> | ||||
|     </platform> | ||||
|     <platform name="ios"> | ||||
|         <allow-intent href="itms:*" /> | ||||
|         <allow-intent href="itms-apps:*" /> | ||||
|         <icon height="57" src="res/ios/icon.png" width="57" /> | ||||
|         <icon height="114" src="res/ios/icon@2x.png" width="114" /> | ||||
|         <icon height="40" src="res/ios/icon-20@2x.png" width="40" /> | ||||
|         <icon height="60" src="res/ios/icon-20@3x.png" width="60" /> | ||||
|         <icon height="29" src="res/ios/icon-29.png" width="29" /> | ||||
|         <icon height="58" src="res/ios/icon-29@2x.png" width="58" /> | ||||
|         <icon height="87" src="res/ios/icon-29@3x.png" width="87" /> | ||||
|         <icon height="80" src="res/ios/icon-40@2x.png" width="80" /> | ||||
|         <icon height="120" src="res/ios/icon-60@2x.png" width="120" /> | ||||
|         <icon height="180" src="res/ios/icon-60@3x.png" width="180" /> | ||||
|         <icon height="20" src="res/ios/icon-20.png" width="20" /> | ||||
|         <icon height="40" src="res/ios/icon-40.png" width="40" /> | ||||
|         <icon height="50" src="res/ios/icon-50.png" width="50" /> | ||||
|         <icon height="100" src="res/ios/icon-50@2x.png" width="100" /> | ||||
|         <icon height="72" src="res/ios/icon-72.png" width="72" /> | ||||
|         <icon height="144" src="res/ios/icon-72@2x.png" width="144" /> | ||||
|         <icon height="76" src="res/ios/icon-76.png" width="76" /> | ||||
|         <icon height="152" src="res/ios/icon-76@2x.png" width="152" /> | ||||
|         <icon height="167" src="res/ios/icon-83.5@2x.png" width="167" /> | ||||
|         <icon height="1024" src="res/ios/icon-1024.png" width="1024" /> | ||||
|         <icon height="48" src="res/ios/icon-24@2x.png" width="48" /> | ||||
|         <icon height="55" src="res/ios/icon-27.5@2x.png" width="55" /> | ||||
|         <icon height="88" src="res/ios/icon-44@2x.png" width="88" /> | ||||
|         <icon height="172" src="res/ios/icon-86@2x.png" width="172" /> | ||||
|         <icon height="196" src="res/ios/icon-98@2x.png" width="196" /> | ||||
|         <splash src="res/screen/ios/Default@2x~iphone~anyany.png" /> | ||||
|         <splash src="res/screen/ios/Default@2x~iphone~comany.png" /> | ||||
|         <splash src="res/screen/ios/Default@2x~iphone~comcom.png" /> | ||||
|         <splash src="res/screen/ios/Default@3x~iphone~anyany.png" /> | ||||
|         <splash src="res/screen/ios/Default@3x~iphone~anycom.png" /> | ||||
|         <splash src="res/screen/ios/Default@3x~iphone~comany.png" /> | ||||
|         <splash src="res/screen/ios/Default@2x~ipad~anyany.png" /> | ||||
|         <splash src="res/screen/ios/Default@2x~ipad~comany.png" /> | ||||
|     </platform> | ||||
|     <allow-navigation href="about:*" /> | ||||
|     <preference name="SplashMaintainAspectRatio" value="true" /> | ||||
| </widget> | ||||
|  | @ -0,0 +1,10 @@ | |||
| /* eslint-disable */ | ||||
| // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
 | ||||
| //  REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
 | ||||
| import "quasar/dist/types/feature-flag"; | ||||
| 
 | ||||
| declare module "quasar/dist/types/feature-flag" { | ||||
|   interface QuasarFeatureFlags { | ||||
|     cordova: true; | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,31 @@ | |||
| { | ||||
|   "name": "de.wu5.flaschengeist", | ||||
|   "displayName": "Flaschengeist", | ||||
|   "version": "1.0.0", | ||||
|   "description": "A sample Apache Cordova application that responds to the deviceready event.", | ||||
|   "main": "index.js", | ||||
|   "scripts": { | ||||
|     "test": "echo \"Error: no test specified\" && exit 1" | ||||
|   }, | ||||
|   "keywords": [ | ||||
|     "ecosystem:cordova" | ||||
|   ], | ||||
|   "author": "Apache Cordova Team", | ||||
|   "license": "Apache-2.0", | ||||
|   "devDependencies": { | ||||
|     "cordova-android": "^9.0.0", | ||||
|     "cordova-ios": "^6.1.1", | ||||
|     "cordova-plugin-splashscreen": "^6.0.0", | ||||
|     "cordova-plugin-whitelist": "^1.3.4" | ||||
|   }, | ||||
|   "cordova": { | ||||
|     "plugins": { | ||||
|       "cordova-plugin-whitelist": {}, | ||||
|       "cordova-plugin-splashscreen": {} | ||||
|     }, | ||||
|     "platforms": [ | ||||
|       "ios", | ||||
|       "android" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| After Width: | Height: | Size: 2.1 KiB | 
| After Width: | Height: | Size: 913 B | 
| After Width: | Height: | Size: 1.4 KiB | 
| After Width: | Height: | Size: 2.9 KiB | 
| After Width: | Height: | Size: 4.3 KiB | 
| After Width: | Height: | Size: 5.4 KiB | 
| After Width: | Height: | Size: 15 KiB | 
| After Width: | Height: | Size: 370 B | 
| After Width: | Height: | Size: 702 B | 
| After Width: | Height: | Size: 1022 B | 
| After Width: | Height: | Size: 801 B | 
| After Width: | Height: | Size: 969 B | 
| After Width: | Height: | Size: 529 B | 
| After Width: | Height: | Size: 1015 B | 
| After Width: | Height: | Size: 1.5 KiB | 
| After Width: | Height: | Size: 702 B | 
| After Width: | Height: | Size: 1.3 KiB | 
| After Width: | Height: | Size: 1.6 KiB | 
| After Width: | Height: | Size: 885 B | 
| After Width: | Height: | Size: 1.6 KiB | 
| After Width: | Height: | Size: 1.9 KiB | 
| After Width: | Height: | Size: 2.7 KiB | 
| After Width: | Height: | Size: 1.1 KiB | 
| After Width: | Height: | Size: 2.1 KiB | 
| After Width: | Height: | Size: 1.3 KiB | 
| After Width: | Height: | Size: 2.4 KiB | 
| After Width: | Height: | Size: 2.6 KiB | 
| After Width: | Height: | Size: 3.5 KiB | 
| After Width: | Height: | Size: 3.2 KiB | 
| After Width: | Height: | Size: 996 B | 
| After Width: | Height: | Size: 1.9 KiB | 
| After Width: | Height: | Size: 5.3 KiB | 
| After Width: | Height: | Size: 4.1 KiB | 
| After Width: | Height: | Size: 5.6 KiB | 
| After Width: | Height: | Size: 5.9 KiB | 
| After Width: | Height: | Size: 6.5 KiB | 
| After Width: | Height: | Size: 10 KiB | 
| After Width: | Height: | Size: 6.0 KiB | 
| After Width: | Height: | Size: 4.2 KiB | 
| After Width: | Height: | Size: 4.0 KiB | 
| After Width: | Height: | Size: 5.1 KiB | 
| After Width: | Height: | Size: 6.1 KiB | 
| After Width: | Height: | Size: 10 KiB | 
| After Width: | Height: | Size: 16 KiB | 
| After Width: | Height: | Size: 9.9 KiB | 
| After Width: | Height: | Size: 9.3 KiB | 
| After Width: | Height: | Size: 5.4 KiB | 
| After Width: | Height: | Size: 12 KiB | 
| After Width: | Height: | Size: 16 KiB | 
| After Width: | Height: | Size: 9.7 KiB | 
| After Width: | Height: | Size: 8.5 KiB | 
|  | @ -1,9 +1,9 @@ | |||
| /* eslint-disable */ | ||||
| // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
 | ||||
| //  REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
 | ||||
| import 'quasar/dist/types/feature-flag'; | ||||
| import "quasar/dist/types/feature-flag"; | ||||
| 
 | ||||
| declare module 'quasar/dist/types/feature-flag' { | ||||
| declare module "quasar/dist/types/feature-flag" { | ||||
|   interface QuasarFeatureFlags { | ||||
|     electron: true; | ||||
|   } | ||||
|  |  | |||
|  | @ -1,77 +1,28 @@ | |||
| /** | ||||
|  * This boot file registers interceptors for axios | ||||
|  */ | ||||
| import { useMainStore, api } from '@flaschengeist/api'; | ||||
| import { AxiosError } from 'axios'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import config from 'src/config'; | ||||
| import { clone } from '@flaschengeist/api'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import { LocalStorage, Notify } from 'quasar'; | ||||
| import axios, { AxiosError } from 'axios'; | ||||
| import { useMainStore } from 'src/stores'; | ||||
| 
 | ||||
| /** | ||||
|  * Minify data sent to backend server | ||||
|  * | ||||
|  * Drop unneeded entities which can be identified by ID. | ||||
|  * | ||||
|  * @param obj Object to minify | ||||
|  * @param cloned If this entity is already cloned (JSON En+Decoded) | ||||
|  * @returns Minified object (some types are converted, like a Date object is now a ISO string) | ||||
|  */ | ||||
| function minify(entity: unknown, cloned = false) { | ||||
|   if (!cloned) entity = clone(entity); | ||||
| 
 | ||||
|   if (typeof entity === 'object') { | ||||
|     const obj = entity as { [index: string]: unknown }; | ||||
| 
 | ||||
|     for (const prop in obj) { | ||||
|       if (obj.hasOwnProperty(prop) && !!obj[prop]) { | ||||
|         if (Array.isArray(obj[prop])) { | ||||
|           // eslint-disable-next-line @typescript-eslint/no-unsafe-return
 | ||||
|           obj[prop] = (<Array<unknown>>obj[prop]).map((v) => minify(v, true)); | ||||
|         } else if ( | ||||
|           typeof obj[prop] === 'object' && | ||||
|           Object.keys(<object>obj[prop]).includes('id') && | ||||
|           typeof (<{ id: unknown }>obj[prop])['id'] === 'number' && | ||||
|           !isNaN((<{ id: number }>obj[prop])['id']) | ||||
|         ) { | ||||
|           obj[prop] = (<{ id: unknown }>obj[prop])['id']; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return obj; | ||||
|   } | ||||
|   return entity; | ||||
| } | ||||
| const api = axios.create(); | ||||
| 
 | ||||
| export default boot(({ router }) => { | ||||
|   // Persisted value is read in plugins.ts boot file!
 | ||||
|   if (api.defaults.baseURL === undefined) api.defaults.baseURL = config.baseURL; | ||||
|   api.defaults.baseURL = LocalStorage.getItem<string>('baseURL') || config.baseURL; | ||||
| 
 | ||||
|   /*** | ||||
|    * Intercept requests | ||||
|    *   - insert Token if available | ||||
|    *   - minify JSON requests | ||||
|    * Intercept requests and insert Token if available | ||||
|    */ | ||||
|   api.interceptors.request.use((config) => { | ||||
|     const store = useMainStore(); | ||||
|     if (store.session?.token) { | ||||
|       config.headers = Object.assign(config.headers || {}, { | ||||
|         Authorization: `Bearer ${store.session.token}`, | ||||
|       }); | ||||
|       config.headers = { Authorization: 'Bearer ' + store.session.token }; | ||||
|     } | ||||
|     // Minify JSON requests
 | ||||
|     if ( | ||||
|       !!config.data && | ||||
|       (config.headers === undefined || | ||||
|         config.headers['Content-Type'] === undefined || | ||||
|         config.headers['Content-Type'] === 'application/json') | ||||
|     ) | ||||
|       config.data = minify(config.data); | ||||
|     return config; | ||||
|   }); | ||||
| 
 | ||||
|   /*** | ||||
|    * Intercept responses | ||||
|    *   - filter 401 --> handleLoggedOut | ||||
|    *   - filter 401 --> logout | ||||
|    *   - filter timeout or 502-504 --> backendOffline | ||||
|    */ | ||||
|   api.interceptors.response.use( | ||||
|  | @ -94,11 +45,12 @@ export default boot(({ router }) => { | |||
|             query: { redirect: next }, | ||||
|           }); | ||||
|         } else if (e.response && e.response.status == 401) { | ||||
|           store.handleLoggedOut(); | ||||
|           if (current.name != 'login') { | ||||
|           void store.logout(); | ||||
|           if (current.name !== 'login') { | ||||
|             await router.push({ | ||||
|               name: 'login', | ||||
|               query: { redirect: current.fullPath }, | ||||
|               params: { logout: 'logout' }, | ||||
|               query: { redirect: current.path }, | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|  | @ -107,3 +59,19 @@ export default boot(({ router }) => { | |||
|     } | ||||
|   ); | ||||
| }); | ||||
| 
 | ||||
| export { api }; | ||||
| 
 | ||||
| export const setBaseURL = (url: string) => { | ||||
|   LocalStorage.set('baseURL', url); | ||||
|   api.defaults.baseURL = url; | ||||
|   Notify.create({ | ||||
|     message: 'Serveraddresse gespeichert', | ||||
|     position: 'bottom', | ||||
|     caption: `${url}`, | ||||
|     color: 'positive', | ||||
|   }); | ||||
|   setTimeout(() => { | ||||
|     window.location.reload(); | ||||
|   }, 5000); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,87 +0,0 @@ | |||
| /** | ||||
|  * This boot file initalizes the store from persistent storage and load all plugins | ||||
|  */ | ||||
| import { | ||||
|   PersistentStorage, | ||||
|   api, | ||||
|   isAxiosError, | ||||
|   saveSession, | ||||
|   useMainStore, | ||||
| } from '@flaschengeist/api'; | ||||
| import { Notify, Platform } from 'quasar'; | ||||
| import { loadPlugins } from './plugins'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import routes from 'src/router/routes'; | ||||
| 
 | ||||
| async function loadBaseUrl() { | ||||
|   try { | ||||
|     const url = await PersistentStorage.get<string>('baseURL'); | ||||
|     if (url !== null) api.defaults.baseURL = url; | ||||
|   } catch (e) { | ||||
|     console.warn('Could not load BaseURL', e); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class BackendError extends Error {} | ||||
| 
 | ||||
| /** | ||||
|  * Loading backend information | ||||
|  * @returns Backend object or null | ||||
|  */ | ||||
| async function getBackend() { | ||||
|   const { data } = await api.get<FG.Backend>('/'); | ||||
|   if (!data || typeof data !== 'object' || !('plugins' in data)) | ||||
|     throw new BackendError('Invalid backend response received'); | ||||
|   return data; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Boot file for loading baseURL + Session from PersistentStorage + loading and initializing all plugins | ||||
|  */ | ||||
| export default boot(async ({ app, router }) => { | ||||
|   const store = useMainStore(); | ||||
| 
 | ||||
|   // FIRST(!) get the base URL
 | ||||
|   await loadBaseUrl(); | ||||
| 
 | ||||
|   // Init the store, load current session and user, if available
 | ||||
|   try { | ||||
|     await store.init(); | ||||
|   } finally { | ||||
|     // Any changes on the session is written back to the persistent store
 | ||||
|     store.$subscribe((mutation, state) => { | ||||
|       saveSession(state.session); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   // Load all plugins
 | ||||
|   try { | ||||
|     // Fetch backend data
 | ||||
|     const backend = await getBackend(); | ||||
|     // Load enabled plugins
 | ||||
|     const flaschengeist = await loadPlugins(backend, routes); | ||||
|     // Add loaded routes to router
 | ||||
|     flaschengeist.routes.forEach((route) => router.addRoute(route)); | ||||
|     // save plugins in VM-variable
 | ||||
|     app.provide('flaschengeist', flaschengeist); | ||||
|   } catch (error) { | ||||
|     // Handle errors from loading the backend information
 | ||||
|     if (error instanceof BackendError || isAxiosError(error)) { | ||||
|       router.isReady().finally(() => { | ||||
|         if (Platform.is.capacitor) void router.push({ name: 'setup_backend' }); | ||||
|         else void router.push({ name: 'offline', params: { refresh: 1 } }); | ||||
|       }); | ||||
|     } else if (typeof error === 'string') { | ||||
|       // Handle plugin not found errors
 | ||||
|       void router.push({ name: 'error' }); | ||||
|       Notify.create({ | ||||
|         type: 'negative', | ||||
|         message: `Fehler beim Laden: Bitte wende dich an den Admin (${error})!`, | ||||
|         timeout: 10000, | ||||
|         progress: true, | ||||
|       }); | ||||
|     } else { | ||||
|       console.error('Unknown error in init.ts:', error); | ||||
|     } | ||||
|   } | ||||
| }); | ||||
|  | @ -0,0 +1,14 @@ | |||
| import { boot } from 'quasar/wrappers'; | ||||
| import { Loading } from 'quasar'; | ||||
| //import DarkCircularProgress from 'components/loading/DarkCircularProgress.vue';
 | ||||
| 
 | ||||
| // "async" is optional;
 | ||||
| // more info on params: https://quasar.dev/quasar-cli/cli-documentation/boot-files#Anatomy-of-a-boot-file
 | ||||
| export default boot(() => { | ||||
|   Loading.setDefaults({ | ||||
|     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 | ||||
|     // @ts-ignore
 | ||||
|     // spinner: DarkCircularProgress,
 | ||||
|     // TODO : Das funktioniert wohl erstmal nicht mehr... gibt ne exception
 | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,33 +1,43 @@ | |||
| /** | ||||
|  * This boot file registers login / authentification related axios interceptors | ||||
|  */ | ||||
| import { useMainStore, hasPermissions } from '@flaschengeist/api'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import { useMainStore } from 'src/stores'; | ||||
| import { hasPermissions } from 'src/utils/permission'; | ||||
| import { RouteRecord } from 'vue-router'; | ||||
| 
 | ||||
| export default boot(({ router }) => { | ||||
|   /** | ||||
|    * Login guard | ||||
|    * Check if user tries to access the secured area and validates token | ||||
|    */ | ||||
|   router.beforeEach((to, from) => { | ||||
|   router.beforeResolve((to, from, next) => { | ||||
|     const store = useMainStore(); | ||||
| 
 | ||||
|     // Skip loops
 | ||||
|     if (to.name == 'login' && from.name == 'login') return false; | ||||
|     if (to.path == from.path) return next(); | ||||
| 
 | ||||
|     // Secured area '/in/...' requires to be authenticated
 | ||||
|     if (to.path.startsWith('/in') && (!store.session || store.session.expires <= new Date())) { | ||||
|       store.handleLoggedOut(); | ||||
|       return { name: 'login' }; | ||||
|     if (to.path.startsWith('/main')) { | ||||
|       // Secured area (LOGIN REQUIRED)
 | ||||
|       // Check login is ok
 | ||||
|       if (!store.session || store.session.expires <= new Date()) { | ||||
|         void store.logout(); | ||||
|         return next({ name: 'login', query: { redirect: to.fullPath } }); | ||||
|       } | ||||
| 
 | ||||
|       // Check if special permissions are required
 | ||||
|       if ( | ||||
|         to.matched.every((record: RouteRecord) => { | ||||
|           if (!('meta' in record) || !('permissions' in record.meta)) return true; | ||||
|           if ((<{ permissions: FG.Permission[] }>record.meta).permissions) { | ||||
|             return hasPermissions((<{ permissions: FG.Permission[] }>record.meta).permissions); | ||||
|           } | ||||
|         }) | ||||
|       ) { | ||||
|         return next(); | ||||
|       } else { | ||||
|         return next({ name: 'login', query: { redirect: to.fullPath } }); | ||||
|       } | ||||
|     } else { | ||||
|       if (to.name == 'login' && store.user && !to.params['logout']) { | ||||
|         // Called login while already logged in
 | ||||
|         return next({ name: 'dashboard' }); | ||||
|       } else { | ||||
|         // We are on the non secured area
 | ||||
|         return next(); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   /** | ||||
|    * Permission guard | ||||
|    * Check permissions for route, cancel navigation on errors | ||||
|    */ | ||||
|   router.beforeResolve((to) => { | ||||
|     if (!!to.meta.permissions && !hasPermissions(<FG.Permission[]>to.meta.permissions)) | ||||
|       return false; | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,25 +1,60 @@ | |||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| import routes from 'src/router/routes'; | ||||
| import { api } from 'boot/axios'; | ||||
| import { AxiosResponse } from 'axios'; | ||||
| import { RouteRecordRaw } from 'vue-router'; | ||||
| import { Notify } from 'quasar'; | ||||
| 
 | ||||
| const config: { [key: string]: Array<string> } = { | ||||
|   // Do not change required Modules !!
 | ||||
|   requiredModules: ['User'], | ||||
|   // here you can import plugins.
 | ||||
|   loadModules: ['Balance', 'Schedule', 'Pricelist'], | ||||
| }; | ||||
| 
 | ||||
| /* Stop! | ||||
| 
 | ||||
| // do not change anything here !!
 | ||||
| 
 | ||||
| // You can not even read? I said stop!
 | ||||
| 
 | ||||
| // Really are you stupid? Stop scrolling down here!
 | ||||
| 
 | ||||
| // Every line you scroll down, an unicorn will die painfully!
 | ||||
| 
 | ||||
| // Ok you must hate unicorns... But what if I say you I joked... Baby otters will die!
 | ||||
| 
 | ||||
|          .-"""-. | ||||
|         /      o\ | ||||
|        |    o   0).-. | ||||
|        |       .-;(_/     .-. | ||||
|         \     /  /)).---._|  `\   , | ||||
|          '.  '  /((       `'-./ _/|
 | ||||
|            \  .'  )        .-.;`  /
 | ||||
|             '.             |  `\-' | ||||
|               '._        -'    / | ||||
|                  ``""--`------` | ||||
| */ | ||||
| 
 | ||||
| /**************************************************** | ||||
|  ******** Internal area for some magic ************** | ||||
|  ****************************************************/ | ||||
| 
 | ||||
| declare type ImportPlgn = { default: FG_Plugin.Plugin }; | ||||
| 
 | ||||
| function validatePlugin(plugin: FG_Plugin.Plugin) { | ||||
|   return ( | ||||
|     typeof plugin.name === 'string' && | ||||
|     typeof plugin.id === 'string' && | ||||
|     plugin.id.length > 0 && | ||||
|     typeof plugin.version === 'string' | ||||
|   ); | ||||
| interface BackendPlugin { | ||||
|   permissions: string[]; | ||||
|   version: string; | ||||
| } | ||||
| 
 | ||||
| // Here does some magic happens, WebPack will automatically replace the following comment with the import statements
 | ||||
| const PLUGINS = <Array<Promise<ImportPlgn>>>[ | ||||
|   /*INSERT_PLUGIN_LIST*/ | ||||
| ]; | ||||
| interface BackendPlugins { | ||||
|   [key: string]: BackendPlugin; | ||||
| } | ||||
| 
 | ||||
| interface Backend { | ||||
|   plugins: BackendPlugins; | ||||
|   version: string; | ||||
| } | ||||
| export { Backend }; | ||||
| 
 | ||||
| // Handle Notifications
 | ||||
| export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note; | ||||
|  | @ -186,67 +221,109 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo | |||
| /** | ||||
|  * Load a Flaschengeist plugin | ||||
|  * @param loadedPlugins Flaschgeist object | ||||
|  * @param plugin Plugin to load | ||||
|  * @param pluginName Plugin to load | ||||
|  * @param context RequireContext of plugins | ||||
|  * @param router VueRouter instance | ||||
|  */ | ||||
| function loadPlugin( | ||||
|   loadedPlugins: FG_Plugin.Flaschengeist, | ||||
|   plugin: FG_Plugin.Plugin, | ||||
|   backend: FG.Backend | ||||
|   pluginName: string, | ||||
|   context: __WebpackModuleApi.RequireContext, | ||||
|   backend: Backend | ||||
| ) { | ||||
|   // Check if already loaded
 | ||||
|   if (loadedPlugins.plugins.findIndex((p) => p.id === plugin.id) !== -1) return true; | ||||
|   if (loadedPlugins.plugins.findIndex((p) => p.name === pluginName) !== -1) return true; | ||||
| 
 | ||||
|   // Check backend dependencies
 | ||||
|   if ( | ||||
|     !plugin.requiredModules.every( | ||||
|       (required) => | ||||
|         backend.plugins[required[0]] !== undefined && | ||||
|         (required.length == 1 || | ||||
|           true) /* validate the version, semver440 from python is... tricky on node*/ | ||||
|     ) | ||||
|   ) { | ||||
|     console.error(`Plugin ${plugin.id}: Backend modules not satisfied`); | ||||
|   // Search if plugin is installed
 | ||||
|   const available = context.keys(); | ||||
|   const plugin = available.includes(`./${pluginName.toLowerCase()}/plugin.ts`) | ||||
|     ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
 | ||||
|       <FG_Plugin.Plugin>context(`./${pluginName.toLowerCase()}/plugin.ts`).default | ||||
|     : undefined; | ||||
| 
 | ||||
|   if (!plugin) { | ||||
|     // Plugin is not found, results in an error
 | ||||
|     console.exception(`Could not find required Plugin ${pluginName}`); | ||||
|     return false; | ||||
|   } else { | ||||
|     // Plugin found. Check backend dependencies
 | ||||
|     if ( | ||||
|       !plugin.requiredBackendModules.every((required) => backend.plugins[required] !== undefined) | ||||
|     ) { | ||||
|       console.error(`Plugin ${pluginName}: Backend modules not satisfied`); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     // Check frontend dependencies
 | ||||
|     if ( | ||||
|       !plugin.requiredModules.every((required) => | ||||
|         loadPlugin(loadedPlugins, required, context, backend) | ||||
|       ) | ||||
|     ) { | ||||
|       console.error(`Plugin ${pluginName}: Backend modules not satisfied`); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     // Start combining and loading routes, shortcuts etc
 | ||||
|     if (plugin.internalRoutes) { | ||||
|       combineRoutes(loadedPlugins.routes, plugin.internalRoutes, '/in'); | ||||
|     } | ||||
| 
 | ||||
|     if (plugin.innerRoutes) { | ||||
|       // Routes for Vue Router
 | ||||
|       combineMenuRoutes(loadedPlugins.routes, plugin.innerRoutes, '/in'); | ||||
|       // Combine links for menu
 | ||||
|       plugin.innerRoutes.forEach((route) => combineMenuLinks(loadedPlugins.menuLinks, route)); | ||||
|       // Combine shortcuts
 | ||||
|       combineShortcuts(loadedPlugins.shortcuts, plugin.innerRoutes); | ||||
|     } | ||||
| 
 | ||||
|     if (plugin.outerRoutes) { | ||||
|       combineMenuRoutes(loadedPlugins.routes, plugin.outerRoutes); | ||||
|       combineShortcuts(loadedPlugins.outerShortcuts, plugin.outerRoutes); | ||||
|     } | ||||
| 
 | ||||
|     if (plugin.widgets.length > 0) { | ||||
|       plugin.widgets.forEach((widget) => (widget.name = plugin.name + '_' + widget.name)); | ||||
|       Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets); | ||||
|     } | ||||
| 
 | ||||
|     loadedPlugins.plugins.push({ | ||||
|       name: plugin.name, | ||||
|       version: plugin.version, | ||||
|       notification: plugin.notification?.bind({}) || translateNotification, | ||||
|     }); | ||||
| 
 | ||||
|     return plugin; | ||||
|   } | ||||
| 
 | ||||
|   // Start combining and loading routes, shortcuts etc
 | ||||
|   if (plugin.internalRoutes) { | ||||
|     combineRoutes(loadedPlugins.routes, plugin.internalRoutes, '/in'); | ||||
|   } | ||||
| 
 | ||||
|   if (plugin.innerRoutes) { | ||||
|     // Routes for Vue Router
 | ||||
|     combineMenuRoutes(loadedPlugins.routes, plugin.innerRoutes, '/in'); | ||||
|     // Combine links for menu
 | ||||
|     plugin.innerRoutes.forEach((route) => combineMenuLinks(loadedPlugins.menuLinks, route)); | ||||
|     // Combine shortcuts
 | ||||
|     combineShortcuts(loadedPlugins.shortcuts, plugin.innerRoutes); | ||||
|   } | ||||
| 
 | ||||
|   if (plugin.outerRoutes) { | ||||
|     combineMenuRoutes(loadedPlugins.routes, plugin.outerRoutes); | ||||
|     combineShortcuts(loadedPlugins.outerShortcuts, plugin.outerRoutes); | ||||
|   } | ||||
| 
 | ||||
|   if (plugin.widgets.length > 0) { | ||||
|     plugin.widgets.forEach((widget) => (widget.name = plugin.id + '.' + widget.name)); | ||||
|     Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets); | ||||
|   } | ||||
| 
 | ||||
|   loadedPlugins.plugins.push({ | ||||
|     id: plugin.id, | ||||
|     name: plugin.name, | ||||
|     version: plugin.version, | ||||
|     notification: plugin.notification?.bind({}) || translateNotification, | ||||
|   }); | ||||
| 
 | ||||
|   return true; | ||||
| } | ||||
| 
 | ||||
| export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRaw[]) { | ||||
| /** | ||||
|  * Loading backend information | ||||
|  * @returns Backend object or null | ||||
|  */ | ||||
| async function getBackend() { | ||||
|   try { | ||||
|     const { data }: AxiosResponse<Backend> = await api.get('/'); | ||||
|     return data; | ||||
|   } catch (e) { | ||||
|     console.warn(e); | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Boot file, load all required plugins, check for dependencies | ||||
|  */ | ||||
| export default boot(async ({ router, app }) => { | ||||
|   const backend = await getBackend(); | ||||
|   if (backend === null) { | ||||
|     void router.push({ name: 'error' }); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const loadedPlugins: FG_Plugin.Flaschengeist = { | ||||
|     routes: baseRoutes, | ||||
|     routes, | ||||
|     plugins: [], | ||||
|     menuLinks: [], | ||||
|     shortcuts: [], | ||||
|  | @ -254,35 +331,47 @@ export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRa | |||
|     widgets: [], | ||||
|   }; | ||||
| 
 | ||||
|   // Wait for all plugins to be loaded
 | ||||
|   const results = await Promise.allSettled(PLUGINS); | ||||
|   // get all plugins
 | ||||
|   const pluginsContext = require.context('src/plugins', true, /.+\/plugin.ts$/); | ||||
| 
 | ||||
|   // Check if loaded successfully
 | ||||
|   results.forEach((result) => { | ||||
|     if (result.status === 'rejected') { | ||||
|       throw <string>result.reason; | ||||
|     } else { | ||||
|       if ( | ||||
|         !( | ||||
|           validatePlugin(result.value.default) && | ||||
|           loadPlugin(loadedPlugins, result.value.default, backend) | ||||
|         ) | ||||
|       ) | ||||
|         throw result.value.default.id; | ||||
|   // Start loading plugins
 | ||||
|   // Load required modules, if not found or error when loading this will forward the user to the error page
 | ||||
|   config.requiredModules.forEach((required) => { | ||||
|     const plugin = loadPlugin(loadedPlugins, required, pluginsContext, backend); | ||||
|     if (!plugin) { | ||||
|       void router.push({ name: 'error' }); | ||||
|       return; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   // Sort widgets by priority
 | ||||
|   /** @todo Remove priority with first beta */ | ||||
|   loadedPlugins.widgets.sort( | ||||
|     (a, b) => <number>(b.order || b.priority) - <number>(a.order || a.priority) | ||||
|   ); | ||||
| 
 | ||||
|   /** @todo Can be cleaned up with first beta */ | ||||
|   loadedPlugins.menuLinks.sort((a, b) => { | ||||
|     const diff = a.order && b.order ? b.order - a.order : 0; | ||||
|     return diff ? diff : a.title.toString().localeCompare(b.title.toString()); | ||||
|   // Load user defined plugins
 | ||||
|   // If there is an error with loading a plugin, the user will get informed.
 | ||||
|   const failed: string[] = []; | ||||
|   config.loadModules.forEach((required) => { | ||||
|     const plugin = loadPlugin(loadedPlugins, required, pluginsContext, backend); | ||||
|     if (!plugin) { | ||||
|       failed.push(required); | ||||
|     } | ||||
|   }); | ||||
|   if (failed.length > 0) { | ||||
|     // Log failed plugins
 | ||||
|     console.error('Could not load all plugins', failed); | ||||
|     // Inform user about error
 | ||||
|     Notify.create({ | ||||
|       type: 'negative', | ||||
|       message: | ||||
|         'Fehler beim Laden: Nicht alle Funktionen stehen zur Verfügung. Bitte wende dich an den Admin!', | ||||
|       timeout: 10000, | ||||
|       progress: true, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   return loadedPlugins; | ||||
| } | ||||
|   // Sort widgets by priority
 | ||||
|   loadedPlugins.widgets.sort((a, b) => b.priority - a.priority); | ||||
| 
 | ||||
|   // Add loaded routes to router
 | ||||
|   loadedPlugins.routes.forEach((route) => router.addRoute(route)); | ||||
| 
 | ||||
|   // save plugins in VM-variable
 | ||||
|   app.provide('flaschengeist', loadedPlugins); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,9 +1,14 @@ | |||
| /** | ||||
|  * This boot file installs the global pinia instance | ||||
|  */ | ||||
| import { pinia } from '@flaschengeist/api'; | ||||
| import { createPinia, Pinia } from 'pinia'; | ||||
| import { boot } from 'quasar/wrappers'; | ||||
| import { useMainStore } from 'src/stores'; | ||||
| import { ref } from 'vue'; | ||||
| 
 | ||||
| export const pinia = ref<Pinia>(); | ||||
| 
 | ||||
| export default boot(({ app }) => { | ||||
|   app.use(pinia); | ||||
|   pinia.value = createPinia(); | ||||
|   app.use(pinia.value); | ||||
| 
 | ||||
|   const store = useMainStore(); | ||||
|   void store.init(); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,59 +1,41 @@ | |||
| <template> | ||||
|   <q-card | ||||
|     bordered | ||||
|     class="row q-ma-xs q-pa-xs" | ||||
|     style="position: relative; min-height: 3em" | ||||
|     :class="{ 'cursor-pointer': modelValue.link }" | ||||
|     @click="click" | ||||
|   > | ||||
|     <div class="col-12 text-weight-light">{{ dateString }}</div> | ||||
|     <div :id="`ntfctn-${modelValue.id}`" class="col-12">{{ modelValue.text }}</div> | ||||
|     <q-btn | ||||
|       round | ||||
|       dense | ||||
|       icon="mdi-trash-can" | ||||
|       icon="mdi-close" | ||||
|       size="sm" | ||||
|       color="negative" | ||||
|       class="q-ma-xs" | ||||
|       title="Löschen" | ||||
|       style="position: absolute; top: 0; right: 0; z-index: 999" | ||||
|       @click="dismiss" | ||||
|       style="position: absolute; top: 0; right: 0" | ||||
|       @click="remove" | ||||
|     /> | ||||
|     <q-btn | ||||
|       v-if="modelValue.accept !== undefined" | ||||
|       round | ||||
|       dense | ||||
|       icon="mdi-check" | ||||
|       size="sm" | ||||
|       color="positive" | ||||
|       class="q-ma-xs" | ||||
|       style="position: absolute; top: 0; right: 3em" | ||||
|       @click="accept" | ||||
|     /> | ||||
|     <q-card-section class="q-pa-xs"> | ||||
|       <div class="text-overline">{{ dateString }}</div> | ||||
|       <q-item style="padding: 1px"> | ||||
|         <q-item-section v-if="modelValue.icon" side | ||||
|           ><q-icon color="primary" :name="modelValue.icon" | ||||
|         /></q-item-section> | ||||
|         <q-item-section>{{ modelValue.text }}</q-item-section> | ||||
|       </q-item> | ||||
|     </q-card-section> | ||||
|     <q-card-actions v-if="modelValue.reject || modelValue.accept"> | ||||
|       <q-btn | ||||
|         v-if="modelValue.accept" | ||||
|         icon="mdi-check" | ||||
|         color="positive" | ||||
|         label="Annehmen" | ||||
|         flat | ||||
|         dense | ||||
|         size="sm" | ||||
|         @click="accept" | ||||
|       /> | ||||
|       <q-btn | ||||
|         v-if="modelValue.reject" | ||||
|         icon="mdi-close" | ||||
|         color="negative" | ||||
|         label="Ablehnen" | ||||
|         flat | ||||
|         dense | ||||
|         size="sm" | ||||
|         @click="reject" | ||||
|       /> | ||||
|     </q-card-actions> | ||||
|   </q-card> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, PropType, computed } from 'vue'; | ||||
| import { formatDateTime } from '@flaschengeist/api'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import { formatDateTime } from 'src/utils/datetime'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| import { useRouter } from 'vue-router'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|  | @ -81,19 +63,13 @@ export default defineComponent({ | |||
|       else emit('remove', props.modelValue.id); | ||||
|     } | ||||
| 
 | ||||
|     function reject() { | ||||
|     function remove() { | ||||
|       if (typeof props.modelValue.reject === 'function') | ||||
|         void props.modelValue.reject().finally(() => emit('remove', props.modelValue.id)); | ||||
|       else emit('remove', props.modelValue.id); | ||||
|     } | ||||
| 
 | ||||
|     function dismiss() { | ||||
|       if (typeof props.modelValue.dismiss === 'function') | ||||
|         void props.modelValue.dismiss().finally(() => emit('remove', props.modelValue.id)); | ||||
|       else emit('remove', props.modelValue.id); | ||||
|     } | ||||
| 
 | ||||
|     return { accept, click, dateString, dismiss, reject }; | ||||
|     return { accept, click, dateString, remove }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  |  | |||
|  | @ -1,39 +1,33 @@ | |||
| <template> | ||||
|   <q-expansion-item | ||||
|     v-if="isGranted(entry)" | ||||
|     clickable | ||||
|     :label="getTitle(entry)" | ||||
|     :icon="entry.icon" | ||||
|     expand-separator | ||||
|   > | ||||
|     <q-list class="q-ml-lg"> | ||||
|       <div v-for="child in entry.children" :key="child.link"> | ||||
|         <q-item v-if="isGranted(child)" clickable :to="{ name: child.link }"> | ||||
|           <q-menu context-menu> | ||||
|             <q-btn v-close-popup label="Verknüpfung erstellen" dense @click="addShortCut(child)" /> | ||||
|           </q-menu> | ||||
|           <q-item-section avatar> | ||||
|             <q-icon :name="child.icon" /> | ||||
|           </q-item-section> | ||||
|           <q-item-section> | ||||
|             <q-item-label> | ||||
|               {{ getTitle(child) }} | ||||
|             </q-item-label> | ||||
|           </q-item-section> | ||||
|         </q-item> | ||||
|       </div> | ||||
|     </q-list> | ||||
|   <q-expansion-item v-if="isGranted(entry)" clickable tag="a" target="self" :label='title' :icon='entry.icon' expand-separator> | ||||
|       <q-list class='q-ml-lg'> | ||||
|         <div v-for='child in entry.children' :key='child.link'> | ||||
|     <q-item v-if='isGranted(child)' clickable :to='{name: child.link}'> | ||||
|       <q-menu context-menu> | ||||
|         <q-btn v-close-popup label='Verknüpfung erstellen' dense @click='addShortCut(child)'/> | ||||
|       </q-menu> | ||||
|       <q-item-section avatar> | ||||
|         <q-icon :name='child.icon' /> | ||||
|       </q-item-section> | ||||
|       <q-item-section> | ||||
|         <q-item-label> | ||||
|           {{child.title}} | ||||
|         </q-item-label> | ||||
|       </q-item-section> | ||||
|     </q-item> | ||||
|         </div> | ||||
|       </q-list> | ||||
|   </q-expansion-item> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, PropType } from 'vue'; | ||||
| import { hasPermissions } from '@flaschengeist/api'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import { computed, defineComponent, PropType } from 'vue'; | ||||
| import { hasPermissions } from 'src/utils/permission'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'EssentialExpansionLink', | ||||
|   components: {}, | ||||
|   components: {  }, | ||||
|   props: { | ||||
|     entry: { | ||||
|       type: Object as PropType<FG_Plugin.MenuLink>, | ||||
|  | @ -43,19 +37,17 @@ export default defineComponent({ | |||
|   emits: { | ||||
|     addShortCut: (val: FG_Plugin.MenuLink) => val.link, | ||||
|   }, | ||||
|   setup(_, { emit }) { | ||||
|     function isGranted(val: FG_Plugin.MenuLink) { | ||||
|       return hasPermissions(val.permissions || []); | ||||
|     } | ||||
|     function getTitle(entry: FG_Plugin.MenuLink) { | ||||
|       return typeof entry.title === 'function' ? entry.title() : entry.title; | ||||
|     } | ||||
|   setup(props, {emit}) { | ||||
| 
 | ||||
|     function isGranted(val: FG_Plugin.MenuLink) { return hasPermissions(val.permissions || [])}; | ||||
|     const title = computed(() => | ||||
|       typeof props.entry.title === 'object' ? props.entry.title.value : props.entry.title | ||||
|     ); | ||||
|     function addShortCut(val: FG_Plugin.MenuLink) { | ||||
|       emit('addShortCut', val); | ||||
|       emit('addShortCut', val) | ||||
|     } | ||||
| 
 | ||||
|     return { isGranted, getTitle, addShortCut }; | ||||
|     return { isGranted, title, addShortCut}; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  |  | |||
|  | @ -12,8 +12,8 @@ | |||
| 
 | ||||
| <script lang="ts"> | ||||
| import { computed, defineComponent, PropType } from 'vue'; | ||||
| import { hasPermissions } from '@flaschengeist/api'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import { hasPermissions } from 'src/utils/permission'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'EssentialLink', | ||||
|  | @ -27,7 +27,7 @@ export default defineComponent({ | |||
|   setup(props) { | ||||
|     const isGranted = computed(() => hasPermissions(props.entry.permissions || [])); | ||||
|     const title = computed(() => | ||||
|       typeof props.entry.title === 'function' ? props.entry.title() : props.entry.title | ||||
|       typeof props.entry.title === 'object' ? props.entry.title.value : props.entry.title | ||||
|     ); | ||||
|     return { isGranted, title }; | ||||
|   }, | ||||
|  |  | |||
|  | @ -8,8 +8,8 @@ | |||
| 
 | ||||
| <script lang="ts"> | ||||
| import { computed, defineComponent, PropType } from 'vue'; | ||||
| import { hasPermissions } from '@flaschengeist/api'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import { hasPermissions } from 'src/utils/permission'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'ShortcutLink', | ||||
|  |  | |||
|  | @ -40,7 +40,7 @@ | |||
| <script lang="ts"> | ||||
| import { computed, defineComponent, PropType } from 'vue'; | ||||
| import { date as q_date } from 'quasar'; | ||||
| import { stringIsDate, stringIsTime, stringIsDateTime, Validator } from '..'; | ||||
| import { stringIsDate, stringIsTime, stringIsDateTime, Validator } from 'src/utils/validators'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'IsoDateInput', | ||||
|  | @ -54,7 +54,7 @@ export default defineComponent({ | |||
|     label: { type: String, default: 'Datum' }, | ||||
|     readonly: Boolean, | ||||
|     rules: { | ||||
|       type: Array as PropType<Validator<Date>[]>, | ||||
|       type: Array as PropType<Validator[]>, | ||||
|       default: () => [], | ||||
|     }, | ||||
|   }, | ||||
|  | @ -62,22 +62,7 @@ export default defineComponent({ | |||
|   setup(props, { emit, attrs }) { | ||||
|     const customRules = computed(() => [ | ||||
|       props.type == 'date' ? stringIsDate : props.type == 'time' ? stringIsTime : stringIsDateTime, | ||||
|       (value?: string) => { | ||||
|         if (props.rules.length > 0 && !!value) { | ||||
|           let date: Date | undefined = undefined; | ||||
|           if (props.type == 'date') date = modifyDate(value); | ||||
|           else if (props.type == 'time') date = modifyTime(value); | ||||
|           else { | ||||
|             const split = value.split(' '); | ||||
|             date = modifyTime(split[1], modifyDate(split[0])); | ||||
|           } | ||||
|           for (const rule of props.rules) { | ||||
|             const r = rule(date); | ||||
|             if (typeof r === 'string') return r; | ||||
|           } | ||||
|           return true; | ||||
|         } | ||||
|       }, | ||||
|       ...props.rules, | ||||
|     ]); | ||||
| 
 | ||||
|     const clearable = computed(() => | ||||
|  | @ -1,4 +1,4 @@ | |||
| import { computed } from 'vue'; | ||||
| import {computed} from 'vue'; | ||||
| import { LocalStorage } from 'quasar'; | ||||
| 
 | ||||
| const config = { | ||||
|  | @ -6,7 +6,9 @@ const config = { | |||
|   pollingInterval: 30000, | ||||
| }; | ||||
| 
 | ||||
| const baseURL = computed(() => LocalStorage.getItem<string>('baseURL') || config.baseURL); | ||||
| const baseURL = computed(() => | ||||
| LocalStorage.getItem<string>('baseURL') || config.baseURL | ||||
| ); | ||||
| 
 | ||||
| export { baseURL }; | ||||
| export {baseURL} | ||||
| export default config; | ||||
|  |  | |||
|  | @ -0,0 +1,137 @@ | |||
| declare namespace FG { | ||||
|   interface Notification { | ||||
|     id: number; | ||||
|     plugin: string; | ||||
|     text: string; | ||||
|     data?: unknown; | ||||
|     time: Date; | ||||
|   } | ||||
|   interface User { | ||||
|     userid: string; | ||||
|     display_name: string; | ||||
|     firstname: string; | ||||
|     lastname: string; | ||||
|     mail: string; | ||||
|     birthday?: Date; | ||||
|     roles: Array<string>; | ||||
|     permissions?: Array<string>; | ||||
|     avatar_url?: string; | ||||
|   } | ||||
|   interface Session { | ||||
|     expires: Date; | ||||
|     token: string; | ||||
|     lifetime: number; | ||||
|     browser: string; | ||||
|     platform: string; | ||||
|     userid: string; | ||||
|   } | ||||
|   type Permission = string; | ||||
|   interface Role { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     permissions: Array<Permission>; | ||||
|   } | ||||
|   interface Transaction { | ||||
|     id: number; | ||||
|     time: Date; | ||||
|     amount: number; | ||||
|     reversal_id?: number; | ||||
|     author_id?: string; | ||||
|     sender_id?: string; | ||||
|     original_id?: number; | ||||
|     receiver_id?: string; | ||||
|   } | ||||
|   interface Event { | ||||
|     id: number; | ||||
|     start: Date; | ||||
|     end?: Date; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     type: EventType | number; | ||||
|     is_template: boolean; | ||||
|     jobs: Array<Job>; | ||||
|   } | ||||
|   interface EventType { | ||||
|     id: number; | ||||
|     name: string; | ||||
|   } | ||||
|   interface Invite { | ||||
|     id: number; | ||||
|     job_id: number; | ||||
|     invitee_id: string; | ||||
|     sender_id: string; | ||||
|   } | ||||
|   interface Job { | ||||
|     id: number; | ||||
|     start: Date; | ||||
|     end?: Date; | ||||
|     type: JobType | number; | ||||
|     comment?: string; | ||||
|     services: Array<Service>; | ||||
|     required_services: number; | ||||
|   } | ||||
|   interface JobType { | ||||
|     id: number; | ||||
|     name: string; | ||||
|   } | ||||
|   interface Service { | ||||
|     userid: string; | ||||
|     is_backup: boolean; | ||||
|     value: number; | ||||
|   } | ||||
|   interface Drink { | ||||
|     id: number; | ||||
|     article_id?: string; | ||||
|     package_size?: number; | ||||
|     name: string; | ||||
|     volume?: number; | ||||
|     cost_per_volume?: number; | ||||
|     cost_per_package?: number; | ||||
|     tags?: Array<Tag>; | ||||
|     type?: DrinkType; | ||||
|     volumes: Array<DrinkPriceVolume>; | ||||
|     uuid: string; | ||||
|     receipt?: Array<string>; | ||||
|   } | ||||
|   interface DrinkIngredient { | ||||
|     id: number; | ||||
|     volume: number; | ||||
|     ingredient_id: number; | ||||
|   } | ||||
|   interface DrinkPrice { | ||||
|     id: number; | ||||
|     price: number; | ||||
|     public: boolean; | ||||
|     description?: string; | ||||
|   } | ||||
|   interface DrinkPriceVolume { | ||||
|     id: number; | ||||
|     volume: number; | ||||
|     min_prices: Array<MinPrices>; | ||||
|     prices: Array<DrinkPrice>; | ||||
|     ingredients: Array<Ingredient>; | ||||
|   } | ||||
|   interface DrinkType { | ||||
|     id: number; | ||||
|     name: string; | ||||
|   } | ||||
|   interface ExtraIngredient { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     price: number; | ||||
|   } | ||||
|   interface Ingredient { | ||||
|     id: number; | ||||
|     drink_ingredient?: DrinkIngredient; | ||||
|     extra_ingredient?: ExtraIngredient; | ||||
|   } | ||||
|   interface MinPrices { | ||||
|     percentage: number; | ||||
|     price: number; | ||||
|   } | ||||
|   interface Tag { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     color: string; | ||||
|   } | ||||
| } | ||||
|  | @ -18,7 +18,7 @@ | |||
|           <q-badge color="negative" floating> | ||||
|             {{ notifications.length }} | ||||
|           </q-badge> | ||||
|           <q-menu max-height="400px" style="min-width: 290px" class="q-pa-xs"> | ||||
|           <q-menu style="max-height: 400px; overflow: auto"> | ||||
|             <q-btn | ||||
|               v-if="useNative && noPermission" | ||||
|               label="Benachrichtigungen erlauben" | ||||
|  | @ -40,14 +40,7 @@ | |||
|             <shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" /> | ||||
|           </template> | ||||
|         </drag> | ||||
|         <q-btn | ||||
|           v-if="!platform.is.capacitor" | ||||
|           flat | ||||
|           round | ||||
|           dense | ||||
|           icon="mdi-exit-to-app" | ||||
|           @click="logout()" | ||||
|         /> | ||||
|         <q-btn flat round dense icon="mdi-exit-to-app" @click="logout()" /> | ||||
|       </q-toolbar> | ||||
|     </q-header> | ||||
| 
 | ||||
|  | @ -59,30 +52,20 @@ | |||
|       @click.capture="openMenu" | ||||
|     > | ||||
|       <!-- Plugins --> | ||||
|       <essential-expansion-link | ||||
|         v-for="(entry, index) in mainLinks" | ||||
|         :key="'plugin' + index" | ||||
|         :entry="entry" | ||||
|         @add-short-cut="addShortcut" | ||||
|       /> | ||||
|       <q-list> | ||||
|         <essential-expansion-link | ||||
|           v-for="(entry, index) in mainLinks" | ||||
|           :key="'plugin' + index" | ||||
|           :entry="entry" | ||||
|           @add-short-cut="addShortcut" | ||||
|         /> | ||||
|       </q-list> | ||||
|       <q-separator /> | ||||
|       <essential-link | ||||
|         v-for="(entry, index) in essentials" | ||||
|         :key="'essential' + index" | ||||
|         :entry="entry" | ||||
|       /> | ||||
|       <div v-if="platform.is.capacitor"> | ||||
|         <q-separator /> | ||||
|         <q-item clickable tag="a" target="self" @click="logout"> | ||||
|           <q-item-section avatar> | ||||
|             <q-icon name="mdi-exit-to-app" /> | ||||
|           </q-item-section> | ||||
| 
 | ||||
|           <q-item-section> | ||||
|             <q-item-label>Logout</q-item-label> | ||||
|           </q-item-section> | ||||
|         </q-item> | ||||
|       </div> | ||||
|     </q-drawer> | ||||
|     <q-page-container> | ||||
|       <router-view /> | ||||
|  | @ -91,18 +74,26 @@ | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue'; | ||||
| import EssentialLink from 'src/components/navigation/EssentialLink.vue'; | ||||
| import ShortcutLink from 'src/components/navigation/ShortcutLink.vue'; | ||||
| import Notification from 'src/components/Notification.vue'; | ||||
| import { defineComponent, ref, inject, computed, onBeforeMount, onBeforeUnmount } from 'vue'; | ||||
| import { Screen, Platform } from 'quasar'; | ||||
| import config from 'src/config'; | ||||
| import { | ||||
|   defineComponent, | ||||
|   ref, | ||||
|   inject, | ||||
|   computed, | ||||
|   onBeforeMount, | ||||
|   onBeforeUnmount, | ||||
|   ComponentPublicInstance, | ||||
| } from 'vue'; | ||||
| import { useMainStore } from 'src/stores'; | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| import { useRouter } from 'vue-router'; | ||||
| import { useMainStore } from '@flaschengeist/api'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import drag from 'vuedraggable'; | ||||
| 
 | ||||
| import { Screen } from 'quasar'; | ||||
| import config from 'src/config'; | ||||
| import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue'; | ||||
| import draggable from 'vuedraggable'; | ||||
| const drag: ComponentPublicInstance = <ComponentPublicInstance>draggable; | ||||
| const essentials: FG_Plugin.MenuLink[] = [ | ||||
|   { | ||||
|     title: 'Über Flaschengeist', | ||||
|  | @ -113,18 +104,12 @@ const essentials: FG_Plugin.MenuLink[] = [ | |||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'MainLayout', | ||||
|   components: { | ||||
|     EssentialExpansionLink, | ||||
|     EssentialLink, | ||||
|     ShortcutLink, | ||||
|     Notification, | ||||
|     drag, | ||||
|   }, | ||||
|   components: { EssentialExpansionLink, EssentialLink, ShortcutLink, Notification, drag }, | ||||
|   setup() { | ||||
|     const router = useRouter(); | ||||
|     const mainStore = useMainStore(); | ||||
|     const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist'); | ||||
|     const leftDrawer = ref(!Platform.is.mobile); | ||||
|     const leftDrawer = ref(true); | ||||
|     const leftDrawerMini = ref(false); | ||||
|     const mainLinks = flaschengeist?.menuLinks || []; | ||||
|     const notifications = computed(() => mainStore.notifications.slice().reverse()); | ||||
|  | @ -220,7 +205,6 @@ export default defineComponent({ | |||
|       shortCuts, | ||||
|       addShortcut, | ||||
|       deleteShortcut, | ||||
|       platform: Platform, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
|  |  | |||
|  | @ -25,8 +25,8 @@ | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { FG_Plugin } from 'src/plugins'; | ||||
| import { defineComponent, inject } from 'vue'; | ||||
| import { FG_Plugin } from '@flaschengeist/types'; | ||||
| import ShortcutLink from 'components/navigation/ShortcutLink.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|  |  | |||