feat: 用户账户系统

This commit is contained in:
Junyan Qin
2024-11-17 19:11:44 +08:00
parent 036c2182a5
commit 20e3edba8f
23 changed files with 543 additions and 102 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import abc
import typing
import enum
import quart
from quart.typing import RouteCallable
@@ -23,6 +24,12 @@ def group_class(name: str, path: str) -> None:
return decorator
class AuthType(enum.Enum):
"""认证类型"""
NONE = 'none'
USER_TOKEN = 'user-token'
class RouterGroup(abc.ABC):
name: str
@@ -41,13 +48,30 @@ class RouterGroup(abc.ABC):
async def initialize(self) -> None:
pass
def route(self, rule: str, **options: typing.Any) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
def route(self, rule: str, auth_type: AuthType = AuthType.USER_TOKEN, **options: typing.Any) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""注册一个路由"""
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
rule = self.path + rule
async def handler_error(*args, **kwargs):
if auth_type == AuthType.USER_TOKEN:
# 从Authorization头中获取token
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return self.http_status(401, -1, '未提供有效的用户令牌')
try:
user_email = await self.ap.user_service.verify_jwt_token(token)
# 检查f是否接受user_email参数
if 'user_email' in f.__code__.co_varnames:
kwargs['user_email'] = user_email
except Exception as e:
return self.http_status(401, -1, str(e))
try:
return await f(*args, **kwargs)
except Exception as e: # 自动 500
@@ -61,25 +85,22 @@ class RouterGroup(abc.ABC):
return f
return decorator
def _cors(self, response: quart.Response) -> quart.Response:
return response
def success(self, data: typing.Any = None) -> quart.Response:
"""返回一个 200 响应"""
return self._cors(quart.jsonify({
return quart.jsonify({
'code': 0,
'msg': 'ok',
'data': data,
}))
})
def fail(self, code: int, msg: str) -> quart.Response:
"""返回一个异常响应"""
return self._cors(quart.jsonify({
return quart.jsonify({
'code': code,
'msg': msg,
}))
})
def http_status(self, status: int, code: int, msg: str) -> quart.Response:
"""返回一个指定状态码的响应"""

View File

@@ -10,7 +10,7 @@ from .....utils import constants
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'])
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
return self.success(
data={

View File

@@ -0,0 +1,43 @@
import quart
import sqlalchemy
from .. import group
from .....persistence.entities import user
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
if quart.request.method == 'GET':
return self.success(data={
'initialized': await self.ap.user_service.is_initialized()
})
if await self.ap.user_service.is_initialized():
return self.fail(1, '系统已初始化')
json_data = await quart.request.json
user_email = json_data['user']
password = json_data['password']
await self.ap.user_service.create_user(user_email, password)
return self.success()
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
json_data = await quart.request.json
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
return self.success(data={
'token': token
})
@self.route('/check-token', methods=['GET'])
async def _() -> str:
return self.success()

View File

@@ -7,7 +7,7 @@ import quart
import quart_cors
from ....core import app, entities as core_entities
from .groups import logs, system, settings, plugins, stats
from .groups import logs, system, settings, plugins, stats, user
from . import group

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import sqlalchemy
import argon2
import jwt
import datetime
from ....core import app
from ....persistence.entities import user
from ....utils import constants
class UserService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def is_initialized(self) -> bool:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).limit(1)
)
result_list = result.all()
return result_list is not None and len(result_list) > 0
async def create_user(self, user_email: str, password: str) -> None:
ph = argon2.PasswordHasher()
hashed_password = ph.hash(password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=user_email,
password=hashed_password
)
)
async def authenticate(self, user_email: str, password: str) -> str | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
)
result_list = result.all()
if result_list is None or len(result_list) == 0:
raise ValueError('用户不存在')
user_obj = result_list[0]
ph = argon2.PasswordHasher()
if not ph.verify(user_obj.password, password):
raise ValueError('密码错误')
return await self.generate_jwt_token(user_email)
async def generate_jwt_token(self, user_email: str) -> str:
jwt_secret = self.ap.instance_secret_meta.data['jwt_secret']
jwt_expire = self.ap.system_cfg.data['http-api']['jwt-expire']
payload = {
'user': user_email,
'iss': 'LangBot-'+constants.edition,
'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire)
}
return jwt.encode(payload, jwt_secret, algorithm='HS256')
async def verify_jwt_token(self, token: str) -> str:
jwt_secret = self.ap.instance_secret_meta.data['jwt_secret']
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']

View File

@@ -23,6 +23,7 @@ from ..pipeline import controller, stagemgr
from ..utils import version as version_mgr, proxy as proxy_mgr, announce as announce_mgr
from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service
from ..utils import logcache, ip
from . import taskmgr
from . import entities as core_entities
@@ -74,6 +75,8 @@ class Application:
llm_models_meta: config_mgr.ConfigManager = None
instance_secret_meta: config_mgr.ConfigManager = None
# =========================
ctr_mgr: center_mgr.V2CenterAPI = None
@@ -100,6 +103,10 @@ class Application:
log_cache: logcache.LogCache = None
# ========= HTTP Services =========
user_service: user_service.UserService = None
def __init__(self):
pass

View File

@@ -21,6 +21,8 @@ required_deps = {
"aiosqlite": "aiosqlite",
"aiofiles": "aiofiles",
"aioshutil": "aioshutil",
"argon2": "argon2-cffi",
"jwt": "pyjwt",
}

View File

@@ -17,7 +17,8 @@ class HttpApiConfigMigration(migration.Migration):
self.ap.system_cfg.data['http-api'] = {
"enable": True,
"host": "0.0.0.0",
"port": 5300
"port": 5300,
"jwt-expire": 604800
}
self.ap.system_cfg.data['persistence'] = {

View File

@@ -17,6 +17,7 @@ from ...provider import runnermgr
from ...platform import manager as im_mgr
from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
from ...utils import logcache
from .. import taskmgr
@@ -112,5 +113,8 @@ class BuildAppStage(stage.BootingStage):
await http_ctrl.initialize()
ap.http_ctrl = http_ctrl
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
ctrl = controller.Controller(ap)
ap.ctrl = ctrl

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import secrets
from .. import stage, app
from ..bootutils import config
from ...config import settings as settings_mgr
@@ -75,3 +77,8 @@ class LoadConfigStage(stage.BootingStage):
ap.llm_models_meta = await config.load_json_config("data/metadata/llm-models.json", "templates/metadata/llm-models.json")
await ap.llm_models_meta.dump_config()
ap.instance_secret_meta = await config.load_json_config("data/metadata/instance-secret.json", template_data={
'jwt_secret': secrets.token_hex(16)
})
await ap.instance_secret_meta.dump_config()

View File

View File

@@ -0,0 +1,5 @@
import sqlalchemy.orm
class Base(sqlalchemy.orm.DeclarativeBase):
pass

View File

@@ -0,0 +1,11 @@
import sqlalchemy
from .base import Base
class User(Base):
__tablename__ = 'users'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)

View File

@@ -7,6 +7,7 @@ import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database
from .entities import user, base
from ..core import app
from .databases import sqlite
@@ -23,7 +24,7 @@ class PersistenceManager:
def __init__(self, ap: app.Application):
self.ap = ap
self.meta = sqlalchemy.MetaData()
self.meta = base.Base.metadata
async def initialize(self):
@@ -46,10 +47,11 @@ class PersistenceManager:
self,
*args,
**kwargs
):
) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
await conn.execute(*args, **kwargs)
result = await conn.execute(*args, **kwargs)
await conn.commit()
return result
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
return self.db.get_engine()

View File

@@ -1,3 +1,5 @@
semantic_version = "v3.3.1.1"
debug_mode = False
debug_mode = False
edition = 'community'

View File

@@ -20,4 +20,6 @@ sqlalchemy[asyncio]
aiosqlite
quart-cors
aiofiles
aioshutil
aioshutil
argon2-cffi
pyjwt

View File

@@ -92,6 +92,11 @@
},
"port": {
"type": "integer"
},
"jwt-expire": {
"type": "integer",
"title": "JWT 过期时间",
"description": "单位:秒"
}
}
},

View File

@@ -15,7 +15,8 @@
"http-api": {
"enable": true,
"host": "0.0.0.0",
"port": 5300
"port": 5300,
"jwt-expire": 604800
},
"persistence": {
"sqlite": {

View File

@@ -1,104 +1,123 @@
<template>
<v-app>
<v-snackbar v-model="snackbar" :color="color" :timeout="timeout" :location="location">
{{ text }}
</v-snackbar>
<v-snackbar v-model="snackbar" :color="color" :timeout="timeout" :location="location">
{{ text }}
</v-snackbar>
<v-layout>
<v-navigation-drawer id="navigation-drawer" :width="160" app permanent rail>
<v-list-item id="logo-list-item">
<template v-slot:prepend>
<div id="logo-container">
<v-img id="logo-img" src="@/assets/langbot-logo-block.png" height="32" width="32"></v-img>
<div id="app-container" v-if="proxy.$store.state.user.tokenChecked && proxy.$store.state.user.tokenValid">
<v-layout>
<v-navigation-drawer id="navigation-drawer" :width="160" app permanent rail>
<v-list-item id="logo-list-item">
<template v-slot:prepend>
<div id="logo-container">
<v-img id="logo-img" src="@/assets/langbot-logo-block.png" height="32" width="32"></v-img>
<div id="version-chip"
v-tooltip="proxy.$store.state.version + (proxy.$store.state.debug ? ' (调试模式已启用)' : '')"
:style="{ 'background-color': proxy.$store.state.debug ? '#27aa27' : '#1e9ae2' }">
{{ proxy.$store.state.version }}
<div id="version-chip"
v-tooltip="proxy.$store.state.version + (proxy.$store.state.debug ? ' (调试模式已启用)' : '')"
:style="{ 'background-color': proxy.$store.state.debug ? '#27aa27' : '#1e9ae2' }">
{{ proxy.$store.state.version }}
</div>
</div>
</template>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item to="/" title="仪表盘" value="dashboard" prepend-icon="mdi-speedometer" v-tooltip="仪表盘">
</v-list-item>
<v-list-item to="/settings" title="设置" value="settings" prepend-icon="mdi-toggle-switch-outline"
v-tooltip="设置">
</v-list-item>
<v-list-item to="/logs" title="日志" value="logs" prepend-icon="mdi-file-outline" v-tooltip="日志">
</v-list-item>
<v-list-item to="/plugins" title="插件" value="plugins" prepend-icon="mdi-puzzle-outline" v-tooltip="插件">
</v-list-item>
</v-list>
<template v-slot:append>
<div>
<v-list density="compact" nav>
<v-dialog max-width="500" persistent v-model="taskDialogShow">
<template v-slot:activator="{ props: activatorProps }">
<v-list-item id="system-tasks-list-item" title="任务列表" prepend-icon="mdi-align-horizontal-left"
v-tooltip="任务列表" v-bind="activatorProps">
</v-list-item>
</template>
<template v-slot:default="{ isActive }">
<TaskDialog :dialog="{ show: isActive }" @close="closeTaskDialog" />
</template>
</v-dialog>
<v-list-item id="about-list-item" title="系统信息" prepend-icon="mdi-cog-outline" v-tooltip="系统信息">
<v-menu activator="parent" :close-on-content-click="false" location="end">
<v-list>
<v-list-item @click="showAboutDialog">
<v-list-item-title>
关于 LangBot
<v-dialog max-width="400" persistent v-model="aboutDialogShow">
<template v-slot:default="{ isActive }">
<AboutDialog :dialog="{ show: isActive }" @close="closeAboutDialog" />
</template>
</v-dialog>
</v-list-item-title>
</v-list-item>
<v-list-item @click="openDocs">
<v-list-item-title>
查看文档
</v-list-item-title>
</v-list-item>
<v-list-item @click="reload('platform')">
<v-list-item-title>
重载消息平台
</v-list-item-title>
</v-list-item>
<v-list-item @click="reload('plugin')">
<v-list-item-title>
重载插件
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-list-item>
</v-list>
</div>
</template>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item to="/" title="仪表盘" value="dashboard" prepend-icon="mdi-speedometer" v-tooltip="仪表盘">
</v-list-item>
<v-list-item to="/settings" title="设置" value="settings" prepend-icon="mdi-toggle-switch-outline"
v-tooltip="设置">
</v-list-item>
<v-list-item to="/logs" title="日志" value="logs" prepend-icon="mdi-file-outline" v-tooltip="日志">
</v-list-item>
<v-list-item to="/plugins" title="插件" value="plugins" prepend-icon="mdi-puzzle-outline" v-tooltip="插件">
</v-list-item>
</v-list>
<template v-slot:append>
<div>
<v-list density="compact" nav>
<v-dialog max-width="500" persistent v-model="taskDialogShow">
<template v-slot:activator="{ props: activatorProps }">
<v-list-item id="system-tasks-list-item" title="任务列表" prepend-icon="mdi-align-horizontal-left"
v-tooltip="任务列表" v-bind="activatorProps">
</v-list-item>
</template>
</v-navigation-drawer>
<template v-slot:default="{ isActive }">
<TaskDialog :dialog="{ show: isActive }" @close="closeTaskDialog" />
</template>
</v-dialog>
<v-main style="background-color: #f6f6f6;">
<router-view />
</v-main>
</v-layout>
</div>
<v-list-item id="about-list-item" title="系统信息" prepend-icon="mdi-cog-outline" v-tooltip="系统信息">
<v-menu activator="parent" :close-on-content-click="false" location="end">
<v-list>
<v-list-item @click="showAboutDialog">
<v-list-item-title>
关于 LangBot
<div id="loading-container" v-if="!proxy.$store.state.user.tokenChecked">
<div id="loading-text">
<img src="@/assets/langbot-logo.png" height="32" width="32" />
<span id="loading-text-span">正在加载...</span>
</div>
</div>
<v-dialog max-width="400" persistent v-model="aboutDialogShow">
<template v-slot:default="{ isActive }">
<AboutDialog :dialog="{ show: isActive }" @close="closeAboutDialog" />
</template>
</v-dialog>
</v-list-item-title>
</v-list-item>
<div id="login-container" v-if="proxy.$store.state.user.tokenChecked && !proxy.$store.state.user.tokenValid && proxy.$store.state.user.systemInitialized">
<LoginDialog @error="error" @success="success" @checkToken="checkToken" />
</div>
<v-list-item @click="openDocs">
<v-list-item-title>
查看文档
</v-list-item-title>
</v-list-item>
<v-list-item @click="reload('platform')">
<v-list-item-title>
重载消息平台
</v-list-item-title>
</v-list-item>
<v-list-item @click="reload('plugin')">
<v-list-item-title>
重载插件
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-list-item>
</v-list>
</div>
</template>
</v-navigation-drawer>
<v-main style="background-color: #f6f6f6;">
<router-view />
</v-main>
</v-layout>
<div id="uninitialized-container" v-if="proxy.$store.state.user.tokenChecked && !proxy.$store.state.user.systemInitialized">
<InitDialog @error="error" @success="success" @checkSystemInitialized="checkSystemInitialized" />
</div>
</v-app>
</template>
<script setup>
import { getCurrentInstance } from 'vue'
import { provide, ref, watch } from 'vue';
import { provide, ref, watch, onMounted } from 'vue';
import TaskDialog from '@/components/TaskListDialog.vue'
import AboutDialog from '@/components/AboutDialog.vue'
import InitDialog from '@/components/InitDialog.vue'
import LoginDialog from '@/components/LoginDialog.vue'
const { proxy } = getCurrentInstance()
@@ -160,14 +179,14 @@ function reload(scope) {
{ headers: { 'Content-Type': 'application/json' } }
).then(response => {
if (response.data.code === 0) {
success(label+'已重载')
success(label + '已重载')
// 关闭菜单
} else {
error(label+'重载失败:' + response.data.message)
error(label + '重载失败:' + response.data.message)
}
}).catch(err => {
error(label+'重载失败:' + err)
error(label + '重载失败:' + err)
})
}
@@ -181,9 +200,68 @@ function showAboutDialog() {
function closeAboutDialog() {
aboutDialogShow.value = false
}
function checkSystemInitialized() {
proxy.$axios.get('/user/init').then(response => {
if (response.data.code === 0) {
proxy.$store.state.user.systemInitialized = response.data.data.initialized
} else {
error('系统初始化状态检查失败:' + response.data.message)
proxy.$store.state.user.systemInitialized = true
}
checkToken()
}).catch(err => {
error('系统初始化状态检查失败:' + err)
proxy.$store.state.user.systemInitialized = true
})
}
function checkToken() {
proxy.$axios.get('/user/check-token').then(response => {
if (response.data.code === 0) {
proxy.$store.state.user.tokenValid = true
} else {
proxy.$store.state.user.tokenValid = false
}
}).catch(err => {
proxy.$store.state.user.tokenValid = false
}).finally(() => {
proxy.$store.state.user.tokenChecked = true
})
}
onMounted(() => {
checkSystemInitialized()
})
</script>
<style scoped>
#app-container {
display: flex;
height: 100vh;
}
#loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
user-select: none;
}
#loading-text {
display: flex;
align-items: center;
gap: 0.5rem;
}
#loading-text-span {
font-size: 1.2rem;
font-weight: 500;
padding-top: 0.2rem;
}
#navigation-drawer {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,88 @@
<template>
<v-dialog v-model="dialog" width="500" persistent>
<v-card id="init-dialog">
<v-card-title class="d-flex align-center" style="gap: 0.5rem;">
<img src="@/assets/langbot-logo.png" height="32" width="32" />
<span>系统初始化</span>
</v-card-title>
<v-card-text>
<p>请输入初始管理员邮箱和密码</p>
</v-card-text>
<v-card-text class="d-flex flex-column" style="gap: 0.5rem;">
<v-text-field v-model="user" variant="outlined" label="管理员邮箱" :rules="[rules.required, rules.email]"
clearable />
<v-text-field v-model="password" variant="outlined" label="管理员密码" :rules="[rules.required]"
type="password" clearable />
</v-card-text>
<v-card-actions>
<v-btn color="primary" variant="flat" @click="initialize" prepend-icon="mdi-check">初始化</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, inject, getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['error', 'success', 'checkSystemInitialized'])
const dialog = ref(true)
const user = ref('')
const password = ref('')
const snackbar = inject('snackbar')
const rules = {
required: value => !!value || '必填项',
email: value => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(value) || '请输入有效的邮箱地址'
}
}
function checkEmailValid(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regex.test(email)
}
const initialize = () => {
// 检查邮箱和密码是否为空
if (user.value == undefined || password.value == undefined) {
emit('error', '邮箱和密码不能为空')
return
}
if (user.value == '' || password.value == '') {
emit('error', '邮箱和密码不能为空')
return
}
if (!checkEmailValid(user.value)) {
emit('error', '请输入有效的邮箱地址')
return
}
proxy.$axios.post('/user/init', {
user: user.value,
password: password.value
}).then(res => {
emit('success', '系统初始化成功')
emit('checkSystemInitialized')
})
}
</script>
<style scoped>
#init-dialog {
padding-top: 0.8rem;
padding-inline: 0.5rem;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<v-dialog v-model="dialog" width="350" persistent>
<v-card id="login-dialog">
<v-card-title class="d-flex align-center" style="gap: 0.5rem;">
<img src="@/assets/langbot-logo.png" height="32" width="32" />
<span>登录 LangBot</span>
</v-card-title>
<v-card-text class="d-flex flex-column" style="gap: 0.5rem;margin-bottom: -2rem;margin-top: 1rem;">
<v-text-field v-model="user" variant="outlined" label="邮箱" :rules="[rules.required, rules.email]"
clearable />
<v-text-field v-model="password" variant="outlined" label="密码" :rules="[rules.required]"
type="password" clearable />
</v-card-text>
<v-card-actions>
<v-btn color="primary" variant="flat" @click="login">登录</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['error', 'success', 'checkToken'])
const dialog = ref(true)
const user = ref('')
const password = ref('')
const rules = {
required: value => !!value || '必填项',
email: value => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(value) || '请输入有效的邮箱地址'
}
}
const login = () => {
proxy.$axios.post('/user/auth', {
user: user.value,
password: password.value
}).then(res => {
if (res.data.data.token) {
emit('success', '登录成功')
localStorage.setItem('user-token', res.data.data.token)
setTimeout(() => {
location.reload()
}, 1000)
} else {
emit('error', '登录失败')
}
}).catch(err => {
emit('error', err.response.data.message)
})
}
</script>
<style scoped>
#login-dialog {
padding-top: 0.8rem;
padding-bottom: 0.5rem;
padding-inline: 0.5rem;
}
</style>

View File

@@ -16,6 +16,16 @@ export function registerPlugins (app) {
.use(router)
.use(store)
// 读取用户令牌
const token = localStorage.getItem('user-token')
if (token) {
store.state.user.jwtToken = token
}
// 所有axios请求均携带用户令牌
axios.defaults.headers.common['Authorization'] = `Bearer ${store.state.user.jwtToken}`
app.config.globalProperties.$axios = axios
store.commit('initializeFetch')
}

View File

@@ -13,6 +13,12 @@ export default createStore({
version: 'v0.0.0',
debug: false,
enabledPlatformCount: 0,
user: {
tokenChecked: false,
tokenValid: false,
systemInitialized: true,
jwtToken: '',
}
},
mutations: {
initializeFetch() {