Compare commits

..

12 Commits

190 changed files with 21961 additions and 1340 deletions

View File

@ -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'
}
}

3
.gitignore vendored
View File

@ -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

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "deps/quasar-ui-qcalendar"]
path = deps/quasar-ui-qcalendar
url = https://github.com/susnux/quasar-ui-qcalendar
branch = quasar2

View File

@ -3,6 +3,6 @@
module.exports = {
plugins: [
// to edit target browsers: use "browserslist" field in package.json
require('autoprefixer'),
],
};
require('autoprefixer')
]
}

View File

@ -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
View File

@ -1,88 +1,62 @@
# Flaschengeist (frontend)
![status-badge](http://os-sc.org:8000/api/badges/ferfissimo/flaschengeist-frontend/status.svg)
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`.

View File

@ -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>

View File

@ -1,5 +0,0 @@
import IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue';
import UserAvatar from './UserAvatar.vue';
export { IsoDateInput, PasswordInput, UserAvatar };

View File

@ -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';

View File

@ -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"
}
}

6
api/shims-vue.d.ts vendored
View File

@ -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;
}

View File

@ -1,6 +0,0 @@
import axios from 'axios';
import { createPinia } from 'pinia';
export const api = axios.create();
export const pinia = createPinia();

View File

@ -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';

View File

@ -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;
}
},
},
});

View File

@ -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);
},
},
});

View File

@ -1,3 +0,0 @@
export function clone<T>(o: T): T {
return <T>JSON.parse(JSON.stringify(o));
}

View File

@ -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());
}
}

View File

@ -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' : ''}`;
}

View File

@ -1,16 +0,0 @@
{
"extends": "@quasar/app/tsconfig-preset",
"target": "esnext",
"compilerOptions": {
"baseUrl": "./",
"lib": [
"es2020",
"dom"
],
"types": [
"@flaschengeist/types",
"@quasar/app",
"node"
]
}
}

View File

@ -1,4 +1,6 @@
/* eslint-env node */
module.exports = {
presets: ['@quasar/babel-preset-app'],
};
presets: [
'@quasar/babel-preset-app'
]
}

1
deps/quasar-ui-qcalendar vendored Submodule

@ -0,0 +1 @@
Subproject commit f245cb8b16c855c059d9170611797028c600696a

View File

@ -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",
"browserslist": [
"last 10 Chrome versions",
"last 10 Firefox versions",
"last 4 Edge versions",
"last 4 Safari versions",
"last 4 ChromeAndroid versions",
"last 1 FirefoxAndroid versions"
"last 8 Android versions",
"last 1 ChromeAndroid versions",
"last 1 FirefoxAndroid versions",
"last 6 iOS versions"
],
"cordova": [
"iOS >= 13.0",
"Android >= 76",
"ChromeAndroid >= 76"
]
},
"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"
}
}

View File

@ -1,6 +0,0 @@
// You can add your plugins here
module.exports = [
// '@flaschengeist/balance',
// '@flaschengeist/schedule',
// '@flaschengeist/pricelist',
]

View File

@ -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
},
},
};
}
}
}
});

3
quasar.extensions.json Normal file
View File

@ -0,0 +1,3 @@
{
"@quasar/qcalendar": {}
}

View File

@ -1,10 +0,0 @@
{
"appId": "dev.flaschengeist",
"appName": "flaschengeist-frontend",
"bundledWebRuntime": false,
"npmClient": "yarn",
"webDir": "www",
"ios": {
"allowsLinkPreview": false
}
}

View File

@ -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"
}
}

8
src-cordova/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
# Generated by package manager
node_modules/
# Generated by Cordova
/plugins/
/platforms/

76
src-cordova/config.xml Normal file
View File

@ -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>

10
src-cordova/cordova-flag.d.ts vendored Normal file
View File

@ -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;
}
}

1957
src-cordova/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
src-cordova/package.json Normal file
View File

@ -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"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -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;
}

View File

@ -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);
};

View File

@ -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);
}
}
});

14
src/boot/loading.ts Normal file
View File

@ -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
});
});

View File

@ -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;
});
});

View File

@ -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,27 +221,46 @@ 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
// 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.requiredModules.every(
(required) =>
backend.plugins[required[0]] !== undefined &&
(required.length == 1 ||
true) /* validate the version, semver440 from python is... tricky on node*/
!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 ${plugin.id}: Backend modules not satisfied`);
console.error(`Plugin ${pluginName}: Backend modules not satisfied`);
return false;
}
@ -230,23 +284,46 @@ function loadPlugin(
}
if (plugin.widgets.length > 0) {
plugin.widgets.forEach((widget) => (widget.name = plugin.id + '.' + widget.name));
plugin.widgets.forEach((widget) => (widget.name = plugin.name + '_' + 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;
return plugin;
}
}
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);
});

View File

@ -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();
});

View File

@ -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-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
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-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>

View File

@ -1,23 +1,17 @@
<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-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-btn v-close-popup label='Verknüpfung erstellen' dense @click='addShortCut(child)'/>
</q-menu>
<q-item-section avatar>
<q-icon :name="child.icon" />
<q-icon :name='child.icon' />
</q-item-section>
<q-item-section>
<q-item-label>
{{ getTitle(child) }}
{{child.title}}
</q-item-label>
</q-item-section>
</q-item>
@ -27,13 +21,13 @@
</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>

View File

@ -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 };
},

View File

@ -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',

View File

@ -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(() =>

View File

@ -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;

137
src/flaschengeist.d.ts vendored Normal file
View File

@ -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;
}
}

View File

@ -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 -->
<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,
};
},
});

View File

@ -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({

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