Compare commits

...

47 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 48972f84e1 Merge branch 'hotfix/bug395' 2020-08-21 15:08:14 +02:00
38 changed files with 828 additions and 490 deletions

View File

@ -17,11 +17,11 @@ module.exports = {
project: resolve(__dirname, './tsconfig.json'), project: resolve(__dirname, './tsconfig.json'),
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features 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: { env: {
browser: true browser: true,
}, },
// Rules order is important, please avoid shuffling them // Rules order is important, please avoid shuffling them
@ -44,7 +44,7 @@ module.exports = {
// https://github.com/prettier/eslint-config-prettier#installation // https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'. // usage with Prettier, provided by 'eslint-config-prettier'.
'prettier', //'plugin:prettier/recommended' 'plugin:prettier/recommended',
], ],
plugins: [ plugins: [
@ -54,10 +54,6 @@ module.exports = {
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file
// required to lint *.vue files // required to lint *.vue files
'vue', '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: { globals: {
@ -70,7 +66,7 @@ module.exports = {
__QUASAR_SSR_PWA__: true, __QUASAR_SSR_PWA__: true,
process: true, process: true,
Capacitor: true, Capacitor: true,
chrome: true chrome: true,
}, },
// add your custom rules here // add your custom rules here
@ -80,20 +76,16 @@ module.exports = {
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
// Rejects on promises should always be of the Error type (and allow empty rejects as well) // 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, let us be not too strict
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
// allow debugger during development only // allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
} },
} };

3
.npmignore Normal file
View File

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

View File

@ -3,6 +3,6 @@
module.exports = { module.exports = {
plugins: [ plugins: [
// to edit target browsers: use "browserslist" field in package.json // 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) # 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. Modular student club administration system, licensed under the MIT license.

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 IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue'; import PasswordInput from './PasswordInput.vue';
import UserAvatar from './UserAvatar.vue';
export { IsoDateInput, PasswordInput }; export { IsoDateInput, PasswordInput, UserAvatar };

View File

@ -5,5 +5,6 @@ export * from './src/stores/';
export * from './src/utils/datetime'; export * from './src/utils/datetime';
export * from './src/utils/permission'; export * from './src/utils/permission';
export * from './src/utils/persistent'; export * from './src/utils/persistent';
export * from './src/utils/user';
export * from './src/utils/validators'; export * from './src/utils/validators';
export * from './src/utils/misc'; export * from './src/utils/misc';

View File

@ -1,6 +1,6 @@
{ {
"license": "MIT", "license": "MIT",
"version": "1.0.0-alpha.4", "version": "1.0.0",
"name": "@flaschengeist/api", "name": "@flaschengeist/api",
"author": "Tim Gröger <flaschengeist@wu5.de>", "author": "Tim Gröger <flaschengeist@wu5.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist", "homepage": "https://flaschengeist.dev/Flaschengeist",
@ -8,27 +8,16 @@
"bugs": { "bugs": {
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" "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", "main": "./src/index.ts",
"peerDependencies": { "peerDependencies": {
"@quasar/app": "^3.2.3", "@quasar/app-webpack": "^3.7.2",
"flaschengeist": "^2.0.0-alpha.1", "flaschengeist": "^2.0.0",
"pinia": "^2.0.4" "pinia": "^2.0.8"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/types": "^1.0.0-alpha.5", "@flaschengeist/types": "^1.0.0",
"@types/node": "^12.20.37", "@types/node": "^14.18.0",
"@typescript-eslint/eslint-plugin": "^5.4.0", "typescript": "^4.5.4"
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.1.1",
"eslint-webpack-plugin": "^3.1.1",
"prettier": "^2.4.1",
"typescript": "^4.4.4"
}, },
"prettier": { "prettier": {
"singleQuote": true, "singleQuote": true,

View File

@ -4,18 +4,18 @@ import { AxiosResponse } from 'axios';
import { api } from '../internal'; import { api } from '../internal';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { PersistentStorage } from '../utils/persistent'; import { PersistentStorage } from '../utils/persistent';
import { LocalStorage, SessionStorage } from 'quasar';
function loadToken() { function reviveSession() {
return PersistentStorage.get<string>('fg_token'); return PersistentStorage.get<FG.Session>('fg_session').then((s) => fixSession(s || undefined));
} }
function clearToken() { function clearPersistant() {
void PersistentStorage.remove('fg_token'); void PersistentStorage.remove('fg_session');
} }
export function saveToken(token?: string) { export function saveSession(session?: FG.Session) {
if (token === undefined) return clearToken(); if (session === undefined) return clearPersistant();
PersistentStorage.set('fg_token', token).catch(() => PersistentStorage.set('fg_session', session).catch(() =>
console.error('Could not save token to storage') console.error('Could not save token to storage')
); );
} }
@ -38,6 +38,10 @@ export const useMainStore = defineStore({
if (this.user === undefined) throw 'Not logged in, this should not be called'; if (this.user === undefined) throw 'Not logged in, this should not be called';
return this.user; return this.user;
}, },
currentSession(): FG.Session {
if (this.session === undefined) throw 'Not logged in, this should not be called';
return this.session;
},
permissions(): string[] { permissions(): string[] {
return this.user?.permissions || []; return this.user?.permissions || [];
}, },
@ -52,9 +56,9 @@ export const useMainStore = defineStore({
const userStore = useUserStore(); const userStore = useUserStore();
try { try {
const token = await loadToken(); this.session = await reviveSession();
if (token !== null) { if (this.session !== undefined) {
this.session = await sessionStore.getSession(token); this.session = await sessionStore.getSession(this.session.token);
if (this.session !== undefined) this.user = await userStore.getUser(this.session.userid); if (this.session !== undefined) this.user = await userStore.getUser(this.session.userid);
} }
} catch (error) { } catch (error) {
@ -63,11 +67,14 @@ export const useMainStore = defineStore({
}, },
async login(userid: string, password: string) { async login(userid: string, password: string) {
const userStore = useUserStore();
try { try {
const { data } = await api.post<FG.Session>('/auth', { userid, password }); const { data } = await api.post<FG.Session>('/auth', { userid, password });
this.session = fixSession(data); this.session = fixSession(data);
this.user = await userStore.getUser(data.userid, true);
return true; return true;
} catch ({ response }) { } catch ({ response }) {
this.handleLoggedOut();
return (<AxiosResponse | undefined>response)?.status || false; return (<AxiosResponse | undefined>response)?.status || false;
} }
}, },
@ -146,7 +153,9 @@ export const useMainStore = defineStore({
handleLoggedOut() { handleLoggedOut() {
this.$reset(); this.$reset();
void clearToken(); void clearPersistant();
LocalStorage.clear();
SessionStorage.clear();
}, },
}, },
}); });

View File

@ -1,84 +1,195 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { api } from '../internal'; import { api } from '../internal';
import { isAxiosError, useMainStore } from '.'; import { isAxiosError, useMainStore } from '.';
import { DisplayNameMode } from '@flaschengeist/users';
export function fixUser(u?: FG.User) { export function fixUser(u?: FG.User) {
return !u ? u : Object.assign(u, { birthday: u.birthday ? new Date(u.birthday) : undefined }); 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({ export const useUserStore = defineStore({
id: 'users', id: 'users',
state: () => ({ state: () => ({
roles: [] as FG.Role[], roles: [] as FG.Role[],
users: [] as FG.User[],
permissions: [] as FG.Permission[], permissions: [] as FG.Permission[],
_dirty_users: true, userSettings: {} as FG.UserSettings,
_dirty_roles: true, // 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: { actions: {
/** Simply filter all users by ID */
findUser(userid: string) { 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) { async getUser(userid: string, force = false) {
const idx = this.users.findIndex((user) => user.userid === userid); const idx = this._users.findIndex((user) => user.userid === userid);
if (force || this._dirty_users || idx === -1) { if (force || idx === -1 || isDirty(this._dirty_users)) {
try { try {
const { data } = await api.get<FG.User>(`/users/${userid}`); const { data } = await api.get<FG.User>(`/users/${userid}`);
fixUser(data); fixUser(data);
if (idx === -1) this.users.push(data); if (idx === -1) this._users.push(data);
else this.users[idx] = data; else this._users[idx] = data;
return data; return data;
} catch (error) { } catch (error) {
// Ignore 404, throw all other // Ignore 404, throw all other
if (!isAxiosError(error, 404)) throw error; if (!isAxiosError(error, 404)) throw error;
} }
} else { } 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) { async getUsers(force = false) {
if (force || this._dirty_users) { if (force || isDirty(this._dirty_users)) {
const { data } = await api.get<FG.User[]>('/users'); const { data } = await api.get<FG.User[]>('/users');
data.forEach(fixUser); data.forEach(fixUser);
this.users = data; this._users = data;
this._dirty_users = false; 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) { async updateUser(user: FG.User) {
await api.put(`/users/${user.userid}`, 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(); const mainStore = useMainStore();
if (user.userid === mainStore.user?.userid) mainStore.user = user; 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) { async createUser(user: FG.User) {
const { data } = await api.post<FG.User>('/users', user); const { data } = await api.post<FG.User>('/users', user);
this.users.push(data); this._users.push(<FG.User>fixUser(data));
return 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(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
await api.post(`/users/${user.userid}/avatar`, formData, { await api.post(`/users/${user}/avatar`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}, },
async deleteAvatar(user: FG.User) { /** Delete avatar of an user
await api.delete(`/users/${user.userid}/avatar`); * @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) { async getPermissions(force = false) {
if (force || this.permissions.length === 0) { if (force || this.permissions.length === 0) {
const { data } = await api.get<FG.Permission[]>('/roles/permissions'); const { data } = await api.get<FG.Permission[]>('/roles/permissions');
@ -87,39 +198,81 @@ export const useUserStore = defineStore({
return this.permissions; 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) { async getRoles(force = false) {
if (force || this._dirty_roles) { if (force || isDirty(this._dirty_roles)) {
const { data } = await api.get<FG.Role[]>('/roles'); const { data } = await api.get<FG.Role[]>('/roles');
this.roles = data; this.roles = data;
this._dirty_roles = false; this._dirty_roles = Date.now();
} }
return this.roles; 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) { async updateRole(role: FG.Role) {
try { await api.put(`/roles/${role.id}`, role);
await api.put(`/roles/${role.id}`, role);
} catch (error) {
console.warn(error);
}
this._updatePermission(role);
},
_updatePermission(role: FG.Role) {
const idx = this.roles.findIndex((r) => r.id === role.id); const idx = this.roles.findIndex((r) => r.id === role.id);
if (idx != -1) this.roles[idx] = role; 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) { async newRole(role: FG.Role) {
const { data } = await api.post<FG.Role>('/roles', role); const { data } = await api.post<FG.Role>('/roles', role);
this.roles.push(data); this.roles.push(data);
return 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) { async deleteRole(role: FG.Role | number) {
await api.delete(`/roles/${typeof role === 'number' ? role : role.id}`); if (typeof role === 'object') role = role.id;
this.roles = this.roles.filter((r) => r.id !== (typeof role == 'number' ? 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

@ -1,35 +1,35 @@
import { LocalStorage, Platform } from 'quasar'; import { LocalStorage, Platform } from 'quasar';
import { Storage } from '@capacitor/storage'; import { Preferences } from '@capacitor/preferences';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type PersitentTypes = Date | RegExp | number | boolean | string | object; type PersitentTypes = Date | RegExp | number | boolean | string | object;
export class PersistentStorage { export class PersistentStorage {
static clear() { static clear() {
if (Platform.is.capacitor) return Storage.clear(); if (Platform.is.capacitor) return Preferences.clear();
else return Promise.resolve(LocalStorage.clear()); else return Promise.resolve(LocalStorage.clear());
} }
static remove(key: string) { static remove(key: string) {
if (Platform.is.capacitor) return Storage.remove({ key: key }); if (Platform.is.capacitor) return Preferences.remove({ key: key });
else return Promise.resolve(LocalStorage.remove(key)); else return Promise.resolve(LocalStorage.remove(key));
} }
static set(key: string, value: PersitentTypes) { static set(key: string, value: PersitentTypes) {
if (Platform.is.capacitor) return Storage.set({ key, value: JSON.stringify(value) }); if (Platform.is.capacitor) return Preferences.set({ key, value: JSON.stringify(value) });
else return Promise.resolve(LocalStorage.set(key, value)); else return Promise.resolve(LocalStorage.set(key, value));
} }
static get<T extends PersitentTypes>(key: string) { static get<T extends PersitentTypes>(key: string) {
if (Platform.is.capacitor) if (Platform.is.capacitor)
return Storage.get({ key }).then((v) => return Preferences.get({ key }).then((v) =>
v.value === null ? null : (JSON.parse(v.value) as T) v.value === null ? null : (JSON.parse(v.value) as T)
); );
else return Promise.resolve(LocalStorage.getItem<T>(key)); else return Promise.resolve(LocalStorage.getItem<T>(key));
} }
static keys() { static keys() {
if (Platform.is.capacitor) return Storage.keys().then((v) => v.keys); if (Platform.is.capacitor) return Preferences.keys().then((v) => v.keys);
else return Promise.resolve(LocalStorage.getAllKeys()); 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

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

View File

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

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"version": "2.0.0-alpha.1", "version": "2.1.0",
"productName": "flaschengeist-frontend", "productName": "flaschengeist-frontend",
"name": "flaschengeist", "name": "flaschengeist",
"author": "Tim Gröger <flaschengeist@wu5.de>", "author": "Tim Gröger <flaschengeist@wu5.de>",
@ -11,33 +11,41 @@
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
}, },
"scripts": { "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" "lint": "eslint --ext .js,.ts,.vue ./src ./api"
}, },
"dependencies": { "dependencies": {
"@capacitor/storage": "^1.2.3", "@flaschengeist/api": "^1.0.0",
"@flaschengeist/api": "file:./api", "@flaschengeist/balance": "^1.0.0",
"@flaschengeist/users": "^1.0.0-alpha.1", "@flaschengeist/pricelist-old": "^1.0.0",
"axios": "^0.24.0", "@flaschengeist/schedule": "^1.0.0",
"pinia": "^2.0.4", "@flaschengeist/users": "^1.0.0",
"quasar": "^2.3.3" "axios": "^1.4.0",
"pinia": "^2.0.8",
"quasar": "^2.11.10",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/types": "^1.0.0-alpha.6", "@capacitor/core": "^5.0.0",
"@quasar/app": "^3.2.3", "@capacitor/preferences": "^5.0.0",
"@quasar/extras": "^1.12.1", "@flaschengeist/types": "^1.0.0",
"@types/node": "^14.17.34", "@quasar/app-webpack": "^3.7.2",
"@quasar/extras": "^1.16.3",
"@types/node": "^14.18.0",
"@types/webpack": "^5.28.0", "@types/webpack": "^5.28.0",
"@types/webpack-env": "^1.16.3", "@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.4.0", "@typescript-eslint/parser": "^5.8.0",
"eslint": "^8.3.0", "@vue/devtools": "^6.5.0",
"eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.1.1", "eslint-plugin-prettier": "^4.0.0",
"eslint-webpack-plugin": "^3.1.1", "eslint-plugin-vue": "^9.14.1",
"modify-source-webpack-plugin": "^3.0.0", "eslint-webpack-plugin": "^4.0.1",
"prettier": "^2.4.1", "modify-source-webpack-plugin": "^4.1.0",
"typescript": "^4.5.2", "prettier": "^2.5.1",
"typescript": "^4.5.4",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"prettier": { "prettier": {
@ -58,7 +66,8 @@
], ],
"cordova": [ "cordova": [
"iOS >= 13.0", "iOS >= 13.0",
"Android >= 76" "Android >= 76",
"ChromeAndroid >= 76"
] ]
}, },
"engines": { "engines": {

View File

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

View File

@ -8,10 +8,23 @@
/* eslint-env node */ /* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const ESLintPlugin = require('eslint-webpack-plugin') const ESLintPlugin = require('eslint-webpack-plugin');
const { ModifySourcePlugin } = require('modify-source-webpack-plugin') const { ModifySourcePlugin, ReplaceOperation } = require('modify-source-webpack-plugin');
const { configure } = require('quasar/wrappers') 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 */) { module.exports = configure(function (/* ctx */) {
return { return {
@ -22,7 +35,7 @@ module.exports = configure(function (/* ctx */) {
enabled: true, enabled: true,
files: './src/**/*.{ts,tsx,js,jsx,vue}', files: './src/**/*.{ts,tsx,js,jsx,vue}',
}, },
} },
}, },
// https://quasar.dev/quasar-cli/prefetch-feature // https://quasar.dev/quasar-cli/prefetch-feature
@ -31,7 +44,7 @@ module.exports = configure(function (/* ctx */) {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://quasar.dev/quasar-cli/boot-files // 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 // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
css: ['app.scss'], css: ['app.scss'],
@ -43,9 +56,9 @@ module.exports = configure(function (/* ctx */) {
// 'ionicons-v5', // 'ionicons-v5',
// 'line-awesome', // 'line-awesome',
// 'material-icons', // 'material-icons',
'mdi-v6', 'mdi-v7',
// 'themify', // 'themify',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both! // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it '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 // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
build: { build: {
vueRouterMode: 'history', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
//publicPath: 'flaschengeist2',
// transpile: false, // transpile: false,
// Add dependencies for transpiling with Babel (Array of string/regex) // Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled). // (from node_modules, which are by default not transpiled).
// Applies only if "transpile" is set to true. // Applies only if "transpile" is set to true.
@ -71,33 +83,29 @@ module.exports = configure(function (/* ctx */) {
// https://quasar.dev/quasar-cli/handling-webpack // https://quasar.dev/quasar-cli/handling-webpack
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpack (chain) { chainWebpack(chain) {
chain.plugin('eslint-webpack-plugin') chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [
.use(ESLintPlugin, [{ {
extensions: [ 'ts', 'js', 'vue' ], extensions: ['ts', 'js', 'vue'],
exclude: ['node_modules', 'src-capacitor'] exclude: ['node_modules', 'src-capacitor'],
}]) },
chain.plugin('modify-source-webpack-plugin') ]);
.use(ModifySourcePlugin, [{ chain.plugin('modify-source-webpack-plugin').use(ModifySourcePlugin, [
{
rules: [ rules: [
{ {
test: /plugins\.ts$/, test: /plugins\.ts$/,
modify: (src, filename) => { operations: [operation()],
const custom_plgns = require('./plugin.config.js') },
const required_plgns = require('./src/vendor-plugin.config.js') ],
return src.replace(/\/\* *INSERT_PLUGIN_LIST *\*\//, },
[...custom_plgns, ...required_plgns].map(v => `import("${v}").catch(() => "${v}")`) ]);
.join(','))
}
}
]
}])
chain.merge({ chain.merge({
snapshot: { snapshot: {
managedPaths: [] managedPaths: [],
} },
}) });
} },
}, },
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer // 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, https: false,
port: 8080, port: 8080,
open: false, // opens browser window automatically 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 // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
@ -117,8 +125,8 @@ module.exports = configure(function (/* ctx */) {
loadingBar: { loadingBar: {
position: 'top', position: 'top',
color: 'warning', color: 'warning',
size: '5px' size: '5px',
} },
}, },
// For special cases outside of where the auto-import stategy can have an impact // For special cases outside of where the auto-import stategy can have an impact
@ -129,14 +137,7 @@ module.exports = configure(function (/* ctx */) {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [ plugins: ['LocalStorage', 'SessionStorage', 'Dialog', 'Loading', 'Notify', 'LoadingBar'],
'LocalStorage',
'SessionStorage',
'Dialog',
'Loading',
'Notify',
'LoadingBar'
]
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
@ -145,7 +146,7 @@ module.exports = configure(function (/* ctx */) {
// https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr // https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: { ssr: {
pwa: false pwa: false,
}, },
// https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa // https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa
@ -164,20 +165,20 @@ module.exports = configure(function (/* ctx */) {
{ {
src: 'flaschengeist-logo.svg', src: 'flaschengeist-logo.svg',
sizes: 'any', sizes: 'any',
type: 'image/svg+xml' type: 'image/svg+xml',
}, },
{ {
src: 'favicon-128x128.png', src: 'favicon-128x128.png',
sizes: '128x128', sizes: '128x128',
type: 'image/png' type: 'image/png',
}, },
{ {
src: 'favicon-256x256.png', src: 'favicon-256x256.png',
sizes: '256x256', sizes: '256x256',
type: 'image/png' type: 'image/png',
}, },
] ],
} },
}, },
// Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova // Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
@ -187,7 +188,7 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor // Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
capacitor: { capacitor: {
hideSplashscreen: true hideSplashscreen: true,
}, },
// Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron // Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
@ -196,13 +197,11 @@ module.exports = configure(function (/* ctx */) {
packager: { packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store // OS X / Mac App Store
// appBundleId: '', // appBundleId: '',
// appCategoryType: '', // appCategoryType: '',
// osxSign: '', // osxSign: '',
// protocol: 'myapp://path', // protocol: 'myapp://path',
// Windows only // Windows only
// win32metadata: { ... } // win32metadata: { ... }
}, },
@ -210,16 +209,19 @@ module.exports = configure(function (/* ctx */) {
builder: { builder: {
// https://www.electron.build/configuration/configuration // https://www.electron.build/configuration/configuration
appId: 'flaschengeist-frontend' appId: 'flaschengeist-frontend',
}, },
// More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration // More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration
nodeIntegration: true, nodeIntegration: true,
extendWebpack (/* cfg */) { extendWebpack(/* cfg */) {
// do something with Electron main process Webpack cfg // do something with Electron main process Webpack cfg
// chainWebpack also available besides this extendWebpack // chainWebpack also available besides this extendWebpack
} },
} },
} bin: {
linuxAndroidStudio: '/home/crimsen/.local/share/JetBrains/Toolbox/scripts/studio',
},
};
}); });

View File

@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED, // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag"; import 'quasar/dist/types/feature-flag';
declare module "quasar/dist/types/feature-flag" { declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags { interface QuasarFeatureFlags {
capacitor: true; capacitor: true;
} }

View File

@ -4,6 +4,9 @@
"bundledWebRuntime": false, "bundledWebRuntime": false,
"npmClient": "yarn", "npmClient": "yarn",
"webDir": "www", "webDir": "www",
"android": {
"minWebViewVersion": 71
},
"ios": { "ios": {
"allowsLinkPreview": false "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

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

View File

@ -1,18 +1,31 @@
/**
* This boot file registers interceptors for axios
*/
import { useMainStore, api } from '@flaschengeist/api'; import { useMainStore, api } from '@flaschengeist/api';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import config from 'src/config'; import config from 'src/config';
import { clone } from '@flaschengeist/api'; import { clone } from '@flaschengeist/api';
function minify(o: unknown, cloned = false) { /**
if (!cloned) o = clone(o); * 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 o === 'object') { if (typeof entity === 'object') {
const obj = o as { [index: string]: unknown }; const obj = entity as { [index: string]: unknown };
for (const prop in obj) { for (const prop in obj) {
if (obj.hasOwnProperty(prop) && !!obj[prop]) { if (obj.hasOwnProperty(prop) && !!obj[prop]) {
if (Array.isArray(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)); obj[prop] = (<Array<unknown>>obj[prop]).map((v) => minify(v, true));
} else if ( } else if (
typeof obj[prop] === 'object' && typeof obj[prop] === 'object' &&
@ -26,12 +39,12 @@ function minify(o: unknown, cloned = false) {
} }
return obj; return obj;
} }
return o; return entity;
} }
export default boot(({ router }) => { export default boot(({ router }) => {
// Persisted value is read in plugins.ts boot file! // Persisted value is read in plugins.ts boot file!
api.defaults.baseURL = config.baseURL; if (api.defaults.baseURL === undefined) api.defaults.baseURL = config.baseURL;
/*** /***
* Intercept requests * Intercept requests
@ -41,7 +54,9 @@ export default boot(({ router }) => {
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const store = useMainStore(); const store = useMainStore();
if (store.session?.token) { 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 // Minify JSON requests
if ( if (
@ -79,12 +94,11 @@ export default boot(({ router }) => {
query: { redirect: next }, query: { redirect: next },
}); });
} else if (e.response && e.response.status == 401) { } else if (e.response && e.response.status == 401) {
void store.handleLoggedOut(); store.handleLoggedOut();
if (current.name !== 'login') { if (current.name != 'login') {
await router.push({ await router.push({
name: 'login', name: 'login',
params: { logout: 'logout' }, query: { redirect: current.fullPath },
query: { redirect: current.path },
}); });
} }
} }
@ -93,5 +107,3 @@ export default boot(({ router }) => {
} }
); );
}); });
export { api };

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 { useMainStore, hasPermissions } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import { RouteRecord } from 'vue-router';
export default boot(({ 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(); 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 '/in/...' requires to be authenticated
// Secured area (LOGIN REQUIRED) if (to.path.startsWith('/in') && (!store.session || store.session.expires <= new Date())) {
// Check login is ok store.handleLoggedOut();
if (!store.session || store.session.expires <= new Date()) { return { name: 'login' };
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,10 +1,5 @@
import { Notify, Platform } from 'quasar';
import { api } from 'src/boot/axios';
import { boot } from 'quasar/wrappers';
import routes from 'src/router/routes';
import { RouteRecordRaw } from 'vue-router';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
import { PersistentStorage } from '@flaschengeist/api'; import { RouteRecordRaw } from 'vue-router';
/**************************************************** /****************************************************
******** Internal area for some magic ************** ******** Internal area for some magic **************
@ -26,21 +21,6 @@ const PLUGINS = <Array<Promise<ImportPlgn>>>[
/*INSERT_PLUGIN_LIST*/ /*INSERT_PLUGIN_LIST*/
]; ];
interface BackendPlugin {
permissions: string[];
version: string;
}
interface BackendPlugins {
[key: string]: BackendPlugin;
}
interface Backend {
plugins: BackendPlugins;
version: string;
}
export { Backend };
// Handle Notifications // Handle Notifications
export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note; export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note;
@ -212,7 +192,7 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
function loadPlugin( function loadPlugin(
loadedPlugins: FG_Plugin.Flaschengeist, loadedPlugins: FG_Plugin.Flaschengeist,
plugin: FG_Plugin.Plugin, plugin: FG_Plugin.Plugin,
backend: Backend backend: FG.Backend
) { ) {
// Check if already loaded // Check if already loaded
if (loadedPlugins.plugins.findIndex((p) => p.id === plugin.id) !== -1) return true; if (loadedPlugins.plugins.findIndex((p) => p.id === plugin.id) !== -1) return true;
@ -254,6 +234,12 @@ function loadPlugin(
Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets); 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({ loadedPlugins.plugins.push({
id: plugin.id, id: plugin.id,
name: plugin.name, name: plugin.name,
@ -264,95 +250,46 @@ function loadPlugin(
return true; return true;
} }
async function loadBaseUrl() { export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRaw[]) {
return PersistentStorage.get<string>('baseURL').then((url) => {
if (url !== null) api.defaults.baseURL = url;
console.log('loaded: ', url, api.defaults.baseURL);
});
}
/**
* Loading backend information
* @returns Backend object or null
*/
async function getBackend() {
try {
const { data } = await api.get<Backend>('/');
if (!data || typeof data !== 'object' || !('plugins' in data))
throw Error('Invalid backend response received');
return data;
} catch (e) {
console.error('Loading backend', e);
return null;
}
}
/**
* Boot file, load all required plugins, check for dependencies
*/
export default boot(async ({ router, app }) => {
await loadBaseUrl();
const backend = await getBackend();
if (backend === null) {
router.isReady().finally(() => {
if (Platform.is.capacitor) void router.push({ name: 'setup_backend' });
else void router.push({ name: 'offline', params: { refresh: 1 } });
});
return;
}
const loadedPlugins: FG_Plugin.Flaschengeist = { const loadedPlugins: FG_Plugin.Flaschengeist = {
routes, routes: baseRoutes,
plugins: [], plugins: [],
menuLinks: [], menuLinks: [],
shortcuts: [], shortcuts: [],
outerShortcuts: [], outerShortcuts: [],
widgets: [], widgets: [],
settingWidgets: [],
}; };
try { // Wait for all plugins to be loaded
// Wait for all plugins to be loaded const results = await Promise.allSettled(PLUGINS);
const results = await Promise.allSettled(PLUGINS);
// Check if loaded successfully // Check if loaded successfully
results.forEach((result) => { results.forEach((result) => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
throw <string>result.reason; throw <string>result.reason;
} else { } else {
if ( if (
!( !(
validatePlugin(result.value.default) && validatePlugin(result.value.default) &&
loadPlugin(loadedPlugins, result.value.default, backend) loadPlugin(loadedPlugins, result.value.default, backend)
)
) )
throw result.value.default.id; )
} throw result.value.default.id;
}); }
} catch (reason) { });
const id = <string>reason;
void router.push({ name: 'error' });
Notify.create({
type: 'negative',
message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${id}!`,
timeout: 10000,
progress: true,
});
}
// Sort widgets by priority // Sort widgets by priority
/** @todo Remove priority with first beta */ /** @todo Remove priority with first beta */
loadedPlugins.widgets.sort( loadedPlugins.widgets.sort(
(a, b) => <number>(b.order || b.priority) - <number>(a.order || a.priority) (a, b) => <number>(b.order || b.priority) - <number>(a.order || a.priority)
); );
/** @todo Can be cleaned up with first beta */ /** @todo Can be cleaned up with first beta */
loadedPlugins.menuLinks.sort((a, b) => { loadedPlugins.menuLinks.sort((a, b) => {
const diff = a.order && b.order ? b.order - a.order : 0; const diff = a.order && b.order ? b.order - a.order : 0;
return diff ? diff : a.title.toString().localeCompare(b.title.toString()); return diff ? diff : a.title.toString().localeCompare(b.title.toString());
}); });
// Add loaded routes to router return loadedPlugins;
loadedPlugins.routes.forEach((route) => router.addRoute(route)); }
// save plugins in VM-variable
app.provide('flaschengeist', loadedPlugins);
});

View File

@ -1,14 +1,9 @@
import { useMainStore, pinia } from '@flaschengeist/api'; /**
import { saveToken } from 'app/api'; * This boot file installs the global pinia instance
*/
import { pinia } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
export default boot(({ app }) => { export default boot(({ app }) => {
app.use(pinia); app.use(pinia);
const store = useMainStore();
store.init().finally(() => {
store.$subscribe((mutation, state) => {
saveToken(state.session?.token);
});
});
}); });

View File

@ -14,7 +14,7 @@
class="q-ma-xs" class="q-ma-xs"
title="Löschen" title="Löschen"
style="position: absolute; top: 0; right: 0; z-index: 999" style="position: absolute; top: 0; right: 0; z-index: 999"
@click="dismiss" @click.stop.prevent="dismiss"
/> />
<q-card-section class="q-pa-xs"> <q-card-section class="q-pa-xs">
<div class="text-overline">{{ dateString }}</div> <div class="text-overline">{{ dateString }}</div>
@ -34,7 +34,7 @@
flat flat
dense dense
size="sm" size="sm"
@click="accept" @click.stop.prevent="accept"
/> />
<q-btn <q-btn
v-if="modelValue.reject" v-if="modelValue.reject"
@ -44,7 +44,7 @@
flat flat
dense dense
size="sm" size="sm"
@click="reject" @click.stop.prevent="reject"
/> />
</q-card-actions> </q-card-actions>
</q-card> </q-card>

View File

@ -2,7 +2,7 @@
<q-layout view="hHh Lpr lff"> <q-layout view="hHh Lpr lff">
<q-header elevated class="bg-primary text-white"> <q-header elevated class="bg-primary text-white">
<q-toolbar> <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> <q-toolbar-title>
<router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit"> <router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit">
@ -40,7 +40,14 @@
<shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" /> <shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" />
</template> </template>
</drag> </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-toolbar>
</q-header> </q-header>
@ -49,21 +56,45 @@
side="left" side="left"
bordered bordered
:mini="leftDrawerMini" :mini="leftDrawerMini"
@click.capture="openMenu" @click.capture="openMenuMini"
> >
<!-- Plugins --> <!-- Plugins -->
<essential-expansion-link <q-scroll-area class="fit">
v-for="(entry, index) in mainLinks" <essential-expansion-link
:key="'plugin' + index" v-for="(entry, index) in mainLinks"
:entry="entry" :key="'plugin' + index"
@add-short-cut="addShortcut" :entry="entry"
/> @add-short-cut="addShortcut"
<q-separator /> />
<essential-link <q-separator />
v-for="(entry, index) in essentials" <essential-link
:key="'essential' + index" v-for="(entry, index) in essentials"
:entry="entry" :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-drawer>
<q-page-container> <q-page-container>
<router-view /> <router-view />
@ -106,7 +137,7 @@ export default defineComponent({
const mainStore = useMainStore(); const mainStore = useMainStore();
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist'); const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const leftDrawer = ref(!Platform.is.mobile); const leftDrawer = ref(!Platform.is.mobile);
const leftDrawerMini = ref(false); const leftDrawerMini = ref(true);
const mainLinks = flaschengeist?.menuLinks || []; const mainLinks = flaschengeist?.menuLinks || [];
const notifications = computed(() => mainStore.notifications.slice().reverse()); const notifications = computed(() => mainStore.notifications.slice().reverse());
const polling = ref(NaN); const polling = ref(NaN);
@ -119,9 +150,11 @@ export default defineComponent({
void mainStore.getShortcuts(); void mainStore.getShortcuts();
}); });
onBeforeUnmount(() => window.clearInterval(polling.value)); onBeforeUnmount(() => window.clearInterval(polling.value));
/*
function openMenu(event: { target: HTMLInputElement }) { 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 { else {
if (!leftDrawer.value || leftDrawerMini.value) { if (!leftDrawer.value || leftDrawerMini.value) {
leftDrawer.value = true; 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() { function logout() {
void router.push({ name: 'login', params: { logout: 'logout' } }); void router.push({ name: 'login', params: { logout: 'logout' } });
void mainStore.logout(); void mainStore.logout();
@ -195,12 +234,14 @@ export default defineComponent({
notifications, notifications,
noPermission, noPermission,
openMenu, openMenu,
openMenuMini,
remove, remove,
requestPermission, requestPermission,
useNative, useNative,
shortCuts, shortCuts,
addShortcut, addShortcut,
deleteShortcut, deleteShortcut,
platform: Platform,
}; };
}, },
}); });

View File

@ -1,46 +0,0 @@
<template>
<div class="row justify-center items-center content-center q-pa-md">
<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>Servereinstellung</q-toolbar-title>
</q-toolbar>
<q-card-section>
<q-form class="q-gutter-md" @submit="changeUrl">
<q-input v-model="server" filled label="Server" dense />
<q-btn dense color="primary" label="Speichern" type="submit" />
</q-form>
</q-card-section>
</q-card>
</div>
</template>
<script lang="ts">
import { useRouter } from 'vue-router';
import { notEmpty, PersistentStorage } from '@flaschengeist/api';
import { defineComponent, ref } from 'vue';
import { api } from 'boot/axios';
export default defineComponent({
name: 'PageBackend',
setup() {
const router = useRouter();
const server = ref(api.defaults.baseURL);
function changeUrl() {
if (server.value) {
void PersistentStorage.set('baseURL', server.value).then(() => {
console.log('uiuiui');
void router.push({ name: 'login' }).then(() => router.go(0));
});
}
}
return {
changeUrl,
notEmpty,
server,
};
},
});
</script>

View File

@ -2,9 +2,9 @@
<q-page <q-page
padding padding
style="grid-auto-rows: 1fr" 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" /> <component :is="item.widget" />
</div> </div>
</q-page> </q-page>

View File

@ -2,89 +2,146 @@
<q-page padding class="fit row justify-center items-center content-center"> <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-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 class="bg-primary text-white">
<q-toolbar-title> Login </q-toolbar-title> <q-toolbar-title>{{ backendSetup ? 'Servereinrichtung' : 'Login' }}</q-toolbar-title>
</q-toolbar> </q-toolbar>
<div v-if="!backendSetup">
<q-card-section> <q-card-section>
<q-form @submit="doLogin"> <q-form @submit="doLogin">
<q-input <q-input
v-model="userid" v-model="userid"
class="q-pa-md" class="q-pa-md"
filled filled
label="Benutzername oder E-Mail" label="Benutzername oder E-Mail"
autocomplete="username" autocomplete="username"
:rules="[notEmpty]" :rules="[notEmpty]"
tabindex="1" 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> <password-input
<div class="full-width row justify-end q-px-md q-pt-md"> v-model="password"
<q-btn label="Login" type="submit" color="primary" tabindex="3" /> class="q-px-md q-pt-md q-pb-none"
</div> filled
</q-form> label="Passwort"
</q-card-section> autocomplete="cureent-password"
<div class="row justify-end"> :rules="[notEmpty]"
<q-btn tabindex="2"
v-if="$q.platform.is.capacitor || $q.platform.is.electron" />
flat <div class="full-width row justify-end q-px-md">
round <q-btn
icon="mdi-menu-down" label="Passwort vergessen?"
:to="{ name: 'setup_backend' }" 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> </div>
<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="changeBackend">
<div class="text-h6">Backend einrichten</div>
<q-input v-model="server" filled label="Server" dense />
<q-btn dense color="primary" label="Speichern" type="submit" />
</q-form>
</q-card-section>
</div>
</q-slide-transition>
</q-card> </q-card>
</q-page> </q-page>
</template> </template>
<script lang="ts"> <script lang="ts">
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Loading, Notify } from 'quasar'; import { Loading, Notify, useQuasar } from 'quasar';
import { notEmpty, useMainStore, useUserStore } from '@flaschengeist/api'; import { api, notEmpty, PersistentStorage, useMainStore } from '@flaschengeist/api';
import { PasswordInput } from '@flaschengeist/api/components'; import { PasswordInput } from '@flaschengeist/api/components';
import { defineComponent, ref } from 'vue'; import { defineComponent, onMounted, ref } from 'vue';
import { useSessionStore } from 'app/api';
export default defineComponent({ export default defineComponent({
name: 'PageLogin', name: 'PageLogin',
components: { PasswordInput }, components: { PasswordInput },
setup() { props: {
backendSetup: {
type: Boolean,
default: false,
},
},
setup(props) {
const mainRoute = { name: 'dashboard' }; const mainRoute = { name: 'dashboard' };
const mainStore = useMainStore(); const mainStore = useMainStore();
const userStore = useUserStore(); const sessionStore = useSessionStore();
const router = useRouter(); const router = useRouter();
const quasar = useQuasar();
onMounted(() => {
if (mainStore.session) void router.replace(mainRoute);
});
/* Stuff for the real login page */ /* Stuff for the real login page */
const userid = ref(''); const userid = ref('');
const password = ref(''); const password = ref('');
const server = ref(api.defaults.baseURL);
const showBackendSetup = ref(!!props.backendSetup);
function changeBackend() {
if (server.value) {
void PersistentStorage.set('baseURL', server.value).then(() => {
showBackendSetup.value = false;
void router.go(0);
});
}
}
async function doLogin() { async function doLogin() {
Loading.show({ message: 'Du wirst angemeldet' }); Loading.show({ message: 'Du wirst angemeldet' });
const status = await mainStore.login(userid.value, password.value); const status = await mainStore.login(userid.value, password.value);
if (status === true) { if (status === true) {
mainStore.user = (await userStore.getUser(userid.value, true)) || undefined; // On capacitor we set the lifetime to at least two weeks to not annoy users.
const x = router.currentRoute.value.query['redirect']; if (quasar.platform.is.capacitor)
void router.push(typeof x === 'string' ? { path: x } : mainRoute); 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 { } else {
// Login failed, notify and reset form
password.value = ''; password.value = '';
if (status === 401) { if (status === 401) {
Notify.create({ Notify.create({
@ -129,11 +186,14 @@ export default defineComponent({
} }
return { return {
changeBackend,
doLogin, doLogin,
doReset, doReset,
notEmpty, notEmpty,
password, password,
userid, userid,
server,
showBackendSetup,
}; };
}, },
}); });

View File

@ -10,12 +10,18 @@ const routes: RouteRecordRaw[] = [
name: 'login', name: 'login',
path: 'login', path: 'login',
component: () => import('pages/Login.vue'), component: () => import('pages/Login.vue'),
props: true,
}, },
{ {
name: 'password_reset', name: 'password_reset',
path: 'reset', path: 'reset',
component: () => import('pages/Reset.vue'), component: () => import('pages/Reset.vue'),
}, },
{
path: '/setup-backend',
name: 'setup_backend',
redirect: { name: 'login', params: { backendSetup: 1 } },
},
{ {
name: 'about_out', name: 'about_out',
path: 'about', path: 'about',
@ -53,11 +59,6 @@ const routes: RouteRecordRaw[] = [
name: 'offline', name: 'offline',
component: () => import('pages/Offline.vue'), component: () => import('pages/Offline.vue'),
}, },
{
path: '/setup-backend',
name: 'setup_backend',
component: () => import('pages/Backend.vue'),
},
// Always leave this as last one, // Always leave this as last one,
// but you can also remove it // but you can also remove it
{ {

View File

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

View File

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