Use more logical seperation on user and session

* Seperated user and session more logical
* Fixed error with expired sessions
* Cache user only in SessionStore
* Use current backend responses
* Used prettier
This commit is contained in:
Ferdinand Thiessen 2020-11-04 23:53:10 +01:00
parent 245944b6a9
commit 4061d84ace
24 changed files with 307 additions and 305 deletions

View File

@ -103,7 +103,7 @@ module.exports = configure(function(ctx) {
// directives: [],
// Quasar plugins
plugins: ['LocalStorage', 'Loading']
plugins: ['LocalStorage', 'SessionStorage', 'Loading']
},
// animations: 'all', // --- includes all animations

View File

@ -7,6 +7,6 @@
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
name: 'App',
name: 'App'
});
</script>

View File

@ -16,9 +16,9 @@ export default boot<Store<StateInterface>>(({ Vue, store }) => {
axios.defaults.baseURL = config.baseURL;
axios.interceptors.request.use(config => {
const session = store.state.user.session;
if (session.token) {
config.headers = {'Authorization': 'Bearer ' + session.token};
const session = store.state.session.currentSession;
if (session?.token) {
config.headers = { Authorization: 'Bearer ' + session.token };
}
return config;
});

View File

@ -1,53 +1,49 @@
import { boot } from 'quasar/wrappers';
import { StateInterface } from 'src/store';
import { RouteRecord } from 'vue-router';
import { Store } from 'vuex'
import { Store } from 'vuex';
export default boot<Store<StateInterface>>(({ router, store }) => {
router.beforeEach((to, from, next) => {
store
.dispatch('user/loadFromLocalStorage')
.then(() => {
const user = store.state.user.user;
const session = store.state.user.session;
const user = store.state.user.currentUser;
const session = store.state.session.currentSession;
let permissions: string[] = [];
user.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
if (to.name != 'login') {
if (session.expires >= new Date() || session.token === '') {
store.dispatch('user/doLogout').catch(error => {
console.warn(error);
});
return next({ name: 'login', query: { redirect: to.fullPath } });
}
if (
to.matched.every((record: RouteRecord) => {
if (!('meta' in record) || !('permissions' in record.meta))
return true;
if (record.meta) {
if ((<{permissions: FG.Permission[]}>record.meta).permissions) {
return (<{permissions: FG.Permission[]}>record.meta).permissions.every((permission: string) => {
return permissions.includes(
permission
);
})
}
}
})
) {
next();
} else {
next({ name: 'login', query: { redirect: to.fullPath } });
}
} else {
next();
}
})
.catch(error => {
console.exception(error);
let permissions: string[] = [];
if (user) {
user.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
}
if (to.name != 'login') {
if (!session || session.expires <= new Date()) {
store.dispatch('session/logout').catch(error => {
console.warn(error);
});
return next({ name: 'login', query: { redirect: to.fullPath } });
}
if (
to.matched.every((record: RouteRecord) => {
if (!('meta' in record) || !('permissions' in record.meta))
return true;
if (record.meta) {
if ((<{ permissions: FG.Permission[] }>record.meta).permissions) {
return (<{ permissions: FG.Permission[] }>(
record.meta
)).permissions.every((permission: string) => {
return permissions.includes(permission);
});
}
}
})
) {
next();
} else {
next({ name: 'login', query: { redirect: to.fullPath } });
}
} else {
next();
}
});
});

View File

@ -1,5 +1,5 @@
const config = {
baseURL: '/api'
baseURL: '/api'
};
export default config;

111
src/flaschengeist.d.ts vendored
View File

@ -1,57 +1,58 @@
declare namespace FG {
interface Session {
expires: Date;
token: string;
lifetime: number;
browser: string;
platform: string;
}
interface User {
userid: string;
display_name: string;
firstname: string;
lastname: string;
mail: string;
roles: Array<Role>;
}
type Permission = string;
interface Role {
name: string;
permissions: Array<Permission>;
}
interface Transaction {
id: number;
time: Date;
amount: number;
sender_id: string;
receiver_id: string;
author_id: string;
}
interface Event {
id: number;
start: Date;
description?: any;
type: EventType;
slots: Array<EventSlot>;
}
interface EventSlot {
id: number;
start: Date;
end?: any;
jobs: Array<JobSlot>;
}
type EventType = string;
interface Job {
userid: string;
value: number;
}
interface JobSlot {
type: JobType;
users: Array<Job>;
required_jobs: number;
}
interface JobType {
id: number;
name: string;
}
interface Session {
expires: Date;
token: string;
lifetime: number;
browser: string;
platform: string;
userid: string;
}
interface User {
userid: string;
display_name: string;
firstname: string;
lastname: string;
mail: string;
roles: Array<Role>;
}
type Permission = string;
interface Role {
name: string;
permissions: Array<Permission>;
}
interface Transaction {
id: number;
time: Date;
amount: number;
sender_id: string;
receiver_id: string;
author_id: string;
}
interface Event {
id: number;
start: Date;
description?: any;
type: EventType;
slots: Array<EventSlot>;
}
interface EventSlot {
id: number;
start: Date;
end?: any;
jobs: Array<JobSlot>;
}
type EventType = string;
interface Job {
userid: string;
value: number;
}
interface JobSlot {
type: JobType;
users: Array<Job>;
required_jobs: number;
}
interface JobType {
id: number;
name: string;
}
}

View File

@ -1,24 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<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<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<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<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- DO NOT touch the following DIV -->
<div id="q-app"></div>
</body>
<link
rel="icon"
type="image/png"
sizes="128x128"
href="icons/favicon-128x128.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="icons/favicon-32x32.png"
/>
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- DO NOT touch the following DIV -->
<div id="q-app"></div>
</body>
</html>

View File

@ -14,7 +14,7 @@
<q-toolbar-title>
<q-avatar>
<img src="logo.svg"/>
<img src="logo.svg" />
</q-avatar>
<span class="gt-xs">
Flaschengeist
@ -31,7 +31,7 @@
:permissions="shortcut.permissions"
/>
</div>
<q-btn 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>
@ -54,7 +54,7 @@
:permissions="link.permissions"
/>
</q-list>
<q-separator/>
<q-separator />
<!-- Plugin functions -->
<!-- <router-view name="plugin-nav" /> -->
@ -81,7 +81,7 @@
/>
</div>
<q-separator/>
<q-separator />
<essential-link
v-for="(link, index) in links"
@ -94,7 +94,7 @@
</q-drawer>
<q-page-container>
<router-view/>
<router-view />
</q-page-container>
</q-layout>
</template>
@ -102,11 +102,11 @@
<script lang="ts">
import EssentialLink from 'components/navigation/EssentialLink.vue';
import ShortCutLink from 'components/navigation/ShortCutLink.vue';
import {Screen} from 'quasar';
import {defineComponent, ref, computed} from '@vue/composition-api';
import {Store} from 'vuex';
import {StateInterface} from 'src/store';
import {FG_Plugin} from 'src/plugins';
import { Screen } from 'quasar';
import { defineComponent, ref, computed } from '@vue/composition-api';
import { Store } from 'vuex';
import { StateInterface } from 'src/store';
import { FG_Plugin } from 'src/plugins';
const links = [
{
@ -140,7 +140,7 @@ declare module 'vue/types/vue' {
export default defineComponent({
name: 'MainLayout',
components: {EssentialLink, ShortCutLink},
components: { EssentialLink, ShortCutLink },
setup(_, ctx) {
const leftDrawer = ref(false);
@ -179,7 +179,7 @@ export default defineComponent({
function logout() {
const store = <Store<StateInterface>>ctx.root.$store;
store
.dispatch('user/logout', store.state.user.session.token)
.dispatch('session/logout', store.state.session.currentSession?.token)
.catch(error => {
console.warn(error);
});

View File

@ -1,5 +1,7 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div
class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"
>
<div>
<div style="font-size: 30vh">
404
@ -26,6 +28,6 @@
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
name: 'Error404',
name: 'Error404'
});
</script>

View File

@ -48,7 +48,7 @@ export default defineComponent({
function doLogin() {
console.log(userid.value, password.value);
ctx.root.$store
.dispatch('user/login', {
.dispatch('session/login', {
userid: userid.value,
password: password.value
})

4
src/plugins.d.ts vendored
View File

@ -13,7 +13,7 @@ declare namespace FG_Plugin {
title: string;
icon: string;
children?: PluginRouteConfig[];
meta?: {permissions?: string[]}
meta?: { permissions?: string[] };
}
interface Plugin {
@ -34,7 +34,7 @@ declare namespace FG_Plugin {
title: string;
link: string;
icon: string;
permissions?: string[]
permissions?: string[];
}
interface LoadedPlugin {

View File

@ -38,7 +38,8 @@ const mutations: MutationTree<BalanceInterface> = {
const actions: ActionTree<BalanceInterface, StateInterface> = {
getBalance({ commit, rootState }) {
axios
.get(`/users/${rootState.user.user.userid}/balance`)
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
.get(`/users/${rootState.user.currentUser?.userid}/balance`)
.then(({ data }: AxiosResponse<BalanceResponse>) => {
commit('setBalance', data.balance);
commit('setCredit', data.credit);
@ -50,7 +51,8 @@ const actions: ActionTree<BalanceInterface, StateInterface> = {
},
getLimit({ rootState }) {
axios
.get(`/users/${rootState.user.user.userid}/balance/limit`)
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
.get(`/users/${rootState.user.currentUser?.userid}/balance/limit`)
.then(({ data }) => {
console.log(data);
})
@ -60,7 +62,10 @@ const actions: ActionTree<BalanceInterface, StateInterface> = {
},
changeBalance({ rootState, dispatch }, amount: number) {
axios
.put(`/users/${rootState.user.user.userid}/balance`, <{ amount: number }>{
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
.put(`/users/${rootState.user.currentUser?.userid}/balance`, <
{ amount: number }
>{
amount: amount
})
.then(() => {

View File

@ -100,14 +100,12 @@ export default defineComponent({
setup(_, { root }) {
const store = <Store<StateInterface>>root.$store;
const user = computed<FG.User>(() => {
return store.state.user.user;
});
const user = computed(() => <FG.User>store.state.user.currentUser);
const firstname = ref(user.value.firstname);
const lastname = ref(user.value.lastname);
const mail = ref(user.value.mail);
const display_name = ref(user.value.display_name);
const firstname = ref(user.value?.firstname);
const lastname = ref(user.value?.lastname);
const mail = ref(user.value?.mail);
const display_name = ref(user.value?.display_name);
const password = ref('');
const new_password = ref('');

View File

@ -76,12 +76,12 @@ export default defineComponent({
}
function deleteSession(token: string) {
store.dispatch('sessions/deleteSession', token).catch(error => {
store.dispatch('session/deleteSession', token).catch(error => {
console.warn(error);
});
}
function isThisSession(token: string) {
return store.state.user.session.token == token;
return store.state.session.currentSession?.token === token;
}
return {

View File

@ -2,3 +2,8 @@ export interface LoginData {
userid: string;
password: string;
}
export interface LoginResponse {
user: FG.User;
session: FG.Session;
}

View File

@ -34,7 +34,7 @@ export default defineComponent({
const checkMain = computed(() => {
return root.$route.matched.length == 2;
});
return { checkMain, mainRoutes};
return { checkMain, mainRoutes };
}
});
</script>

View File

@ -43,11 +43,11 @@ export default defineComponent({
const store = <Store<StateInterface>>root.$store;
onBeforeMount(() => {
store.dispatch('sessions/getSessions').catch(error => {
store.dispatch('session/getSessions').catch(error => {
console.warn(error);
});
});
const sessions = computed(() => store.state.sessions.sessions);
const sessions = computed(() => store.state.session.sessions);
function showRootGetters() {
console.log(sessions.value);
@ -55,7 +55,7 @@ export default defineComponent({
const sessionsLoading = computed(
() =>
store.state.sessions.loading ||
store.state.session.loading ||
store.state.user.getUserLoading ||
store.state.user.updateUserLoading
);

View File

@ -27,9 +27,9 @@ export default defineComponent({
setup(_, { root }) {
const store = <Store<StateInterface>>root.$store;
const userObj = computed(() => store.state.user.user);
const userObj = computed(() => store.state.user.currentUser);
const sessionObj = computed(() => store.state.user.session);
const sessionObj = computed(() => store.state.session.currentSession);
return { userObj, sessionObj };
}

View File

@ -12,7 +12,7 @@ const plugin: FG_Plugin.Plugin = {
version: '0.0.1',
store: new Map<string, Module<any, StateInterface>>([
['user', userStore],
['sessions', sessionsStore]
['session', sessionsStore]
])
};

View File

@ -1,20 +1,33 @@
import { Module, MutationTree, ActionTree, GetterTree } from 'vuex';
import { LoginData, LoginResponse } from 'src/plugins/user/models';
import { StateInterface } from 'src/store';
import { axios } from 'src/boot/axios';
import { AxiosResponse } from 'axios';
import { Router } from 'src/router';
import { LocalStorage, Loading } from 'quasar';
export interface SessionInterface {
currentSession?: FG.Session;
sessions: FG.Session[];
loading: boolean;
}
const state: SessionInterface = {
sessions: [],
currentSession:
LocalStorage.getItem<FG.Session>('currentSession') || undefined,
loading: false
};
const mutations: MutationTree<SessionInterface> = {
setCurrentSession(state, session: FG.Session) {
LocalStorage.set('currentSession', session);
state.currentSession = session;
},
clearCurrentSession(state) {
LocalStorage.remove('currentSession');
state.currentSession = undefined;
},
setSessions(state, sessions: FG.Session[]) {
state.sessions = sessions;
},
@ -24,40 +37,75 @@ const mutations: MutationTree<SessionInterface> = {
};
const actions: ActionTree<SessionInterface, StateInterface> = {
getSessions({ commit, rootState, dispatch }) {
commit('setLoading', true);
axios
.get('/auth')
.then((response: AxiosResponse<FG.Session[]>) => {
console.log(response.data);
response.data.forEach(session => {
session.expires = new Date(session.expires);
});
commit('setSessions', response.data);
const currentSession = response.data.find((session: FG.Session) => {
return session.token === rootState.user.session.token;
});
if (currentSession) {
void dispatch('user/setSession', currentSession, { root: true });
}
login({ commit }, data: LoginData) {
Loading.show({
message: 'Du wirst angemeldet'
});
void axios
.post('/auth', data)
.then((response: AxiosResponse<LoginResponse>) => {
response.data.session.expires = new Date(response.data.session.expires);
commit('setCurrentSession', response.data.session);
commit('user/setCurrentUser', response.data.user, { root: true });
void Router.push({ name: 'user-main' });
})
.catch(error => {
console.exception(error);
})
.finally(() => {
commit('setLoading', false);
Loading.hide();
});
},
deleteSession({ commit, dispatch, rootState }, token: string) {
/**
* Logout from current session
*/
logout({ dispatch, rootState }) {
Loading.show({ message: 'Session wird abgemeldet' });
dispatch('deleteSession', rootState.session.currentSession?.token).finally(
() => {
Loading.hide();
}
);
},
/**
* Delete a given session
*/
deleteSession({ commit, rootState }, token: string | null) {
if (token === null) return;
commit('setLoading', true);
axios
.delete(`/auth/${token}`)
.then(() => {
if (token === rootState.user.session.token) {
void dispatch('user/setSession', null, { root: true });
Router.go(0);
if (token === rootState.session.currentSession?.token) {
commit('clearCurrentSession');
commit('user/clearCurrentUser', null, { root: true });
void Router.push({ name: 'login' });
} else {
void dispatch('getSessions');
commit('getSessions');
}
})
.finally(() => {
commit('setLoading', false);
});
},
/**
* Get all sessions from current User
*/
getSessions({ commit, state, dispatch }) {
commit('setLoading', true);
axios
.get('/auth')
.then((response: AxiosResponse<FG.Session[]>) => {
response.data.forEach(session => {
session.expires = new Date(session.expires);
});
commit('setSessions', response.data);
const currentSession = response.data.find((session: FG.Session) => {
return session.token === state.currentSession?.token;
});
if (currentSession) {
void dispatch('setCurrentSession', currentSession);
}
})
.catch(error => {
@ -70,6 +118,9 @@ const actions: ActionTree<SessionInterface, StateInterface> = {
};
const getters: GetterTree<SessionInterface, StateInterface> = {
currentSession(state) {
return state.currentSession;
},
sessions(state) {
return state.sessions;
},

View File

@ -1,131 +1,81 @@
import { Module, MutationTree, ActionTree, GetterTree } from 'vuex';
import { StateInterface } from 'src/store';
import { axios } from 'boot/axios';
import { LoginData } from 'src/plugins/user/models';
import { AxiosResponse } from 'axios';
import { LocalStorage, Loading } from 'quasar';
import { Router } from 'src/router';
import { SessionStorage } from 'quasar';
export interface UserStateInterface extends LoginResponse {
export interface UserStateInterface {
updateUserLoading: boolean;
getUserLoading: boolean;
currentUser?: FG.User;
users: FG.User[];
}
export interface LoginResponse {
user: FG.User;
session: FG.Session;
}
const empty_session: FG.Session = {
browser: '',
expires: new Date(),
lifetime: -1,
platform: '',
token: ''
};
const empty_user: FG.User = {
display_name: '',
firstname: '',
lastname: '',
mail: '',
roles: [],
userid: ''
};
const state: UserStateInterface = {
user: empty_user,
session: empty_session,
users: [],
currentUser: SessionStorage.getItem<FG.User>('currentUser') || undefined,
updateUserLoading: false,
getUserLoading: false
};
const mutations: MutationTree<UserStateInterface> = {
setUser(state, data: FG.User) {
state.user = data;
setCurrentUser(state, data: FG.User) {
SessionStorage.set('currentUser', data);
state.currentUser = data;
},
setSession(state, data: FG.Session) {
state.session = data;
clearCurrentUser(state) {
SessionStorage.remove('currentUser');
state.currentUser = undefined;
},
setUsers(state, data: FG.User[]) {
state.users = data;
},
setLoading(
state,
data: { key: 'updateUserLoading' | 'getUserLoading'; data: boolean }
) {
state[data.key] = data.data;
},
showState(state) {
console.log(state);
}
};
const actions: ActionTree<UserStateInterface, StateInterface> = {
login({ commit }, data: LoginData) {
Loading.show({
message: 'Du wirst eingeloggt'
});
void axios
.post('/auth', data)
.then((response: AxiosResponse<LoginResponse>) => {
response.data.session.expires = new Date(response.data.session.expires);
commit('setUser', response.data.user);
commit('setSession', response.data.session);
commit('showState');
LocalStorage.set('user', response.data.user);
LocalStorage.set('session', response.data.session);
void Router.push({ name: 'user-main' });
})
.catch(error => {
console.exception(error);
})
.finally(() => {
Loading.hide();
});
getCurrentUser({ commit, rootState }) {
if (rootState.session.currentSession) {
commit('setLoading', { key: 'getUserLoading', data: true });
axios
.get(`/users/${rootState.session.currentSession.userid}`)
.then((response: AxiosResponse<FG.User>) => {
commit('setCurrentUser', response.data);
})
.catch(err => {
console.warn(err);
})
.finally(() => {
commit('setLoading', { key: 'getUserLoading', data: false });
});
} else {
console.debug('User not logged in, can not get current_user.');
}
},
doLogout({ commit }, token: string) {
Loading.show({ message: 'Du wirst ausgeloggt' });
void axios
.delete(`/auth/${token}`)
.then(() => {
commit('setUser', empty_user);
commit('setSession', empty_session);
})
.finally(() => {
LocalStorage.remove('user');
LocalStorage.remove('session');
Loading.hide();
});
},
logout({ dispatch }, token: string) {
dispatch('doLogout', token).finally(() => {
void Router.push({ name: 'login' });
});
},
getUser({ commit, state }) {
commit('setLoading', { key: 'getUserLoading', data: true });
getUsers({ commit }) {
axios
.get(`/users/${state.user.userid}`)
.then((response: AxiosResponse<FG.User>) => {
commit('setUser', response.data);
LocalStorage.set('user', response.data);
.get(`/users`)
.then((response: AxiosResponse<FG.User[]>) => {
commit('setUsers', response.data);
})
.catch(err => {
console.warn(err);
})
.finally(() => {
commit('setLoading', { key: 'getUserLoading', data: false });
});
},
updateUser({ commit, state, dispatch }, data) {
commit('setLoading', { key: 'updateUserLoading', data: true });
if (!state.currentUser) throw 'Not logged in';
axios
.put(`/users/${state.user.userid}`, data)
.put(`/users/${state.currentUser.userid}`, data)
.then(() => {
void dispatch('getUser');
void dispatch('getCurrentUser');
})
.catch(error => {
console.log(error);
@ -133,46 +83,30 @@ const actions: ActionTree<UserStateInterface, StateInterface> = {
.finally(() => {
commit('setLoading', { key: 'updateUserLoading', data: false });
});
},
loadFromLocalStorage({ commit }) {
let data = LocalStorage.getItem('user');
commit('setUser', data ? data : empty_user);
data = LocalStorage.getItem('session');
commit('setSession', data ? data : empty_session);
commit('showState');
},
setSession({ commit }, session: FG.Session) {
if (session) {
commit('setSession', session);
LocalStorage.set('session', session);
} else {
commit('setSession', empty_session);
LocalStorage.remove('session');
}
}
};
const getters: GetterTree<UserStateInterface, StateInterface> = {
user({ user }) {
return user;
currentUser({ currentUser }) {
return currentUser;
},
displayName({ user }) {
return user.display_name;
},
session({ session }) {
return session;
users({ users }) {
return users;
},
loading({ updateUserLoading, getUserLoading }) {
return updateUserLoading || getUserLoading;
},
permissions({user}) {
let permissions: string[] = []
user.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
return permissions
displayName({ currentUser }) {
return currentUser?.display_name;
},
permissions({ currentUser }) {
let permissions: string[] = [];
if (currentUser) {
currentUser.roles.forEach(role => {
permissions = permissions.concat(role.permissions);
});
}
return permissions;
}
};

View File

@ -26,7 +26,7 @@ const routes: RouteConfig[] = [
{
name: 'about',
path: 'about',
meta: { 'permission': 'user' },
meta: { permission: 'user' },
component: () => import('pages/about/About.vue')
}
]

View File

@ -9,7 +9,7 @@ import { UserStateInterface } from 'src/plugins/user/store/user';
*/
export interface StateInterface {
user: UserStateInterface;
sessions: SessionInterface;
session: SessionInterface;
}
export default store(function({ Vue }) {

View File

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