feat: RBAC (#8922)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Val <68596159+valya@users.noreply.github.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Danny Martini <despair.blue@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
Csaba Tuncsik
2024-05-17 10:53:15 +02:00
committed by GitHub
parent b1f977ebd0
commit 596c472ecc
292 changed files with 14129 additions and 3989 deletions

View File

@@ -10,6 +10,22 @@
@click:add="addCredential"
@update:filters="filters = $event"
>
<template #header>
<ProjectTabs />
</template>
<template #add-button="{ disabled }">
<div>
<n8n-button
size="large"
block
:disabled="disabled"
data-test-id="resources-list-add"
@click="addCredential"
>
{{ addCredentialButtonText }}
</n8n-button>
</div>
</template>
<template #default="{ data }">
<CredentialCard data-test-id="resources-list-item" class="mb-2xs" :data="data" />
</template>
@@ -53,11 +69,12 @@ import type { ICredentialType } from 'n8n-workflow';
import { CREDENTIAL_SELECT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useProjectsStore } from '@/features/projects/projects.store';
import ProjectTabs from '@/features/projects/components/ProjectTabs.vue';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -68,13 +85,13 @@ export default defineComponent({
components: {
ResourcesListLayout,
CredentialCard,
ProjectTabs,
},
data() {
return {
filters: {
search: '',
ownedBy: '',
sharedWith: '',
homeProject: '',
type: '',
},
sourceControlStoreUnsubscribe: () => {},
@@ -85,9 +102,9 @@ export default defineComponent({
useCredentialsStore,
useNodeTypesStore,
useUIStore,
useUsersStore,
useSourceControlStore,
useExternalSecretsStore,
useProjectsStore,
),
allCredentials(): ICredentialsResponse[] {
return this.credentialsStore.allCredentials;
@@ -98,11 +115,19 @@ export default defineComponent({
credentialTypesById(): ICredentialTypeMap {
return this.credentialsStore.credentialTypesById;
},
addCredentialButtonText() {
return this.projectsStore.currentProject
? this.$locale.baseText('credentials.project.add')
: this.$locale.baseText('credentials.add');
},
},
watch: {
'filters.type'() {
this.sendFiltersTelemetry('type');
},
'$route.params.projectId'() {
void this.initialize();
},
},
mounted() {
this.sourceControlStoreUnsubscribe = this.sourceControlStore.$onAction(({ name, after }) => {
@@ -130,7 +155,9 @@ export default defineComponent({
);
const loadPromises = [
this.credentialsStore.fetchAllCredentials(),
this.credentialsStore.fetchAllCredentials(
this.$route?.params?.projectId as string | undefined,
),
this.credentialsStore.fetchCredentialTypes(false),
this.externalSecretsStore.fetchAllSecrets(),
this.nodeTypesStore.loadNodeTypesIfNotLoaded(),
@@ -138,8 +165,6 @@ export default defineComponent({
];
await Promise.all(loadPromises);
await this.usersStore.fetchUsers(); // Can be loaded in the background, used for filtering
},
onFilter(
resource: ICredentialsResponse,

View File

@@ -290,6 +290,7 @@ import type {
Workflow,
ConnectionTypes,
INodeOutputConfiguration,
IRun,
} from 'n8n-workflow';
import {
deepCopy,
@@ -317,6 +318,7 @@ import type {
NodeCreatorOpenSource,
AddedNodesAndConnections,
ToggleNodeCreatorOptions,
IPushDataExecutionFinished,
AIAssistantConnectionInfo,
} from '@/Interface';
@@ -388,6 +390,8 @@ import { useCanvasPanning } from '@/composables/useCanvasPanning';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useProjectsStore } from '@/features/projects/projects.store';
import type { ProjectSharingData } from '@/features/projects/projects.types';
import { useAIStore } from '@/stores/ai.store';
import { useStorage } from '@/composables/useStorage';
import { isJSPlumbEndpointElement } from '@/utils/typeGuards';
@@ -555,7 +559,7 @@ export default defineComponent({
this.resetWorkspace();
this.uiStore.stateIsDirty = previousDirtyState;
}
await Promise.all([this.loadCredentials(), this.initView()]);
await this.initView();
this.canvasStore.stopLoading();
if (this.blankRedirect) {
this.blankRedirect = false;
@@ -617,6 +621,7 @@ export default defineComponent({
usePushConnectionStore,
useSourceControlStore,
useExecutionsStore,
useProjectsStore,
useAIStore,
),
nativelyNumberSuffixedDefaults(): string[] {
@@ -837,11 +842,7 @@ export default defineComponent({
const loadPromises = (() => {
if (this.settingsStore.isPreviewMode && this.isDemo) return [];
const promises = [
this.loadActiveWorkflows(),
this.loadCredentials(),
this.loadCredentialTypes(),
];
const promises = [this.loadActiveWorkflows(), this.loadCredentialTypes()];
if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
promises.push(this.loadVariables());
}
@@ -1282,17 +1283,10 @@ export default defineComponent({
this.workflowsStore.setWorkflowPinData(data.workflowData.pinData);
}
if (data.workflowData.ownedBy) {
this.workflowsEEStore.setWorkflowOwnedBy({
workflowId: data.workflowData.id,
ownedBy: data.workflowData.ownedBy,
});
}
if (data.workflowData.sharedWith) {
if (data.workflowData.sharedWithProjects) {
this.workflowsEEStore.setWorkflowSharedWith({
workflowId: data.workflowData.id,
sharedWith: data.workflowData.sharedWith,
sharedWithProjects: data.workflowData.sharedWithProjects,
});
}
@@ -1418,7 +1412,11 @@ export default defineComponent({
await this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } });
await this.addNodes(data.workflow.nodes, data.workflow.connections);
this.workflowData = (await this.workflowsStore.getNewWorkflowData(data.name)) || {};
this.workflowData =
(await this.workflowsStore.getNewWorkflowData(
data.name,
this.projectsStore.currentProjectId,
)) || {};
this.workflowsStore.addToWorkflowMetadata({ templateId });
await this.$nextTick();
this.canvasStore.zoomToFit();
@@ -1447,17 +1445,10 @@ export default defineComponent({
this.workflowsStore.setWorkflowVersionId(workflow.versionId);
this.workflowsStore.setWorkflowMetadata(workflow.meta);
if (workflow.ownedBy) {
this.workflowsEEStore.setWorkflowOwnedBy({
workflowId: workflow.id,
ownedBy: workflow.ownedBy,
});
}
if (workflow.sharedWith) {
if (workflow.sharedWithProjects) {
this.workflowsEEStore.setWorkflowSharedWith({
workflowId: workflow.id,
sharedWith: workflow.sharedWith,
sharedWithProjects: workflow.sharedWithProjects,
});
}
@@ -3647,10 +3638,21 @@ export default defineComponent({
// Clear the interval to prevent the notification from being sent
clearTimeout(this.unloadTimeout);
},
makeNewWorkflowShareable() {
const { currentProject, personalProject } = this.projectsStore;
const homeProject = currentProject ?? personalProject ?? {};
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
this.workflowsStore.workflow.homeProject = homeProject as ProjectSharingData;
this.workflowsStore.workflow.scopes = scopes;
},
async newWorkflow(): Promise<void> {
this.canvasStore.startLoading();
this.resetWorkspace();
this.workflowData = await this.workflowsStore.getNewWorkflowData();
this.workflowData = await this.workflowsStore.getNewWorkflowData(
undefined,
this.projectsStore.currentProjectId,
);
this.workflowsStore.currentWorkflowExecutions = [];
this.executionsStore.activeExecution = null;
@@ -3660,6 +3662,7 @@ export default defineComponent({
this.uiStore.nodeViewInitialized = true;
this.historyStore.reset();
this.executionsStore.activeExecution = null;
this.makeNewWorkflowShareable();
this.canvasStore.stopLoading();
},
async tryToAddWelcomeSticky(): Promise<void> {
@@ -3700,6 +3703,7 @@ export default defineComponent({
return;
}
}
await this.loadCredentials();
// Load a workflow
let workflowId = null as string | null;
if (this.$route.params.name) {
@@ -4664,7 +4668,12 @@ export default defineComponent({
await this.credentialsStore.fetchCredentialTypes(true);
},
async loadCredentials(): Promise<void> {
await this.credentialsStore.fetchAllCredentials();
const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow);
const projectId =
workflow?.homeProject?.type === 'personal'
? this.projectsStore.personalProject?.id
: workflow?.homeProject?.id;
await this.credentialsStore.fetchAllCredentials(projectId);
},
async loadVariables(): Promise<void> {
await this.environmentsStore.fetchAllVariables();
@@ -4968,7 +4977,7 @@ export default defineComponent({
this.resetWorkspace();
this.uiStore.stateIsDirty = false;
await this.$router.replace({ name: VIEWS.WORKFLOWS });
await this.$router.replace({ name: VIEWS.HOMEPAGE });
});
}
},
@@ -5032,7 +5041,7 @@ export default defineComponent({
}
try {
await Promise.all([this.loadCredentials(), this.loadVariables(), this.tagsStore.fetchAll()]);
await Promise.all([this.loadVariables(), this.tagsStore.fetchAll(), this.loadCredentials()]);
if (workflowId !== null && !this.uiStore.stateIsDirty) {
const workflow: IWorkflowDb | undefined =

View File

@@ -110,11 +110,6 @@ export default defineComponent({
...useToast(),
};
},
async mounted() {
if (!this.showUMSetupWarning) {
await this.usersStore.fetchUsers();
}
},
computed: {
...mapStores(useSettingsStore, useUIStore, useUsersStore, useUsageStore, useSSOStore),
isSharingEnabled() {
@@ -191,6 +186,11 @@ export default defineComponent({
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
},
},
async mounted() {
if (!this.showUMSetupWarning) {
await this.usersStore.fetchUsers();
}
},
methods: {
redirectToSetup() {
void this.$router.push({ name: VIEWS.SETUP });

View File

@@ -66,7 +66,7 @@ export const credentialsTelegram1: ICredentialsResponse = {
firstName: 'Player',
lastName: 'One',
},
sharedWith: [],
sharedWithProjects: [],
};
export const credentialsTelegram2: ICredentialsResponse = {
@@ -81,7 +81,7 @@ export const credentialsTelegram2: ICredentialsResponse = {
firstName: 'Player',
lastName: 'One',
},
sharedWith: [],
sharedWithProjects: [],
};
export {

View File

@@ -246,6 +246,11 @@ onBeforeUnmount(() => {
@sort="resetNewVariablesList"
@click:add="addTemporaryVariable"
>
<template #header>
<n8n-heading size="2xlarge" class="mb-m">
{{ i18n.baseText('variables.heading') }}
</n8n-heading>
</template>
<template #add-button>
<n8n-tooltip placement="top" :disabled="canCreateVariables">
<div>

View File

@@ -6,13 +6,15 @@
:filters="filters"
:additional-filters-handler="onFilter"
:type-props="{ itemSize: 80 }"
:show-aside="allWorkflows.length > 0"
:shareable="isShareable"
:initialize="initialize"
:disabled="readOnlyEnv"
@click:add="addWorkflow"
@update:filters="onFiltersUpdated"
>
<template #header>
<ProjectTabs v-if="showProjectTabs" />
</template>
<template #add-button="{ disabled }">
<n8n-tooltip :disabled="!readOnlyEnv">
<div>
@@ -23,7 +25,7 @@
data-test-id="resources-list-add"
@click="addWorkflow"
>
{{ $locale.baseText(`workflows.add`) }}
{{ addWorkflowButtonText }}
</n8n-button>
</div>
<template #content>
@@ -171,11 +173,11 @@ import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/features/projects/projects.store';
import ProjectTabs from '@/features/projects/components/ProjectTabs.vue';
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
@@ -201,13 +203,13 @@ const WorkflowsView = defineComponent({
TagsDropdown,
SuggestedTemplatesPage,
SuggestedTemplatesSection,
ProjectTabs,
},
data() {
return {
filters: {
search: '',
ownedBy: '',
sharedWith: '',
homeProject: '',
status: StatusFilter.ALL as string | boolean,
tags: [] as string[],
},
@@ -220,11 +222,9 @@ const WorkflowsView = defineComponent({
useUIStore,
useUsersStore,
useWorkflowsStore,
useCredentialsStore,
useSourceControlStore,
useTagsStore,
useTemplatesStore,
useUsersStore,
useProjectsStore,
),
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;
@@ -269,14 +269,29 @@ const WorkflowsView = defineComponent({
}
return ['Sales', 'sales-and-marketing'].includes(this.userRole);
},
showProjectTabs() {
return (
!!this.$route.params.projectId ||
!!this.allWorkflows.length ||
this.projectsStore.myProjects.length > 1
);
},
addWorkflowButtonText() {
return this.projectsStore.currentProject
? this.$locale.baseText('workflows.project.add')
: this.$locale.baseText('workflows.add');
},
},
watch: {
'filters.tags'() {
this.sendFiltersTelemetry('tags');
},
'$route.params.projectId'() {
void this.initialize();
},
},
mounted() {
this.setFiltersFromQueryString();
async mounted() {
await this.setFiltersFromQueryString();
void this.usersStore.showPersonalizationSurvey();
@@ -298,7 +313,10 @@ const WorkflowsView = defineComponent({
},
addWorkflow() {
this.uiStore.nodeViewInitialized = false;
void this.$router.push({ name: VIEWS.NEW_WORKFLOW });
void this.$router.push({
name: VIEWS.NEW_WORKFLOW,
query: { projectId: this.$route?.params?.projectId },
});
this.$telemetry.track('User clicked add workflow button', {
source: 'Workflows list',
@@ -316,9 +334,8 @@ const WorkflowsView = defineComponent({
async initialize() {
await Promise.all([
this.usersStore.fetchUsers(),
this.workflowsStore.fetchAllWorkflows(),
this.workflowsStore.fetchAllWorkflows(this.$route?.params?.projectId as string | undefined),
this.workflowsStore.fetchActiveWorkflows(),
this.credentialsStore.fetchAllCredentials(),
]);
},
onClickTag(tagId: string, event: PointerEvent) {
@@ -367,32 +384,27 @@ const WorkflowsView = defineComponent({
query.tags = this.filters.tags.join(',');
}
if (this.filters.ownedBy) {
query.ownedBy = this.filters.ownedBy;
}
if (this.filters.sharedWith) {
query.sharedWith = this.filters.sharedWith;
if (this.filters.homeProject) {
query.homeProject = this.filters.homeProject;
}
void this.$router.replace({
query: Object.keys(query).length ? query : undefined,
});
},
isValidUserId(userId: string) {
return Object.keys(this.usersStore.users).includes(userId);
isValidProjectId(projectId: string) {
return this.projectsStore.projects.some((project) => project.id === projectId);
},
setFiltersFromQueryString() {
const { tags, status, search, ownedBy, sharedWith } = this.$route.query;
async setFiltersFromQueryString() {
const { tags, status, search, homeProject } = this.$route.query;
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
if (ownedBy && typeof ownedBy === 'string' && this.isValidUserId(ownedBy)) {
filtersToApply.ownedBy = ownedBy;
}
if (sharedWith && typeof sharedWith === 'string' && this.isValidUserId(sharedWith)) {
filtersToApply.sharedWith = sharedWith;
if (homeProject && typeof homeProject === 'string') {
await this.projectsStore.getAllProjects();
if (this.isValidProjectId(homeProject)) {
filtersToApply.homeProject = homeProject;
}
}
if (search && typeof search === 'string') {

View File

@@ -0,0 +1,137 @@
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import ModalRoot from '@/components/ModalRoot.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import SettingsUsersView from '@/views/SettingsUsersView.vue';
import { useProjectsStore } from '@/features/projects/projects.store';
import { useUsersStore } from '@/stores/users.store';
import { createUser } from '@/__tests__/data/users';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useRBACStore } from '@/stores/rbac.store';
import { DELETE_USER_MODAL_KEY } from '@/constants';
import { expect } from 'vitest';
const wrapperComponentWithModal = {
components: { SettingsUsersView, ModalRoot, DeleteUserModal },
template: `
<div>
<SettingsUsersView />
<ModalRoot name="${DELETE_USER_MODAL_KEY}">
<template #default="{ modalName, activeId }">
<DeleteUserModal :modal-name="modalName" :active-id="activeId" />
</template>
</ModalRoot>
</div>
`,
};
const renderComponent = createComponentRenderer(wrapperComponentWithModal);
const loggedInUser = createUser();
const users = Array.from({ length: 3 }, createUser);
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let rbacStore: ReturnType<typeof useRBACStore>;
describe('SettingsUsersView', () => {
beforeEach(() => {
setActivePinia(createPinia());
projectsStore = useProjectsStore();
usersStore = useUsersStore();
rbacStore = useRBACStore();
vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true);
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(usersStore, 'allUsers', 'get').mockReturnValue(users);
vi.spyOn(usersStore, 'getUserById', 'get').mockReturnValue(() => loggedInUser);
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
async () => await Promise.resolve(),
);
vi.spyOn(projectsStore, 'personalProjects', 'get').mockReturnValue(personalProjects);
});
it('should show confirmation modal before deleting user and delete with transfer', async () => {
const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {});
const { getByTestId } = renderComponent();
const userListItem = getByTestId(`user-list-item-${users[0].email}`);
expect(userListItem).toBeInTheDocument();
const actionToggle = within(userListItem).getByTestId('action-toggle');
const actionToggleButton = within(actionToggle).getByRole('button');
expect(actionToggleButton).toBeVisible();
await userEvent.click(actionToggle);
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
const actionDelete = within(actionDropdown).getByTestId('action-delete');
await userEvent.click(actionDelete);
const modal = getByTestId('deleteUser-modal');
expect(modal).toBeVisible();
const confirmButton = within(modal).getByTestId('confirm-delete-user-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[0]);
const projectSelect = getByTestId('project-sharing-select');
expect(projectSelect).toBeVisible();
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteUserSpy).toHaveBeenCalledWith({
id: users[0].id,
transferId: expect.any(String),
});
});
it('should show confirmation modal before deleting user and delete without transfer', async () => {
const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {});
const { getByTestId } = renderComponent();
const userListItem = getByTestId(`user-list-item-${users[0].email}`);
expect(userListItem).toBeInTheDocument();
const actionToggle = within(userListItem).getByTestId('action-toggle');
const actionToggleButton = within(actionToggle).getByRole('button');
expect(actionToggleButton).toBeVisible();
await userEvent.click(actionToggle);
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
const actionDelete = within(actionDropdown).getByTestId('action-delete');
await userEvent.click(actionDelete);
const modal = getByTestId('deleteUser-modal');
expect(modal).toBeVisible();
const confirmButton = within(modal).getByTestId('confirm-delete-user-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[1]);
const input = within(modal).getByRole('textbox');
await userEvent.type(input, 'delete all ');
expect(confirmButton).toBeDisabled();
await userEvent.type(input, 'data');
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteUserSpy).toHaveBeenCalledWith({
id: users[0].id,
});
});
});

View File

@@ -7,6 +7,7 @@ import WorkflowsView from '@/views/WorkflowsView.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/features/projects/projects.store';
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
@@ -18,12 +19,14 @@ describe('WorkflowsView', () => {
let pinia: ReturnType<typeof createPinia>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let projectsStore: ReturnType<typeof useProjectsStore>;
const renderComponent = createComponentRenderer(WorkflowsView, {
global: {
mocks: {
$route: {
query: {},
params: {},
},
$router: {
replace: vi.fn(),
@@ -54,6 +57,10 @@ describe('WorkflowsView', () => {
settingsStore = useSettingsStore();
usersStore = useUsersStore();
projectsStore = useProjectsStore();
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(async () => {});
await settingsStore.getSettings();
await usersStore.fetchUsers();
await usersStore.loginWithCookie();