Compare commits

..

No commits in common. "main" and "@flaschengeist/api-v1.0.0-alpha.2" have entirely different histories.

105 changed files with 11211 additions and 1111 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,23 @@ 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',
// Rejects on promises should always be of the Error type (and allow empty rejects as well)
'prefer-promise-reject-errors': ['error', { allowEmptyReject: true }],
// Misc
'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

View File

@ -1,3 +0,0 @@
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')
]
}

View File

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

View File

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

View File

@ -1,6 +1,4 @@
# 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.
@ -10,9 +8,9 @@ Modular student club administration system, licensed under the MIT license.
```
"engines": {
"node": ">= 14.18.1",
"node": ">= 12.22.1",
"npm": ">= 6.14.12",
"yarn": ">= 1.22.0"
"yarn": ">= 1.21.1"
}
```
@ -20,9 +18,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/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"
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"
npm i -g yarn
npm i -g @quasar/cli
popd
@ -76,13 +74,6 @@ 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 our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).
Please refer to out [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<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,46 +0,0 @@
<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,5 +1,4 @@
import IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue';
import UserAvatar from './UserAvatar.vue';
export { IsoDateInput, PasswordInput, UserAvatar };
export { IsoDateInput, PasswordInput };

View File

@ -4,7 +4,4 @@ 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",
"version": "1.0.0-alpha.2",
"name": "@flaschengeist/api",
"author": "Tim Gröger <flaschengeist@wu5.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist",
@ -8,16 +8,27 @@
"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-webpack": "^3.7.2",
"flaschengeist": "^2.0.0",
"pinia": "^2.0.8"
"@quasar/app": "^3.0.0-beta.26",
"flaschengeist": "^2.0.0-alpha.1",
"pinia": "^2.0.0-alpha.19"
},
"devDependencies": {
"@flaschengeist/types": "^1.0.0",
"@types/node": "^14.18.0",
"typescript": "^4.5.4"
"@flaschengeist/types": "^1.0.0-alpha.4",
"@types/node": "^12.20.37",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.0.3",
"eslint-webpack-plugin": "^3.1.0",
"prettier": "^2.4.1",
"typescript": "^4.4.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 (typeof error !== 'object' || !error || !('isAxiosError' in error)) return false;
if (!error || typeof error !== 'object' || !('isAxiosError' in <object>error)) return false;
// Check status code if status was given
if (status !== undefined)
return (

View File

@ -1,31 +1,28 @@
import { LocalStorage, SessionStorage } from 'quasar';
import { FG_Plugin } from '@flaschengeist/types';
import { fixSession, useSessionStore, useUserStore } from '.';
import { useSessionStore, useUserStore } from '.';
import { AxiosResponse } from 'axios';
import { api } from '../internal';
import { defineStore } from 'pinia';
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 loadCurrentSession() {
const session = LocalStorage.getItem<FG.Session>('session');
if (session) session.expires = new Date(session.expires);
return session || 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')
);
function loadUser() {
const user = SessionStorage.getItem<FG.User>('user');
if (user && user.birthday) user.birthday = new Date(user.birthday);
return user || undefined;
}
export const useMainStore = defineStore({
id: 'main',
state: () => ({
session: undefined as FG.Session | undefined,
user: undefined as FG.User | undefined,
session: loadCurrentSession(),
user: loadUser(),
notifications: [] as Array<FG_Plugin.Notification>,
shortcuts: [] as Array<FG_Plugin.MenuLink>,
}),
@ -38,10 +35,6 @@ 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 || [];
},
@ -52,29 +45,29 @@ export const useMainStore = defineStore({
* Updates session and loads current user
*/
async init() {
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);
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);
}
}
} 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 = fixSession(data);
this.user = await userStore.getUser(data.userid, true);
this.session = data;
this.session.expires = new Date(this.session.expires);
LocalStorage.set('session', this.session);
return true;
} catch ({ response }) {
this.handleLoggedOut();
return (<AxiosResponse | undefined>response)?.status || false;
}
},
@ -110,22 +103,24 @@ export const useMainStore = defineStore({
},
async loadNotifications(flaschengeist: FG_Plugin.Flaschengeist) {
const { data } = await api.get<FG.Notification[]>('/notifications', {
params:
this.notifications.length > 0
? { from: this.notifications[this.notifications.length - 1].time }
: {},
});
const notes = [] as FG_Plugin.Notification[];
const params =
this.notifications.length > 0
? { from: this.notifications[this.notifications.length - 1].time }
: {};
const { data } = await api.get<FG.Notification[]>('/notifications', { params: params });
const notifications: FG_Plugin.Notification[] = [];
data.forEach((n) => {
n.time = new Date(n.time);
const plugin = flaschengeist?.plugins.filter((p) => p.id === n.plugin)[0];
if (!plugin) console.debug('Could not find a parser for this notification', n);
else notes.push(plugin.notification(n));
notifications.push(
(flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification)(
/*||
translateNotification*/
n
)
);
});
this.notifications.push(...notes);
return notes;
this.notifications.push(...notifications);
return notifications;
},
async removeNotification(id: number) {
@ -150,11 +145,13 @@ export const useMainStore = defineStore({
async setShortcuts() {
await api.put(`users/${this.currentUser.userid}/shortcuts`, this.shortcuts);
},
handleLoggedOut() {
this.$reset();
void clearPersistant();
LocalStorage.clear();
this.$patch({
session: undefined,
user: undefined,
});
SessionStorage.clear();
},
},

View File

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

View File

@ -1,195 +1,78 @@
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[],
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,
_dirty_users: true,
_dirty_roles: true,
}),
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;
},
},
getters: {},
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 || idx === -1 || isDirty(this._dirty_users)) {
const idx = this.users.findIndex((user) => user.userid === userid);
if (force || this._dirty_users || idx === -1) {
try {
const { data } = await api.get<FG.User>(`/users/${userid}`);
fixUser(data);
if (idx === -1) this._users.push(data);
else this._users[idx] = data;
if (data.birthday) data.birthday = new Date(data.birthday);
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 || isDirty(this._dirty_users)) {
if (force || this._dirty_users) {
const { data } = await api.get<FG.User[]>('/users');
data.forEach(fixUser);
this._users = data;
this._dirty_users = Date.now();
data.forEach((user) => {
if (user.birthday) user.birthday = new Date(user.birthday);
});
this.users = data;
this._dirty_users = false;
}
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(<FG.User>fixUser(data));
this.users.push(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;
async uploadAvatar(user: FG.User, file: string | File) {
const formData = new FormData();
formData.append('file', file);
await api.post(`/users/${user}/avatar`, formData, {
await api.post(`/users/${user.userid}/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');
@ -198,81 +81,31 @@ 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 || isDirty(this._dirty_roles)) {
if (force || this._dirty_roles) {
const { data } = await api.get<FG.Role[]>('/roles');
this.roles = data;
this._dirty_roles = Date.now();
this._dirty_roles = false;
}
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;
this._dirty_roles = true;
},
/** 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);
},
/** 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;
await api.delete(`/roles/${typeof role === 'number' ? role : role.id}`);
this.roles = this.roles.filter((r) => r.id !== (typeof role == 'number' ? role : role.id));
},
},
});

View File

@ -17,23 +17,12 @@ export function formatDateTime(
return dateTimeFormat.format(date);
}
export function asDate(date?: Date, placeholder = '') {
return date ? formatDateTime(date, true) : placeholder;
export function asDate(date?: Date) {
return date ? formatDateTime(date, 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 asHour(date?: Date) {
return date ? formatDateTime(date, false, true) : '';
}
export function startOfWeek(date: Date, startMonday = true) {

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

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

View File

@ -3,7 +3,14 @@
"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,4 +1,6 @@
/* 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.1.0",
"version": "2.0.0-alpha.1",
"productName": "flaschengeist-frontend",
"name": "flaschengeist",
"author": "Tim Gröger <flaschengeist@wu5.de>",
@ -11,41 +11,33 @@
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
},
"scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules|dist|.*)/**/}*.{js,ts,vue}'",
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src ./api"
},
"dependencies": {
"@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"
"@flaschengeist/api": "file:./api",
"@flaschengeist/users": "^1.0.0-alpha.1",
"axios": "^0.21.1",
"cordova": "^10.0.0",
"pinia": "^2.0.0-rc.6",
"quasar": "^2.0.4"
},
"devDependencies": {
"@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",
"@flaschengeist/types": "^1.0.0-alpha.4",
"@quasar/app": "^3.1.0",
"@quasar/extras": "^1.10.12",
"@types/node": "^12.20.21",
"@types/webpack": "^5.28.0",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"@vue/devtools": "^6.5.0",
"eslint": "^8.5.0",
"@types/webpack-env": "^1.16.2",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"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",
"eslint-plugin-vue": "^7.17.0",
"eslint-webpack-plugin": "^3.0.1",
"modify-source-webpack-plugin": "^3.0.0",
"prettier": "^2.3.2",
"typescript": "~4.3.5",
"vuedraggable": "^4.1.0"
},
"prettier": {
@ -54,25 +46,19 @@
"printWidth": 100,
"arrowParens": "always"
},
"browserslist": {
"defaults": [
"Firefox esr",
"last 6 Chrome versions",
"last 4 Firefox versions",
"last 4 Edge versions",
"last 4 Safari versions",
"last 4 ChromeAndroid versions",
"last 1 FirefoxAndroid versions"
],
"cordova": [
"iOS >= 13.0",
"Android >= 76",
"ChromeAndroid >= 76"
]
},
"browserslist": [
"last 10 Chrome versions",
"last 10 Firefox versions",
"last 4 Edge versions",
"last 4 Safari versions",
"last 8 Android versions",
"last 1 ChromeAndroid versions",
"last 1 FirefoxAndroid versions",
"last 6 iOS versions"
],
"engines": {
"node": ">= 14.18.1",
"node": ">= 12.22.1",
"npm": ">= 6.14.12",
"yarn": ">= 1.22.0"
"yarn": ">= 1.21.1"
}
}

View File

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

View File

@ -8,23 +8,10 @@
/* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */
const ESLintPlugin = require('eslint-webpack-plugin');
const { ModifySourcePlugin, ReplaceOperation } = require('modify-source-webpack-plugin');
const { configure } = require('quasar/wrappers');
const ESLintPlugin = require('eslint-webpack-plugin')
const { ModifySourcePlugin } = 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 {
@ -35,7 +22,7 @@ module.exports = configure(function (/* ctx */) {
enabled: true,
files: './src/**/*.{ts,tsx,js,jsx,vue}',
},
},
}
},
// https://quasar.dev/quasar-cli/prefetch-feature
@ -44,7 +31,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'],
@ -53,10 +40,10 @@ module.exports = configure(function (/* ctx */) {
extras: [
// 'eva-icons',
// 'fontawesome-v5',
// 'ionicons-v5',
// 'ionicons-v4',
// 'line-awesome',
// 'material-icons',
'mdi-v7',
'mdi-v5',
// 'themify',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
@ -66,9 +53,10 @@ 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.
@ -83,29 +71,33 @@ 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, [
{
chainWebpack (chain) {
chain.plugin('eslint-webpack-plugin')
.use(ESLintPlugin, [{
extensions: [ 'ts', 'js', 'vue' ],
exclude: 'node_modules'
}])
chain.plugin('modify-source-webpack-plugin')
.use(ModifySourcePlugin, [{
rules: [
{
test: /plugins\.ts$/,
operations: [operation()],
},
],
},
]);
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(','))
}
}
]
}])
chain.merge({
snapshot: {
managedPaths: [],
},
});
},
managedPaths: []
}
})
}
},
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
@ -113,20 +105,20 @@ 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
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
@ -137,7 +129,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
@ -146,7 +144,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
@ -165,20 +163,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
@ -188,7 +186,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
@ -197,11 +195,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: { ... }
},
@ -209,19 +209,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
},
},
bin: {
linuxAndroidStudio: '/home/crimsen/.local/share/JetBrains/Toolbox/scripts/studio',
},
};
}
}
}
});

View File

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

View File

@ -1,35 +0,0 @@
<!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

@ -1,16 +0,0 @@
{
"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"
}
}

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>

View File

@ -5,6 +5,6 @@ import 'quasar/dist/types/feature-flag';
declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags {
capacitor: true;
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,71 +1,20 @@
/**
* 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 }) => {
// 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;
});
@ -94,11 +43,12 @@ export default boot(({ router }) => {
query: { redirect: next },
});
} else if (e.response && e.response.status == 401) {
store.handleLoggedOut();
if (current.name != 'login') {
void store.handleLoggedOut();
if (current.name !== 'login') {
await router.push({
name: 'login',
query: { redirect: current.fullPath },
params: { logout: 'logout' },
query: { redirect: current.path },
});
}
}
@ -107,3 +57,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,97 +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);
}
}
// 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);
}
}
});

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,42 @@
/**
* 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 }) => {
/**
* 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.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();
}
}
});
/**
* 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,5 +1,10 @@
import { FG_Plugin } from '@flaschengeist/types';
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';
/****************************************************
******** Internal area for some magic **************
@ -16,10 +21,41 @@ 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 = <Array<Promise<ImportPlgn>>>[
/*INSERT_PLUGIN_LIST*/
];
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 };
// Handle Notifications
export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note;
@ -192,10 +228,10 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
function loadPlugin(
loadedPlugins: FG_Plugin.Flaschengeist,
plugin: FG_Plugin.Plugin,
backend: FG.Backend
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 === plugin.name) !== -1) return true;
// Check backend dependencies
if (
@ -206,7 +242,7 @@ function loadPlugin(
true) /* validate the version, semver440 from python is... tricky on node*/
)
) {
console.error(`Plugin ${plugin.id}: Backend modules not satisfied`);
console.error(`Plugin ${plugin.name}: Backend modules not satisfied`);
return false;
}
@ -230,18 +266,11 @@ 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);
}
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,
version: plugin.version,
notification: plugin.notification?.bind({}) || translateNotification,
@ -250,46 +279,73 @@ function loadPlugin(
return true;
}
export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRaw[]) {
/**
* Loading backend information
* @returns Backend object or null
*/
async function getBackend() {
try {
const { data }: AxiosResponse<Backend> = await api.get('/');
return data;
} catch (e) {
console.warn(e);
return null;
}
}
/**
* Boot file, load all required plugins, check for dependencies
*/
export default boot(async ({ router, app }) => {
const backend = await getBackend();
if (!backend || typeof backend !== 'object' || !('plugins' in backend)) {
console.log('Backend error');
router.isReady().finally(() => void router.push({ name: 'offline', params: { refresh: 1 } }));
return;
}
const loadedPlugins: FG_Plugin.Flaschengeist = {
routes: baseRoutes,
routes,
plugins: [],
menuLinks: [],
shortcuts: [],
outerShortcuts: [],
widgets: [],
settingWidgets: [],
};
// Wait for all plugins to be loaded
const results = await Promise.allSettled(PLUGINS);
const BreakError = {};
try {
PLUGINS.plugins.forEach((plugin, name) => {
if (!loadPlugin(loadedPlugins, plugin, backend)) {
void router.push({ name: 'error' });
// 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;
}
});
Notify.create({
type: 'negative',
message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${name}!`,
timeout: 10000,
progress: true,
});
throw BreakError;
}
});
} catch (e) {
if (e !== BreakError) throw e;
}
// 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());
});
return loadedPlugins;
}
// 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,9 @@
/**
* This boot file installs the global pinia instance
*/
import { pinia } from '@flaschengeist/api';
import { useMainStore, 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,52 +1,34 @@
<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.stop.prevent="dismiss"
style="position: absolute; top: 0; right: 0"
@click="remove"
/>
<q-btn
v-if="modelValue.accept !== undefined"
round
dense
icon="mdi-check"
size="sm"
color="positive"
class="q-ma-xs"
style="position: absolute; top: 0; right: 3em"
@click="accept"
/>
<q-card-section class="q-pa-xs">
<div class="text-overline">{{ dateString }}</div>
<q-item style="padding: 1px">
<q-item-section v-if="modelValue.icon" side
><q-icon color="primary" :name="modelValue.icon"
/></q-item-section>
<q-item-section>{{ modelValue.text }}</q-item-section>
</q-item>
</q-card-section>
<q-card-actions v-if="modelValue.reject || modelValue.accept">
<q-btn
v-if="modelValue.accept"
icon="mdi-check"
color="positive"
label="Annehmen"
flat
dense
size="sm"
@click.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>
@ -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

@ -27,7 +27,7 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { computed, defineComponent, PropType } from 'vue';
import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/types';
@ -43,12 +43,12 @@ export default defineComponent({
emits: {
addShortCut: (val: FG_Plugin.MenuLink) => val.link,
},
setup(_, { emit }) {
setup(props, { emit }) {
function isGranted(val: FG_Plugin.MenuLink) {
return hasPermissions(val.permissions || []);
return computed(() => hasPermissions(val.permissions || []));
}
function getTitle(entry: FG_Plugin.MenuLink) {
return typeof entry.title === 'function' ? entry.title() : entry.title;
return computed(() => (typeof entry.title === 'function' ? entry.title() : entry.title)).value;
}
function addShortCut(val: FG_Plugin.MenuLink) {

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(true)" />
<q-btn dense flat round icon="mdi-menu" @click="openMenu" />
<q-toolbar-title>
<router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit">
@ -18,12 +18,8 @@
<q-badge color="negative" floating>
{{ notifications.length }}
</q-badge>
<q-menu max-height="400px" style="min-width: 290px" class="q-pa-xs">
<q-btn
v-if="useNative && noPermission"
label="Benachrichtigungen erlauben"
@click="requestPermission"
/>
<q-menu style="max-height: 400px; overflow: auto">
<q-btn v-if="useNative && noPermission" label="Benachrichtigungen erlauben" @click="requestPermission" />
<template v-if="notifications.length > 0">
<Notification
v-for="(notification, index) in notifications"
@ -40,61 +36,20 @@
<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>
<q-drawer
v-model="leftDrawer"
side="left"
bordered
:mini="leftDrawerMini"
@click.capture="openMenuMini"
>
<q-drawer v-model="leftDrawer" side="left" bordered :mini="leftDrawerMini" @click.capture="openMenu">
<!-- Plugins -->
<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>
<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-drawer>
<q-page-container>
<router-view />
@ -108,7 +63,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, Platform } from 'quasar';
import { Screen } from 'quasar';
import config from 'src/config';
import { useRouter } from 'vue-router';
import { useMainStore } from '@flaschengeist/api';
@ -136,8 +91,8 @@ export default defineComponent({
const router = useRouter();
const mainStore = useMainStore();
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const leftDrawer = ref(!Platform.is.mobile);
const leftDrawerMini = ref(true);
const leftDrawer = ref(true);
const leftDrawerMini = ref(false);
const mainLinks = flaschengeist?.menuLinks || [];
const notifications = computed(() => mainStore.notifications.slice().reverse());
const polling = ref(NaN);
@ -150,11 +105,9 @@ export default defineComponent({
void mainStore.getShortcuts();
});
onBeforeUnmount(() => window.clearInterval(polling.value));
/*
function openMenu(event: { target: HTMLInputElement }) {
console.log(event.target.nodeName);
if (event.target.nodeName === 'DIV' || event.target.nodeName === 'I')
leftDrawerMini.value = false;
if (event.target.nodeName === 'DIV') leftDrawerMini.value = false;
else {
if (!leftDrawer.value || leftDrawerMini.value) {
leftDrawer.value = true;
@ -165,13 +118,7 @@ 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();
@ -182,23 +129,19 @@ export default defineComponent({
}
function requestPermission() {
void window.Notification.requestPermission().then(
(p) => (noPermission.value = p !== 'granted')
);
void window.Notification.requestPermission().then((p) => (noPermission.value = p !== 'granted'));
}
function pollNotification() {
void mainStore
.loadNotifications(<FG_Plugin.Flaschengeist>flaschengeist)
.then((notifications) => {
if (useNative && !noPermission.value)
notifications.forEach(
(notif) =>
new window.Notification(notif.text, {
timestamp: notif.time.getTime(),
})
);
});
void mainStore.loadNotifications(<FG_Plugin.Flaschengeist>flaschengeist).then((notifications) => {
if (useNative && !noPermission.value)
notifications.forEach(
(notif) =>
new window.Notification(notif.text, {
timestamp: notif.time.getTime(),
})
);
});
}
const shortCuts = computed({
@ -234,14 +177,12 @@ 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="row justify-center content-center items-center q-col-gutter-lg"
class="fit row justify-around items-start q-col-gutter-sm"
>
<div v-for="(item, index) in widgets" :key="index" class="full-height col-sm-6 col-xs-12">
<div v-for="(item, index) in widgets" :key="index" class="col-4 full-height col-sm-6 col-xs-12">
<component :is="item.widget" />
</div>
</q-page>
@ -16,12 +16,12 @@ import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({
name: 'PageDashboard',
name: 'Dashboard',
setup() {
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const widgets = computed(() =>
flaschengeist?.widgets.filter((widget) => hasPermissions(widget.permissions))
);
const widgets = computed(() => {
return flaschengeist?.widgets.filter((widget) => hasPermissions(widget.permissions));
});
return {
widgets,

View File

@ -2,64 +2,63 @@
<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>{{ backendSetup ? 'Servereinrichtung' : 'Login' }}</q-toolbar-title>
<q-toolbar-title> Login </q-toolbar-title>
</q-toolbar>
<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"
/>
<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"
<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>
<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="quasar.platform.is.cordova || quasar.platform.is.electron"
flat
round
icon="mdi-menu-down"
@click="openServerSettings"
/>
</div>
<q-slide-transition v-if="$q.platform.is.capacitor">
<div v-show="showBackendSetup || backendSetup">
<q-slide-transition v-if="quasar.platform.is.cordova || quasar.platform.is.electron">
<div v-show="visible">
<q-separator />
<q-card-section>
<q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeBackend">
<div class="text-h6">Backend einrichten</div>
<q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeUrl">
<div class="text-h6">Servereinstellung</div>
<q-input v-model="server" filled label="Server" dense />
<q-btn dense color="primary" label="Speichern" type="submit" />
<q-btn size="xs" dense color="primary" label="Speichern" type="submit" />
</q-form>
</q-card-section>
</div>
@ -70,45 +69,36 @@
<script lang="ts">
import { useRouter } from 'vue-router';
import { Loading, Notify, useQuasar } from 'quasar';
import { api, notEmpty, PersistentStorage, useMainStore } from '@flaschengeist/api';
import { Loading, Notify } from 'quasar';
import { notEmpty, useMainStore, useUserStore } from '@flaschengeist/api';
import { PasswordInput } from '@flaschengeist/api/components';
import { defineComponent, onMounted, ref } from 'vue';
import { useSessionStore } from 'app/api';
import { defineComponent, ref } from 'vue';
import { setBaseURL, api } from 'boot/axios';
import { useQuasar } from 'quasar';
export default defineComponent({
name: 'PageLogin',
name: 'Login',
components: { PasswordInput },
props: {
backendSetup: {
type: Boolean,
default: false,
},
},
setup(props) {
const mainRoute = { name: 'dashboard' };
setup() {
const mainStore = useMainStore();
const sessionStore = useSessionStore();
const userStore = useUserStore();
const mainRoute = { name: 'dashboard' };
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(api.defaults.baseURL);
const showBackendSetup = ref(!!props.backendSetup);
const server = ref<string | undefined>(api.defaults.baseURL);
const visible = ref(false);
const quasar = useQuasar();
function changeBackend() {
function openServerSettings() {
visible.value = !visible.value;
}
function changeUrl() {
if (server.value) {
void PersistentStorage.set('baseURL', server.value).then(() => {
showBackendSetup.value = false;
void router.go(0);
});
setBaseURL(server.value);
}
}
@ -117,31 +107,10 @@ export default defineComponent({
const status = await mainStore.login(userid.value, password.value);
if (status === true) {
// 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);
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);
} else {
// Login failed, notify and reset form
password.value = '';
if (status === 401) {
Notify.create({
@ -176,8 +145,7 @@ export default defineComponent({
Notify.create({
group: false,
type: 'ongoing',
message:
'Sollte der Benutzername korrekt und vorhanden sein, erhälst du jetzt eine E-Mail.',
message: 'Sollte der Benutzername korrekt und vorhanden sein, erhälst du jetzt eine E-Mail.',
timeout: 10000,
progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }],
@ -186,14 +154,16 @@ export default defineComponent({
}
return {
changeBackend,
changeUrl,
doLogin,
doReset,
notEmpty,
openServerSettings,
password,
userid,
server,
showBackendSetup,
userid,
visible,
quasar,
};
},
});

View File

@ -39,15 +39,14 @@ import { defineComponent, ref, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
export default defineComponent({
name: 'PageOffline',
name: 'Offline',
setup() {
const router = useRouter();
const reload = ref(10);
const ival = setInterval(() => {
reload.value -= 1;
if (reload.value <= 0) {
if (router.currentRoute.value.params && 'refresh' in router.currentRoute.value.params)
router.go(0);
if (router.currentRoute.value.params && 'refresh' in router.currentRoute.value.params) router.go(0);
const path = router.currentRoute.value.query.redirect;
void router.replace(path ? { path: <string>path } : { name: 'login' });
}

View File

@ -38,7 +38,7 @@
import { defineComponent } from 'vue';
export default defineComponent({
name: 'PagePluginError',
name: 'PluginError',
});
</script>

View File

@ -40,7 +40,7 @@ import { Loading, Notify } from 'quasar';
import { defineComponent, ref } from 'vue';
export default defineComponent({
name: 'PageReset',
// name: 'PageName'
setup() {
const mainStore = useMainStore();
const router = useRouter();

View File

@ -92,7 +92,7 @@ const developers = [
},
];
export default defineComponent({
name: 'PageAbout',
name: 'About',
components: { Developer },
setup() {
const plugins = inject<FG_Plugin.Flaschengeist>('flaschengeist')?.plugins || [];

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