Compare commits

...

76 Commits

Author SHA1 Message Date
Tim Gröger b7735e2924 update to version 2.1.0 2024-10-08 13:23:43 +00:00
Tim Gröger d35cc8e8d1 [feat] sort names by display name mode 2024-10-08 13:23:43 +00:00
Tim Gröger d34898e1e9 [feat] save display name mode 2024-10-08 13:23:43 +00:00
Tim Gröger 93669d66dc [feat] add settings 2024-10-08 13:23:43 +00:00
Tim Gröger ec5458bf7e sort users (by lastname) 2024-10-08 13:23:43 +00:00
Tim Gröger efc7c49a0b update mdi version 2024-10-08 13:23:43 +00:00
Tim Gröger b1e4879881 Merge pull request 'release v2.0.0' (!4) from develop into master
Reviewed-on: #4
2024-01-18 15:15:06 +00:00
Tim Gröger ee7e03ce28 update to version to 2.0.0 2024-01-16 19:52:45 +01:00
Tim Gröger 2928c241ad fix prevention that click card when click on notification buttons 2024-01-16 19:36:49 +01:00
Tim Gröger fe9ec96ce1 update behavior of left drawer 2024-01-16 14:37:38 +01:00
Tim Gröger 417689b725 update dependencies
for modify-source-webpack-plugin new operation function because of api-change
2023-06-14 12:10:33 +02:00
Tim Gröger 847e923265 update to v1.0.0-alpha.2 2023-05-15 23:58:54 +02:00
Tim Gröger 4cb0362bb7 fix capacitor 2023-05-15 23:57:56 +02:00
Tim Gröger a46c41cb5b better widget dashboard 2023-05-14 00:02:08 +02:00
Tim Gröger 3d55f2d2ae fix hyphanation 2023-05-13 09:44:52 +02:00
Tim Gröger 3689da810c add all plugins 2023-05-06 12:35:27 +02:00
Tim Gröger e6d9054256 update dependencies 2023-05-06 11:51:24 +02:00
Tim Gröger ab45bf3667 if capacitor only notification is used when backend is offline 2023-05-05 14:25:31 +02:00
Tim Gröger 857d07040b update capacitor dependencies 2023-05-05 07:53:12 +02:00
groegert e07df08822 eleminate permanent spinner on login
continuous-integration/woodpecker the build was successful Details
2021-12-26 12:01:22 +01:00
Ferdinand Thiessen ec28857af5 chore(deps): Update dependencies
continuous-integration/woodpecker the build was successful Details
2021-12-23 03:21:33 +01:00
Ferdinand Thiessen 1c452e23fe fix(api): Ensure everything is cleared on logout 2021-12-23 03:20:34 +01:00
Ferdinand Thiessen 195593ddc5 feat(ci): Added woodpecker CI
continuous-integration/woodpecker the build was successful Details
2021-12-14 15:48:53 +01:00
Ferdinand Thiessen 2425e6cf2f fix(boot): Fix navigation guards so users get redirected after logging in 2021-12-06 13:14:42 +01:00
Ferdinand Thiessen a9edc12494 fix(api): Allow userid as parameter for avatarURL 2021-12-06 12:51:47 +01:00
Ferdinand Thiessen 1525de1469 chore(deps): Update dependencies 2021-12-06 00:41:13 +01:00
Ferdinand Thiessen 6a75d1bf51 chore(format): Fix formatting, enforce prettier style 2021-12-06 00:40:50 +01:00
Ferdinand Thiessen f9f66e7172 chore(api): Update dependencies, cleanup, tag new version
No need for prettier stuff, it is done by flaschengeists package.json
2021-12-05 23:57:37 +01:00
Ferdinand Thiessen e9c0086859 feat(api): Add component for displaying an users avatar with fallback image 2021-12-05 23:35:32 +01:00
Ferdinand Thiessen b2c70a6657 feat(api): user store decides if data is outdated based on the last update rather then last local changes. 2021-12-05 20:57:57 +01:00
Ferdinand Thiessen f27212f60e feat(api): user store now handels deleted users.
`.users` now is a getter that filters out deleted users.
For all users, including deleted, use `_users` property (should not be needed as `getUser` will return the needed information as well).
2021-12-05 20:57:15 +01:00
Ferdinand Thiessen 9eb5412c14 fix(api): Session is required to load current user 2021-12-05 20:51:44 +01:00
Ferdinand Thiessen 8e552ba508 feat(app): Set lifetime to 14 days on capacitor.
Do not annoy users on capacitory apps with daily logging in again
2021-12-05 13:45:50 +01:00
Ferdinand Thiessen 49c3ec74ba fix(api): Load current user in login function of mainStore 2021-12-05 13:45:50 +01:00
Ferdinand Thiessen 83f32ea82a feat(api): Get current session 2021-12-05 13:30:44 +01:00
Ferdinand Thiessen 656d7a9e3c feat(api): Add delete user function to user store 2021-12-02 21:32:43 +01:00
Ferdinand Thiessen 8fca175d39 chore(api) Tag a new api version 2021-11-29 18:18:00 +01:00
Ferdinand Thiessen 6c219c5226 feat(api) Added function to get the URL of an user avatar 2021-11-29 17:23:55 +01:00
Ferdinand Thiessen cb43b8a39b fix(core) Token should be added to headers instead of replacing them 2021-11-29 17:20:26 +01:00
Tim Gröger acf1816b55 [core] logout on capicitor in drawer 2021-11-28 14:46:00 +01:00
Ferdinand Thiessen 02f60335d4 [api] tag new version 2021-11-27 02:47:14 +01:00
Ferdinand Thiessen d9267bcc0a [api] Save whole session in PersistantStore. 2021-11-27 02:44:40 +01:00
Ferdinand Thiessen 6732921ff7 [core] Improved backend setup on login page 2021-11-27 01:31:43 +01:00
Ferdinand Thiessen 7a705d5f9a [core] Ensure everything is initialized in the correct order.
Make sure api is initialized before making any requests.
2021-11-27 01:18:58 +01:00
Ferdinand Thiessen 368ca23c56 General cleanup of unused code and small fixes 2021-11-27 00:49:12 +01:00
Tim Gröger d82e025700 Merge branch 'capacitor' into develop 2021-11-26 22:14:51 +01:00
Tim Gröger 07e1966471 [capacitor] disable allowLinkPreview for ios 2021-11-26 22:13:35 +01:00
Ferdinand Thiessen 29c085bd2c [core] Save baseURL into PersistentStorage 2021-11-26 20:55:45 +01:00
Ferdinand Thiessen 1b152b52f5 [core] Save session token in PersistentStorage. 2021-11-26 20:40:35 +01:00
Ferdinand Thiessen 6769e18ffa [api] Add PersistentStorage
Wrapper for LocalStorage on browsers and Storage plugin on capacitor
2021-11-26 20:40:35 +01:00
Ferdinand Thiessen 88dd96c937 [app] Use capacitor instead of cordova 2021-11-26 20:40:26 +01:00
Ferdinand Thiessen 4887bc261b [stores] Minor cleanup 2021-11-26 18:11:18 +01:00
Ferdinand Thiessen d516839ad4 [utils] Add placeholder to datetime utils 2021-11-26 11:14:15 +01:00
Ferdinand Thiessen c9df257bbf [core] Fix minor linting error 2021-11-26 01:39:09 +01:00
Tim Gröger 053fdae384 [api] revert user ssp 2021-11-25 12:32:50 +01:00
Ferdinand Thiessen 6fd3f045f8 [core] Fix notifications dismiss function 2021-11-24 15:54:42 +01:00
Ferdinand Thiessen 2d167ebbae [deps] yarn.lock is not needed as it is overwritten when adding plugins 2021-11-24 15:35:26 +01:00
Ferdinand Thiessen a8578e2803 [core] Notifications can now be dismissed without triggering a reject 2021-11-24 15:28:53 +01:00
Ferdinand Thiessen e4889ddac2 [api] Add datetime util for start-end ranges 2021-11-23 16:00:18 +01:00
Ferdinand Thiessen 664def40fc [docs] Add notes on cordova, different browser targets 2021-11-23 15:59:16 +01:00
Ferdinand Thiessen ade6d06eb6 [core] Minify requests 2021-11-22 22:59:50 +01:00
Tim Gröger 53f053a294 [api] add filter for get_users 2021-11-22 15:08:28 +01:00
Tim Gröger cc29893e04 Merge remote-tracking branch 'origin/develop' into develop 2021-11-21 19:43:49 +01:00
Ferdinand Thiessen 42800a9d99 [deps] Update dependencies, node 12 is close to unmaintained 2021-11-21 15:31:02 +01:00
Ferdinand Thiessen f4650ffdeb [api] update dependencies + new release 2021-11-21 15:11:25 +01:00
Ferdinand Thiessen 1158525abb [api] Add clone function to utils 2021-11-21 15:08:58 +01:00
Ferdinand Thiessen 04e3c57397 [clean] Replace unneeded computed with initialized ref 2021-11-21 11:19:08 +01:00
Tim Gröger 2fc411d51d [UI] Show menu drawer if on desktop 2021-11-21 11:13:51 +01:00
Tim Gröger 6061b37887 [users] delete avatar 2021-11-20 22:59:51 +01:00
Tim Gröger efde9a2ee7 [cleanup] remove logs 2021-11-19 23:12:30 +01:00
Tim Gröger e96d15bc66 [chore] show menu drawer auto if on desktop 2021-11-19 23:10:56 +01:00
Tim Gröger dad88ec766 [api][fix] fixed setting roles 2021-11-19 23:10:28 +01:00
Ferdinand Thiessen 3e091fd02b [plugins] Fix bug where not all plugins are loaded 2021-11-19 14:06:29 +01:00
Ferdinand Thiessen fca79c36ef [api] Pass Date objects to IsoDateInput validators instead of string. 2021-11-16 23:57:57 +01:00
Ferdinand Thiessen 34fcdbdb7f [deps] Match dependencies with API 2021-11-16 21:40:56 +01:00
Tim Gröger 48972f84e1 Merge branch 'hotfix/bug395' 2020-08-21 15:08:14 +02:00
100 changed files with 1047 additions and 11176 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'.
'prettier', //'plugin:prettier/recommended'
'plugin:prettier/recommended',
],
plugins: [
@ -54,10 +54,6 @@ 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: {
@ -70,7 +66,7 @@ module.exports = {
__QUASAR_SSR_PWA__: true,
process: true,
Capacitor: true,
chrome: true
chrome: true,
},
// add your custom rules here
@ -80,20 +76,16 @@ module.exports = {
'vue/multi-word-component-names': 'off',
// Rejects on promises should always be of the Error type (and allow empty rejects as well)
'prefer-promise-reject-errors': ["error", {"allowEmptyReject": true}],
'prefer-promise-reject-errors': ['error', { allowEmptyReject: true }],
// Allow " if ' is contained inside the string, so we can avoid escaping
quotes: ['error', 'single', { avoidEscape: true }],
// Allow " if ' is contained inside the string, so we can avoid escaping
quotes: [
process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'single',
{ avoidEscape: true }
],
// TypeScript, let us be not too strict
'@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,6 +4,7 @@ node_modules
# We use yarn, so ignore npm
package-lock.json
yarn.lock
# Quasar core related directories
.quasar
@ -17,6 +18,8 @@ package-lock.json
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/android
/src-capacitor/ios
/src-capacitor/node_modules
# BEX related directories and files

3
.npmignore Normal file
View File

@ -0,0 +1,3 @@
yarn-error.log
.woodpecker/

View File

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

15
.woodpecker/deploy.yml Normal file
View File

@ -0,0 +1,15 @@
pipeline:
deploy:
when:
event: tag
tag: "@flaschengeist/api-v*"
image: node:lts-alpine
commands:
- cd api
- echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc
- yarn publish --non-interactive
secrets: [ node_auth_token ]
depends_on:
- lint

9
.woodpecker/lint.yml Normal file
View File

@ -0,0 +1,9 @@
pipeline:
lint:
when:
branch: [main, develop]
image: node:lts-alpine
commands:
- yarn install
- yarn lint

View File

@ -1,4 +1,6 @@
# Flaschengeist (frontend)
![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist-frontend/status.svg)
Modular student club administration system, licensed under the MIT license.
@ -8,9 +10,9 @@ Modular student club administration system, licensed under the MIT license.
```
"engines": {
"node": ">= 12.22.1",
"node": ">= 14.18.1",
"npm": ">= 6.14.12",
"yarn": ">= 1.21.1"
"yarn": ">= 1.22.0"
}
```
@ -18,9 +20,9 @@ So on debian (buster and bullseye) you will need to install node.js and yarn bes
```bash
pushd ~/opt
wget https://nodejs.org/dist/v16.2.0/node-v16.2.0-linux-x64.tar.xz
tar -xJf node-v16.2.0-linux-x64.tar.xz
export PATH="$(pwd)/node-v16.2.0-linux-x64/bin":"$PATH"
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
@ -74,6 +76,13 @@ This access needs to be configured in `src/config.ts'->config.baseURL
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 out [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).

View File

@ -54,7 +54,7 @@ export default defineComponent({
label: { type: String, default: 'Datum' },
readonly: Boolean,
rules: {
type: Array as PropType<Validator[]>,
type: Array as PropType<Validator<Date>[]>,
default: () => [],
},
},
@ -62,7 +62,22 @@ export default defineComponent({
setup(props, { emit, attrs }) {
const customRules = computed(() => [
props.type == 'date' ? stringIsDate : props.type == 'time' ? stringIsTime : stringIsDateTime,
...props.rules,
(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;
}
},
]);
const clearable = computed(() =>

View File

@ -44,4 +44,4 @@ export default defineComponent({
};
},
});
</script>
</script>

View File

@ -0,0 +1,46 @@
<template>
<q-avatar>
<slot :avatar-u-r-l="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,4 +1,5 @@
import IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue';
import UserAvatar from './UserAvatar.vue';
export { IsoDateInput, PasswordInput };
export { IsoDateInput, PasswordInput, UserAvatar };

View File

@ -4,4 +4,7 @@ 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,6 +1,6 @@
{
"license": "MIT",
"version": "1.0.0-alpha.3",
"version": "1.0.0",
"name": "@flaschengeist/api",
"author": "Tim Gröger <flaschengeist@wu5.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist",
@ -8,27 +8,16 @@
"bugs": {
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
},
"scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src"
},
"main": "./src/index.ts",
"peerDependencies": {
"@quasar/app": "^3.2.2",
"flaschengeist": "^2.0.0-alpha.1",
"pinia": "^2.0.3"
"@quasar/app-webpack": "^3.7.2",
"flaschengeist": "^2.0.0",
"pinia": "^2.0.8"
},
"devDependencies": {
"@flaschengeist/types": "^1.0.0-alpha.5",
"@types/node": "^12.20.37",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.0.3",
"eslint-webpack-plugin": "^3.1.1",
"prettier": "^2.4.1",
"typescript": "^4.4.4"
"@flaschengeist/types": "^1.0.0",
"@types/node": "^14.18.0",
"typescript": "^4.5.4"
},
"prettier": {
"singleQuote": true,

View File

@ -8,7 +8,7 @@ import { AxiosError } from 'axios';
*/
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 (!error || typeof error !== 'object' || !('isAxiosError' in <object>error)) return false;
if (typeof error !== 'object' || !error || !('isAxiosError' in error)) return false;
// Check status code if status was given
if (status !== undefined)
return (

View File

@ -1,28 +1,31 @@
import { LocalStorage, SessionStorage } from 'quasar';
import { FG_Plugin } from '@flaschengeist/types';
import { useSessionStore, useUserStore } from '.';
import { fixSession, useSessionStore, useUserStore } from '.';
import { AxiosResponse } from 'axios';
import { api } from '../internal';
import { defineStore } from 'pinia';
function loadCurrentSession() {
const session = LocalStorage.getItem<FG.Session>('session');
if (session) session.expires = new Date(session.expires);
return session || undefined;
import { PersistentStorage } from '../utils/persistent';
import { LocalStorage, SessionStorage } from 'quasar';
function reviveSession() {
return PersistentStorage.get<FG.Session>('fg_session').then((s) => fixSession(s || undefined));
}
function loadUser() {
const user = SessionStorage.getItem<FG.User>('user');
if (user && user.birthday) user.birthday = new Date(user.birthday);
return user || undefined;
function clearPersistant() {
void PersistentStorage.remove('fg_session');
}
export function saveSession(session?: FG.Session) {
if (session === undefined) return clearPersistant();
PersistentStorage.set('fg_session', session).catch(() =>
console.error('Could not save token to storage')
);
}
export const useMainStore = defineStore({
id: 'main',
state: () => ({
session: loadCurrentSession(),
user: loadUser(),
session: undefined as FG.Session | undefined,
user: undefined as FG.User | undefined,
notifications: [] as Array<FG_Plugin.Notification>,
shortcuts: [] as Array<FG_Plugin.MenuLink>,
}),
@ -35,6 +38,10 @@ export const useMainStore = defineStore({
if (this.user === undefined) throw 'Not logged in, this should not be called';
return this.user;
},
currentSession(): FG.Session {
if (this.session === undefined) throw 'Not logged in, this should not be called';
return this.session;
},
permissions(): string[] {
return this.user?.permissions || [];
},
@ -45,29 +52,29 @@ export const useMainStore = defineStore({
* Updates session and loads current user
*/
async init() {
if (this.session) {
const sessionStore = useSessionStore();
const session = await sessionStore.getSession(this.session.token);
if (session) {
this.session = session;
const userStore = useUserStore();
const user = await userStore.getUser(this.session.userid);
if (user) {
this.user = user;
SessionStorage.set('user', user);
}
const sessionStore = useSessionStore();
const userStore = useUserStore();
try {
this.session = await reviveSession();
if (this.session !== undefined) {
this.session = await sessionStore.getSession(this.session.token);
if (this.session !== undefined) this.user = await userStore.getUser(this.session.userid);
}
} catch (error) {
console.warn('Could not load token from storage', error);
}
},
async login(userid: string, password: string) {
const userStore = useUserStore();
try {
const { data } = await api.post<FG.Session>('/auth', { userid, password });
this.session = data;
this.session.expires = new Date(this.session.expires);
LocalStorage.set('session', this.session);
this.session = fixSession(data);
this.user = await userStore.getUser(data.userid, true);
return true;
} catch ({ response }) {
this.handleLoggedOut();
return (<AxiosResponse | undefined>response)?.status || false;
}
},
@ -143,13 +150,11 @@ export const useMainStore = defineStore({
async setShortcuts() {
await api.put(`users/${this.currentUser.userid}/shortcuts`, this.shortcuts);
},
handleLoggedOut() {
LocalStorage.clear();
this.$patch({
session: undefined,
user: undefined,
});
handleLoggedOut() {
this.$reset();
void clearPersistant();
LocalStorage.clear();
SessionStorage.clear();
},
},

View File

@ -3,6 +3,10 @@ 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',
@ -15,15 +19,13 @@ export const useSessionStore = defineStore({
return await api
.get(`/auth/${token}`)
.then(({ data }: AxiosResponse<FG.Session>) => data)
.catch(() => null);
.catch(() => undefined);
},
async getSessions() {
try {
const { data } = await api.get<FG.Session[]>('/auth');
data.forEach((session) => {
session.expires = new Date(session.expires);
});
data.forEach(fixSession);
const mainStore = useMainStore();
const currentSession = data.find((session) => {
@ -55,7 +57,7 @@ export const useSessionStore = defineStore({
async updateSession(lifetime: number, token: string) {
try {
const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime });
data.expires = new Date(data.expires);
fixSession(data);
const mainStore = useMainStore();
if (mainStore.session?.token == data.token) mainStore.session = data;

View File

@ -1,78 +1,195 @@
import { defineStore } from 'pinia';
import { api } from '../internal';
import { isAxiosError, useMainStore } from '.';
import { DisplayNameMode } from '@flaschengeist/users';
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[],
users: [] as FG.User[],
permissions: [] as FG.Permission[],
_dirty_users: true,
_dirty_roles: true,
userSettings: {} as FG.UserSettings,
// 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: {},
getters: {
users(state) {
const u = state._users.filter((u) => !u.deleted);
switch (this.userSettings['display_name']) {
case DisplayNameMode.FIRSTNAME_LASTNAME || DisplayNameMode.FIRSTNAME:
u.sort((a, b) => {
const a_lastname = a.lastname.toLowerCase();
const b_lastname = b.lastname.toLowerCase();
const a_firstname = a.firstname.toLowerCase();
const b_firstname = b.firstname.toLowerCase();
if (a_firstname === b_firstname) {
return a_lastname < b_lastname ? -1 : 1;
}
return a_firstname < b_firstname ? -1 : 1;
});
break;
case <string>DisplayNameMode.DISPLAYNAME:
u.sort((a, b) => {
const a_displayname = a.display_name.toLowerCase();
const b_displayname = b.display_name.toLowerCase();
return a_displayname < b_displayname ? -1 : 1;
});
break;
default:
u.sort((a, b) => {
const a_lastname = a.lastname.toLowerCase();
const b_lastname = b.lastname.toLowerCase();
const a_firstname = a.firstname.toLowerCase();
const b_firstname = b.firstname.toLowerCase();
if (a_lastname === b_lastname) {
return a_firstname < b_firstname ? -1 : 1;
}
return a_lastname < b_lastname ? -1 : 1;
});
}
return u;
},
},
actions: {
/** Simply filter all users by ID */
findUser(userid: string) {
return this.users.find((user) => user.userid === userid);
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 || this._dirty_users || idx === -1) {
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}`);
if (data.birthday) data.birthday = new Date(data.birthday);
if (idx === -1) this.users.push(data);
else this.users[idx] = data;
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];
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 || this._dirty_users) {
if (force || isDirty(this._dirty_users)) {
const { data } = await api.get<FG.User[]>('/users');
data.forEach((user) => {
if (user.birthday) user.birthday = new Date(user.birthday);
});
this.users = data;
this._dirty_users = false;
data.forEach(fixUser);
this._users = data;
this._dirty_users = Date.now();
}
return this.users;
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;
this._dirty_users = true;
},
/** 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(data);
this._users.push(<FG.User>fixUser(data));
return data;
},
async uploadAvatar(user: FG.User, file: string | File) {
/** 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.userid}/avatar`, formData, {
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');
@ -81,31 +198,81 @@ export const useUserStore = defineStore({
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 || this._dirty_roles) {
if (force || isDirty(this._dirty_roles)) {
const { data } = await api.get<FG.Role[]>('/roles');
this.roles = data;
this._dirty_roles = false;
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;
this._dirty_roles = true;
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) {
await api.delete(`/roles/${typeof role === 'number' ? role : role.id}`);
this.roles = this.roles.filter((r) => r.id !== (typeof role == 'number' ? role : role.id));
if (typeof role === 'object') role = role.id;
await api.delete(`/roles/${role}`);
this.roles = this.roles.filter((r) => r.id !== role);
},
/** Get Settings for display name mode
* @param force If set to true a fresh list is loaded from backend even when a local copy is available
* @throws Probably an AxiosError if request failed
* @returns Settings for display name mode
*/
async getDisplayNameModeSetting(force = false): Promise<string> {
const mainStore = useMainStore();
if (force) {
const { data } = await api.get<{ data: string }>(
`users/${mainStore.currentUser.userid}/setting/display_name_mode`
);
this.userSettings['display_name'] = data.data;
}
return this.userSettings['display_name'];
},
/** Set Settings for display name mode
* @param mode New display name mode
* @throws Probably an AxiosError if request failed
* @returns Settings for display name mode
*/
async setDisplayNameModeSetting(mode: string): Promise<string> {
const mainStore = useMainStore();
await api.put(`users/${mainStore.currentUser.userid}/setting/display_name_mode`, {
data: mode,
});
this.userSettings['display_name'] = mode;
return mode;
},
},
});

View File

@ -17,12 +17,23 @@ export function formatDateTime(
return dateTimeFormat.format(date);
}
export function asDate(date?: Date) {
return date ? formatDateTime(date, true) : '';
export function asDate(date?: Date, placeholder = '') {
return date ? formatDateTime(date, true) : placeholder;
}
export function asHour(date?: Date) {
return date ? formatDateTime(date, false, true) : '';
export function asHour(date?: Date, placeholder = '') {
return date ? formatDateTime(date, false, true) : placeholder;
}
export function formatStartEnd(start: Date, end?: Date) {
const today = asDate(new Date());
const startDate = asDate(start);
const endDate = end ? asDate(end) : '';
return (
(today !== startDate ? `${startDate}, ` : '') +
asHour(start) +
(end ? ' - ' + (endDate !== startDate ? `${endDate}, ` : '') + asHour(end) : '')
);
}
export function startOfWeek(date: Date, startMonday = true) {

3
api/src/utils/misc.ts Normal file
View File

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

View File

@ -0,0 +1,35 @@
import { LocalStorage, Platform } from 'quasar';
import { Preferences } from '@capacitor/preferences';
// 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 Preferences.clear();
else return Promise.resolve(LocalStorage.clear());
}
static remove(key: string) {
if (Platform.is.capacitor) return Preferences.remove({ key: key });
else return Promise.resolve(LocalStorage.remove(key));
}
static set(key: string, value: PersitentTypes) {
if (Platform.is.capacitor) return Preferences.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 Preferences.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 Preferences.keys().then((v) => v.keys);
else return Promise.resolve(LocalStorage.getAllKeys());
}
}

6
api/src/utils/user.ts Normal file
View File

@ -0,0 +1,6 @@
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,4 +1,4 @@
export type Validator = (value: unknown) => boolean | string;
export type Validator<T = unknown> = (value?: T | null) => boolean | string;
export function notEmpty(val: unknown) {
return !!val || 'Feld darf nicht leer sein!';

View File

@ -3,14 +3,7 @@
"target": "esnext",
"compilerOptions": {
"baseUrl": "./",
"lib": [
"es2020",
"dom"
],
"types": [
"@flaschengeist/types",
"@quasar/app",
"node"
]
}
"lib": ["es2020", "dom"],
"types": ["@flaschengeist/types", "@quasar/app", "node"]
}
}

View File

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

View File

@ -1,7 +1,7 @@
{
"private": true,
"license": "MIT",
"version": "2.0.0-alpha.1",
"version": "2.1.0",
"productName": "flaschengeist-frontend",
"name": "flaschengeist",
"author": "Tim Gröger <flaschengeist@wu5.de>",
@ -11,33 +11,41 @@
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
},
"scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"format": "prettier --config ./package.json --write '{,!(node_modules|dist|.*)/**/}*.{js,ts,vue}'",
"lint": "eslint --ext .js,.ts,.vue ./src ./api"
},
"dependencies": {
"@flaschengeist/api": "file:./api",
"@flaschengeist/users": "^1.0.0-alpha.1",
"axios": "^0.24.0",
"cordova": "^10.0.0",
"pinia": "^2.0.2",
"quasar": "^2.3.1"
"@flaschengeist/api": "^1.0.0",
"@flaschengeist/balance": "^1.0.0",
"@flaschengeist/pricelist-old": "^1.0.0",
"@flaschengeist/schedule": "^1.0.0",
"@flaschengeist/users": "^1.0.0",
"axios": "^1.4.0",
"pinia": "^2.0.8",
"quasar": "^2.11.10",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@flaschengeist/types": "^1.0.0-alpha.5",
"@quasar/app": "^3.2.1",
"@quasar/extras": "^1.11.5",
"@types/node": "^12.20.37",
"@capacitor/core": "^5.0.0",
"@capacitor/preferences": "^5.0.0",
"@flaschengeist/types": "^1.0.0",
"@quasar/app-webpack": "^3.7.2",
"@quasar/extras": "^1.16.3",
"@types/node": "^14.18.0",
"@types/webpack": "^5.28.0",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"eslint": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"@vue/devtools": "^6.5.0",
"eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.0.3",
"eslint-webpack-plugin": "^3.1.0",
"modify-source-webpack-plugin": "^3.0.0",
"prettier": "^2.4.1",
"typescript": "^4.4.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^9.14.1",
"eslint-webpack-plugin": "^4.0.1",
"modify-source-webpack-plugin": "^4.1.0",
"prettier": "^2.5.1",
"typescript": "^4.5.4",
"vuedraggable": "^4.1.0"
},
"prettier": {
@ -46,19 +54,25 @@
"printWidth": 100,
"arrowParens": "always"
},
"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"
],
"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"
]
},
"engines": {
"node": ">= 12.22.1",
"node": ">= 14.18.1",
"npm": ">= 6.14.12",
"yarn": ">= 1.21.1"
"yarn": ">= 1.22.0"
}
}

View File

@ -3,4 +3,4 @@ module.exports = [
// '@flaschengeist/balance',
// '@flaschengeist/schedule',
// '@flaschengeist/pricelist',
]
'@flaschengeist/schedule',

View File

@ -8,10 +8,23 @@
/* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */
const ESLintPlugin = require('eslint-webpack-plugin')
const { ModifySourcePlugin } = require('modify-source-webpack-plugin')
const { configure } = require('quasar/wrappers')
const ESLintPlugin = require('eslint-webpack-plugin');
const { ModifySourcePlugin, ReplaceOperation } = require('modify-source-webpack-plugin');
const { configure } = require('quasar/wrappers');
const operation = () => {
const custom_plgns = require('./plugin.config.js');
const required_plgns = require('./src/vendor-plugin.config.js');
const plugins = [...custom_plgns, ...required_plgns].map(
(v) => `import("${v}").catch(() => "${v}")`
);
const replace = new ReplaceOperation(
'all',
`\\/\\* *INSERT_PLUGIN_LIST *\\*\\/`,
`${plugins.join(', ')}`
);
return replace;
};
module.exports = configure(function (/* ctx */) {
return {
@ -22,7 +35,7 @@ module.exports = configure(function (/* ctx */) {
enabled: true,
files: './src/**/*.{ts,tsx,js,jsx,vue}',
},
}
},
},
// https://quasar.dev/quasar-cli/prefetch-feature
@ -31,7 +44,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', 'loading', 'login'],
boot: ['axios', 'store', 'plugins', 'login', 'init'],
// https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
css: ['app.scss'],
@ -43,9 +56,9 @@ module.exports = configure(function (/* ctx */) {
// 'ionicons-v5',
// 'line-awesome',
// 'material-icons',
'mdi-v6',
'mdi-v7',
// 'themify',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
],
@ -53,10 +66,9 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
build: {
vueRouterMode: 'history', // available values: 'hash', 'history'
//publicPath: 'flaschengeist2',
// transpile: false,
// Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled).
// Applies only if "transpile" is set to true.
@ -71,33 +83,29 @@ 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'
}])
chain.plugin('modify-source-webpack-plugin')
.use(ModifySourcePlugin, [{
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}").then(v => success(v)).catch(() => failure("${v}"))`)
.join(','))
}
}
]
}])
operations: [operation()],
},
],
},
]);
chain.merge({
snapshot: {
managedPaths: []
}
})
}
managedPaths: [],
},
});
},
},
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
@ -105,7 +113,7 @@ module.exports = configure(function (/* ctx */) {
https: false,
port: 8080,
open: false, // opens browser window automatically
watchFiles: {paths: ['/node_modules/@flaschengeist/**/*']}
watchFiles: { paths: ['/node_modules/@flaschengeist/**/*'] },
},
// https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
@ -117,8 +125,8 @@ module.exports = configure(function (/* ctx */) {
loadingBar: {
position: 'top',
color: 'warning',
size: '5px'
}
size: '5px',
},
},
// For special cases outside of where the auto-import stategy can have an impact
@ -129,13 +137,7 @@ module.exports = configure(function (/* ctx */) {
// directives: [],
// Quasar plugins
plugins: [
'LocalStorage',
'SessionStorage',
'Loading',
'Notify',
'LoadingBar'
]
plugins: ['LocalStorage', 'SessionStorage', 'Dialog', 'Loading', 'Notify', 'LoadingBar'],
},
// animations: 'all', // --- includes all animations
@ -144,7 +146,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
@ -163,20 +165,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
@ -186,7 +188,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
@ -195,13 +197,11 @@ 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: { ... }
},
@ -209,16 +209,19 @@ 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
}
}
}
},
},
bin: {
linuxAndroidStudio: '/home/crimsen/.local/share/JetBrains/Toolbox/scripts/studio',
},
};
});

View File

@ -5,6 +5,6 @@ import 'quasar/dist/types/feature-flag';
declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags {
cordova: true;
capacitor: true;
}
}

View File

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

35
src-capacitor/index.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<title>Quasar</title>
<meta charset="utf-8">
<meta name="description" content="Quasar Capacitor App">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, viewport-fit=cover">
<style>
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<div>
This file will be auto-generated. Do not edit.
</div>
<div>
Run "quasar dev" or "quasar build" with Capacitor mode.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
{
"name": "flaschengeist",
"version": "2.0.0",
"description": "Modular student club administration system",
"author": "Tim Gröger <flaschengeist@wu5.de>",
"private": true,
"dependencies": {
"@capacitor/android": "^5.0.0-beta.0",
"@capacitor/app": "^5.0.0",
"@capacitor/cli": "^5.0.0",
"@capacitor/core": "^5.0.0",
"@capacitor/ios": "^5.0.0",
"@capacitor/preferences": "^5.0.0",
"@capacitor/splash-screen": "^5.0.0"
}
}

View File

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

View File

@ -1,76 +0,0 @@
<?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>

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,20 +1,71 @@
/**
* This boot file registers interceptors for axios
*/
import { useMainStore, api } from '@flaschengeist/api';
import { LocalStorage, Notify } from 'quasar';
import { AxiosError } from 'axios';
import { boot } from 'quasar/wrappers';
import config from 'src/config';
import { clone } from '@flaschengeist/api';
/**
* 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;
}
export default boot(({ router }) => {
api.defaults.baseURL = LocalStorage.getItem<string>('baseURL') || config.baseURL;
// Persisted value is read in plugins.ts boot file!
if (api.defaults.baseURL === undefined) api.defaults.baseURL = config.baseURL;
/***
* Intercept requests and insert Token if available
* Intercept requests
* - insert Token if available
* - minify JSON requests
*/
api.interceptors.request.use((config) => {
const store = useMainStore();
if (store.session?.token) {
config.headers = { Authorization: 'Bearer ' + store.session.token };
config.headers = Object.assign(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;
});
@ -43,12 +94,11 @@ export default boot(({ router }) => {
query: { redirect: next },
});
} else if (e.response && e.response.status == 401) {
void store.handleLoggedOut();
if (current.name !== 'login') {
store.handleLoggedOut();
if (current.name != 'login') {
await router.push({
name: 'login',
params: { logout: 'logout' },
query: { redirect: current.path },
query: { redirect: current.fullPath },
});
}
}
@ -57,19 +107,3 @@ 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);
};

97
src/boot/init.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* 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);
}
}
// eslint-disable-next-line
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' });
if (Platform.is.capacitor) {
//void router.push({ name: 'setup_backend' })
Notify.create({
type: 'negative',
message:
'Backend nicht erreichbar! Prüfe deine Internetverbindung oder probiere es später nochmal.',
timeout: 0,
icon: 'mdi-alert-circle-outline',
closeBtn: true,
});
} 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);
}
}
});

View File

@ -1,14 +0,0 @@
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,42 +1,33 @@
/**
* This boot file registers login / authentification related axios interceptors
*/
import { useMainStore, hasPermissions } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers';
import { RouteRecord } from 'vue-router';
export default boot(({ router }) => {
router.beforeResolve((to, from, next) => {
/**
* Login guard
* Check if user tries to access the secured area and validates token
*/
router.beforeEach((to, from) => {
const store = useMainStore();
if (to.path == from.path) return next();
// Skip loops
if (to.name == 'login' && from.name == 'login') return false;
if (to.path.startsWith('/main')) {
// Secured area (LOGIN REQUIRED)
// Check login is ok
if (!store.session || store.session.expires <= new Date()) {
void store.handleLoggedOut();
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();
}
// Secured area '/in/...' requires to be authenticated
if (to.path.startsWith('/in') && (!store.session || store.session.expires <= new Date())) {
store.handleLoggedOut();
return { name: 'login' };
}
});
/**
* 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,10 +1,5 @@
import { Notify } from 'quasar';
import { api } from 'src/boot/axios';
import { boot } from 'quasar/wrappers';
import routes from 'src/router/routes';
import { AxiosResponse } from 'axios';
import { RouteRecordRaw } from 'vue-router';
import { FG_Plugin } from '@flaschengeist/types';
import { RouteRecordRaw } from 'vue-router';
/****************************************************
******** Internal area for some magic **************
@ -21,41 +16,10 @@ function validatePlugin(plugin: FG_Plugin.Plugin) {
);
}
/* eslint-disable */
// This functions are used by webpack magic
// Called when import promise resolved
function success(value: ImportPlgn, path: string) {
if (validatePlugin(value.default)) PLUGINS.plugins.set(value.default.id, value.default);
else failure(path);
}
// Called when import promise rejected
function failure(path = 'unknown') {
console.error(`Plugin ${path} could not be found and not imported`);
}
/* eslint-enable */
// Here does some magic happens, WebPack will automatically replace the following comment with the import statements
const PLUGINS = {
context: <Array<() => Promise<ImportPlgn>>>[
/*INSERT_PLUGIN_LIST*/
],
plugins: new Map<string, FG_Plugin.Plugin>(),
};
interface BackendPlugin {
permissions: string[];
version: string;
}
interface BackendPlugins {
[key: string]: BackendPlugin;
}
interface Backend {
plugins: BackendPlugins;
version: string;
}
export { Backend };
const PLUGINS = <Array<Promise<ImportPlgn>>>[
/*INSERT_PLUGIN_LIST*/
];
// Handle Notifications
export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note;
@ -228,7 +192,7 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
function loadPlugin(
loadedPlugins: FG_Plugin.Flaschengeist,
plugin: FG_Plugin.Plugin,
backend: Backend
backend: FG.Backend
) {
// Check if already loaded
if (loadedPlugins.plugins.findIndex((p) => p.id === plugin.id) !== -1) return true;
@ -270,6 +234,12 @@ function loadPlugin(
Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets);
}
if (!plugin.settingWidgets) plugin.settingWidgets = [];
if (plugin.settingWidgets.length > 0) {
plugin.settingWidgets.forEach((widget) => (widget.name = plugin.id + '.' + widget.name));
Array.prototype.push.apply(loadedPlugins.settingWidgets, plugin.settingWidgets);
}
loadedPlugins.plugins.push({
id: plugin.id,
name: plugin.name,
@ -280,73 +250,46 @@ function loadPlugin(
return true;
}
/**
* 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 || typeof backend !== 'object' || !('plugins' in backend)) {
console.log('Backend error');
router.isReady().finally(() => void router.push({ name: 'offline', params: { refresh: 1 } }));
return;
}
export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRaw[]) {
const loadedPlugins: FG_Plugin.Flaschengeist = {
routes,
routes: baseRoutes,
plugins: [],
menuLinks: [],
shortcuts: [],
outerShortcuts: [],
widgets: [],
settingWidgets: [],
};
const BreakError = {};
try {
PLUGINS.plugins.forEach((plugin, id) => {
if (!loadPlugin(loadedPlugins, plugin, backend)) {
void router.push({ name: 'error' });
// Wait for all plugins to be loaded
const results = await Promise.allSettled(PLUGINS);
Notify.create({
type: 'negative',
message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${id}!`,
timeout: 10000,
progress: true,
});
throw BreakError;
}
});
} catch (e) {
if (e !== BreakError) throw e;
}
// 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;
}
});
// 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());
});
// Add loaded routes to router
loadedPlugins.routes.forEach((route) => router.addRoute(route));
// save plugins in VM-variable
app.provide('flaschengeist', loadedPlugins);
});
return loadedPlugins;
}

View File

@ -1,9 +1,9 @@
import { useMainStore, pinia } from '@flaschengeist/api';
/**
* This boot file installs the global pinia instance
*/
import { pinia } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers';
export default boot(({ app }) => {
app.use(pinia);
const store = useMainStore();
void store.init();
});

View File

@ -1,34 +1,52 @@
<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-close"
icon="mdi-trash-can"
size="sm"
color="negative"
class="q-ma-xs"
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"
title="Löschen"
style="position: absolute; top: 0; right: 0; z-index: 999"
@click.stop.prevent="dismiss"
/>
<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.stop.prevent="accept"
/>
<q-btn
v-if="modelValue.reject"
icon="mdi-close"
color="negative"
label="Ablehnen"
flat
dense
size="sm"
@click.stop.prevent="reject"
/>
</q-card-actions>
</q-card>
</template>
@ -63,13 +81,19 @@ export default defineComponent({
else emit('remove', props.modelValue.id);
}
function remove() {
function reject() {
if (typeof props.modelValue.reject === 'function')
void props.modelValue.reject().finally(() => emit('remove', props.modelValue.id));
else emit('remove', props.modelValue.id);
}
return { accept, click, dateString, remove };
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 };
},
});
</script>

View File

@ -2,7 +2,7 @@
<q-layout view="hHh Lpr lff">
<q-header elevated class="bg-primary text-white">
<q-toolbar>
<q-btn dense flat round icon="mdi-menu" @click="openMenu" />
<q-btn dense flat round icon="mdi-menu" @click="openMenu(true)" />
<q-toolbar-title>
<router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit">
@ -18,7 +18,7 @@
<q-badge color="negative" floating>
{{ notifications.length }}
</q-badge>
<q-menu style="max-height: 400px; overflow: auto">
<q-menu max-height="400px" style="min-width: 290px" class="q-pa-xs">
<q-btn
v-if="useNative && noPermission"
label="Benachrichtigungen erlauben"
@ -40,7 +40,14 @@
<shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" />
</template>
</drag>
<q-btn flat round dense icon="mdi-exit-to-app" @click="logout()" />
<q-btn
v-if="!platform.is.capacitor"
flat
round
dense
icon="mdi-exit-to-app"
@click="logout()"
/>
</q-toolbar>
</q-header>
@ -49,21 +56,45 @@
side="left"
bordered
:mini="leftDrawerMini"
@click.capture="openMenu"
@click.capture="openMenuMini"
>
<!-- Plugins -->
<essential-expansion-link
v-for="(entry, index) in mainLinks"
:key="'plugin' + index"
:entry="entry"
@add-short-cut="addShortcut"
/>
<q-separator />
<essential-link
v-for="(entry, index) in essentials"
:key="'essential' + index"
:entry="entry"
/>
<q-scroll-area class="fit">
<essential-expansion-link
v-for="(entry, index) in mainLinks"
:key="'plugin' + index"
:entry="entry"
@add-short-cut="addShortcut"
/>
<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-scroll-area>
<div class="q-mini-drawer-hide absolute" style="top: 15px; right: -17px">
<q-btn
dense
round
unelevated
color="accent"
icon="mdi-chevron-left"
@click="openMenuMini(true)"
/>
</div>
</q-drawer>
<q-page-container>
<router-view />
@ -77,7 +108,7 @@ 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 } from 'quasar';
import { Screen, Platform } from 'quasar';
import config from 'src/config';
import { useRouter } from 'vue-router';
import { useMainStore } from '@flaschengeist/api';
@ -105,8 +136,8 @@ export default defineComponent({
const router = useRouter();
const mainStore = useMainStore();
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const leftDrawer = ref(false);
const leftDrawerMini = ref(false);
const leftDrawer = ref(!Platform.is.mobile);
const leftDrawerMini = ref(true);
const mainLinks = flaschengeist?.menuLinks || [];
const notifications = computed(() => mainStore.notifications.slice().reverse());
const polling = ref(NaN);
@ -119,9 +150,11 @@ export default defineComponent({
void mainStore.getShortcuts();
});
onBeforeUnmount(() => window.clearInterval(polling.value));
/*
function openMenu(event: { target: HTMLInputElement }) {
if (event.target.nodeName === 'DIV') leftDrawerMini.value = false;
console.log(event.target.nodeName);
if (event.target.nodeName === 'DIV' || event.target.nodeName === 'I')
leftDrawerMini.value = false;
else {
if (!leftDrawer.value || leftDrawerMini.value) {
leftDrawer.value = true;
@ -132,7 +165,13 @@ export default defineComponent({
}
}
}
*/
function openMenu(value = !leftDrawer.value) {
leftDrawer.value = value;
}
function openMenuMini(value = !leftDrawerMini.value) {
leftDrawerMini.value = value;
}
function logout() {
void router.push({ name: 'login', params: { logout: 'logout' } });
void mainStore.logout();
@ -195,12 +234,14 @@ export default defineComponent({
notifications,
noPermission,
openMenu,
openMenuMini,
remove,
requestPermission,
useNative,
shortCuts,
addShortcut,
deleteShortcut,
platform: Platform,
};
},
});

View File

@ -2,9 +2,9 @@
<q-page
padding
style="grid-auto-rows: 1fr"
class="fit row justify-around items-start q-col-gutter-sm"
class="row justify-center content-center items-center q-col-gutter-lg"
>
<div v-for="(item, index) in widgets" :key="index" class="col-4 full-height col-sm-6 col-xs-12">
<div v-for="(item, index) in widgets" :key="index" class="full-height col-sm-6 col-xs-12">
<component :is="item.widget" />
</div>
</q-page>

View File

@ -2,63 +2,64 @@
<q-page padding class="fit row justify-center items-center content-center">
<q-card class="col-xs-11 col-sm-8 col-md-6 col-lg-4 justify-center items-center content-center">
<q-toolbar class="bg-primary text-white">
<q-toolbar-title> Login </q-toolbar-title>
<q-toolbar-title>{{ backendSetup ? 'Servereinrichtung' : 'Login' }}</q-toolbar-title>
</q-toolbar>
<q-card-section>
<q-form @submit="doLogin">
<q-input
v-model="userid"
class="q-pa-md"
filled
label="Benutzername oder E-Mail"
autocomplete="username"
:rules="[notEmpty]"
tabindex="1"
/>
<password-input
v-model="password"
class="q-px-md q-pt-md q-pb-none"
filled
label="Passwort"
autocomplete="cureent-password"
:rules="[notEmpty]"
tabindex="2"
/>
<div class="full-width row justify-end q-px-md">
<q-btn
label="Passwort vergessen?"
type="a"
color="secondary"
tabindex="4"
flat
dense
size="sm"
@click="doReset"
<div v-if="!backendSetup">
<q-card-section>
<q-form @submit="doLogin">
<q-input
v-model="userid"
class="q-pa-md"
filled
label="Benutzername oder E-Mail"
autocomplete="username"
:rules="[notEmpty]"
tabindex="1"
/>
</div>
<div class="full-width row justify-end q-px-md q-pt-md">
<q-btn label="Login" type="submit" color="primary" tabindex="3" />
</div>
</q-form>
</q-card-section>
<div class="row justify-end">
<q-btn
v-if="quasar.platform.is.cordova || quasar.platform.is.electron"
flat
round
icon="mdi-menu-down"
@click="openServerSettings"
/>
<password-input
v-model="password"
class="q-px-md q-pt-md q-pb-none"
filled
label="Passwort"
autocomplete="cureent-password"
:rules="[notEmpty]"
tabindex="2"
/>
<div class="full-width row justify-end q-px-md">
<q-btn
label="Passwort vergessen?"
type="a"
color="secondary"
tabindex="4"
flat
dense
size="sm"
@click="doReset"
/>
</div>
<div class="full-width row justify-end q-px-md q-pt-md">
<q-btn label="Login" type="submit" color="primary" tabindex="3" />
</div>
</q-form>
</q-card-section>
<div class="row justify-end">
<q-btn
v-if="$q.platform.is.capacitor || $q.platform.is.electron"
flat
round
:icon="showBackendSetup ? 'mdi-menu-up' : 'mdi-menu-down'"
@click="showBackendSetup = !showBackendSetup"
/>
</div>
</div>
<q-slide-transition v-if="quasar.platform.is.cordova || quasar.platform.is.electron">
<div v-show="visible">
<q-slide-transition v-if="$q.platform.is.capacitor">
<div v-show="showBackendSetup || backendSetup">
<q-separator />
<q-card-section>
<q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeUrl">
<div class="text-h6">Servereinstellung</div>
<q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeBackend">
<div class="text-h6">Backend einrichten</div>
<q-input v-model="server" filled label="Server" dense />
<q-btn size="xs" dense color="primary" label="Speichern" type="submit" />
<q-btn dense color="primary" label="Speichern" type="submit" />
</q-form>
</q-card-section>
</div>
@ -69,36 +70,45 @@
<script lang="ts">
import { useRouter } from 'vue-router';
import { Loading, Notify } from 'quasar';
import { notEmpty, useMainStore, useUserStore } from '@flaschengeist/api';
import { Loading, Notify, useQuasar } from 'quasar';
import { api, notEmpty, PersistentStorage, useMainStore } from '@flaschengeist/api';
import { PasswordInput } from '@flaschengeist/api/components';
import { defineComponent, ref } from 'vue';
import { setBaseURL, api } from 'boot/axios';
import { useQuasar } from 'quasar';
import { defineComponent, onMounted, ref } from 'vue';
import { useSessionStore } from 'app/api';
export default defineComponent({
name: 'PageLogin',
components: { PasswordInput },
setup() {
const mainStore = useMainStore();
const userStore = useUserStore();
props: {
backendSetup: {
type: Boolean,
default: false,
},
},
setup(props) {
const mainRoute = { name: 'dashboard' };
const mainStore = useMainStore();
const sessionStore = useSessionStore();
const router = useRouter();
const quasar = useQuasar();
onMounted(() => {
if (mainStore.session) void router.replace(mainRoute);
});
/* Stuff for the real login page */
const userid = ref('');
const password = ref('');
const server = ref<string | undefined>(api.defaults.baseURL);
const visible = ref(false);
const quasar = useQuasar();
const server = ref(api.defaults.baseURL);
const showBackendSetup = ref(!!props.backendSetup);
function openServerSettings() {
visible.value = !visible.value;
}
function changeUrl() {
function changeBackend() {
if (server.value) {
setBaseURL(server.value);
void PersistentStorage.set('baseURL', server.value).then(() => {
showBackendSetup.value = false;
void router.go(0);
});
}
}
@ -107,10 +117,31 @@ export default defineComponent({
const status = await mainStore.login(userid.value, password.value);
if (status === true) {
mainStore.user = (await userStore.getUser(userid.value, true)) || undefined;
const x = router.currentRoute.value.query['redirect'];
void router.push(typeof x === 'string' ? { path: x } : mainRoute);
// On capacitor we set the lifetime to at least two weeks to not annoy users.
if (quasar.platform.is.capacitor)
await sessionStore.updateSession(14 * 24 * 60 * 60, mainStore.currentSession.token);
// Redirect user to previous page, if any.
// there are different redirects possible:
// 1. when explicitely entered
// a) http://localhost:8080/ -> should be redirected to mainRoute
// b) http://localhost:8080/in/user/settings -> should be redirected to in/user/settings
// 2. when automatically logged out:
// http://localhost:8080/login?redirect=/in/user/settings
// -> should be redirected to in/user/settings
var redirect;
if (router.currentRoute.value.redirectedFrom) {
redirect = router.currentRoute.value.redirectedFrom.path;
if (redirect === '/') {
redirect = mainRoute;
}
} else if ('redirect' in router.currentRoute.value.query) {
redirect = { path: router.currentRoute.value.query.redirect as string };
} else {
redirect = mainRoute;
}
void router.push(redirect);
} else {
// Login failed, notify and reset form
password.value = '';
if (status === 401) {
Notify.create({
@ -155,16 +186,14 @@ export default defineComponent({
}
return {
changeUrl,
changeBackend,
doLogin,
doReset,
notEmpty,
openServerSettings,
password,
server,
userid,
visible,
quasar,
server,
showBackendSetup,
};
},
});

View File

@ -10,12 +10,18 @@ const routes: RouteRecordRaw[] = [
name: 'login',
path: 'login',
component: () => import('pages/Login.vue'),
props: true,
},
{
name: 'password_reset',
path: 'reset',
component: () => import('pages/Reset.vue'),
},
{
path: '/setup-backend',
name: 'setup_backend',
redirect: { name: 'login', params: { backendSetup: 1 } },
},
{
name: 'about_out',
path: 'about',

View File

@ -1,3 +1 @@
module.exports = [
'@flaschengeist/users',
]
module.exports = ['@flaschengeist/users'];

View File

@ -1,5 +1,5 @@
{
"extends": "@quasar/app/tsconfig-preset",
"extends": "@quasar/app-webpack/tsconfig-preset",
"target": "esnext",
"compilerOptions": {
"baseUrl": ".",

8592
yarn.lock

File diff suppressed because it is too large Load Diff