diff options
Diffstat (limited to 'apps/settings/src/store')
-rw-r--r-- | apps/settings/src/store/admin-security.js | 22 | ||||
-rw-r--r-- | apps/settings/src/store/api.js | 28 | ||||
-rw-r--r-- | apps/settings/src/store/app-api-store.ts | 325 | ||||
-rw-r--r-- | apps/settings/src/store/apps-store.ts | 87 | ||||
-rw-r--r-- | apps/settings/src/store/apps.js | 103 | ||||
-rw-r--r-- | apps/settings/src/store/authtoken.ts | 200 | ||||
-rw-r--r-- | apps/settings/src/store/index.js | 65 | ||||
-rw-r--r-- | apps/settings/src/store/oc.js | 24 | ||||
-rw-r--r-- | apps/settings/src/store/settings.js | 38 | ||||
-rw-r--r-- | apps/settings/src/store/users-settings.js | 23 | ||||
-rw-r--r-- | apps/settings/src/store/users.js | 370 |
11 files changed, 1002 insertions, 283 deletions
diff --git a/apps/settings/src/store/admin-security.js b/apps/settings/src/store/admin-security.js index 4bd1443273a..2cb5eb101ef 100644 --- a/apps/settings/src/store/admin-security.js +++ b/apps/settings/src/store/admin-security.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' diff --git a/apps/settings/src/store/api.js b/apps/settings/src/store/api.js index c7136bccab8..f36d44cc5c0 100644 --- a/apps/settings/src/store/api.js +++ b/apps/settings/src/store/api.js @@ -1,31 +1,11 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sujith Haridasan <sujith.h@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import axios from '@nextcloud/axios' -import confirmPassword from '@nextcloud/password-confirmation' +import { confirmPassword } from '@nextcloud/password-confirmation' +import '@nextcloud/password-confirmation/dist/style.css' const sanitize = function(url) { return url.replace(/\/$/, '') // Remove last url slash diff --git a/apps/settings/src/store/app-api-store.ts b/apps/settings/src/store/app-api-store.ts new file mode 100644 index 00000000000..769f212ebd7 --- /dev/null +++ b/apps/settings/src/store/app-api-store.ts @@ -0,0 +1,325 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { confirmPassword } from '@nextcloud/password-confirmation' +import { showError, showInfo } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' + +import api from './api' +import logger from '../logger' + +import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../app-types.ts' +import Vue from 'vue' + +interface AppApiState { + apps: IAppstoreExApp[] + updateCount: number + loading: Record<string, boolean> + loadingList: boolean + statusUpdater: number | null | undefined + daemonAccessible: boolean + defaultDaemon: IDeployDaemon | null + dockerDaemons: IDeployDaemon[] +} + +export const useAppApiStore = defineStore('app-api-apps', { + state: (): AppApiState => ({ + apps: [], + updateCount: loadState('settings', 'appstoreExAppUpdateCount', 0), + loading: {}, + loadingList: false, + statusUpdater: null, + daemonAccessible: loadState('settings', 'defaultDaemonConfigAccessible', false), + defaultDaemon: loadState('settings', 'defaultDaemonConfig', null), + dockerDaemons: [], + }), + + getters: { + getLoading: (state) => (id: string) => state.loading[id] ?? false, + getAllApps: (state) => state.apps, + getUpdateCount: (state) => state.updateCount, + getDaemonAccessible: (state) => state.daemonAccessible, + getDefaultDaemon: (state) => state.defaultDaemon, + getAppStatus: (state) => (appId: string) => + state.apps.find((app) => app.id === appId)?.status || null, + getStatusUpdater: (state) => state.statusUpdater, + getInitializingOrDeployingApps: (state) => + state.apps.filter((app) => + app?.status?.action + && (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck') + && app.status.type !== '', + ), + }, + + actions: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appsApiFailure(error: any) { + showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true }) + logger.error(error) + }, + + setLoading(id: string, value: boolean) { + Vue.set(this.loading, id, value) + }, + + setError(appId: string | string[], error: string) { + const appIds = Array.isArray(appId) ? appId : [appId] + appIds.forEach((_id) => { + const app = this.apps.find((app) => app.id === _id) + if (app) { + app.error = error + } + }) + }, + + enableApp(appId: string, daemon: IDeployDaemon, deployOptions: IDeployOptions) { + this.setLoading(appId, true) + this.setLoading('install', true) + return confirmPassword().then(() => { + + return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}/${daemon.name}`), { deployOptions }) + .then((response) => { + this.setLoading(appId, false) + this.setLoading('install', false) + + const app = this.apps.find((app) => app.id === appId) + if (app) { + if (!app.installed) { + app.installed = true + app.needsDownload = false + app.daemon = daemon + app.status = { + type: 'install', + action: 'deploy', + init: 0, + deploy: 0, + } as IExAppStatus + } + app.active = true + app.canUnInstall = false + app.removable = true + app.error = '' + } + + this.updateAppsStatus() + + return axios.get(generateUrl('apps/files')) + .then(() => { + if (response.data.update_required) { + showInfo( + t('settings', 'The app has been enabled but needs to be updated.'), + { + onClick: () => window.location.reload(), + close: false, + }, + ) + setTimeout(() => { + location.reload() + }, 5000) + } + }) + .catch(() => { + this.setError(appId, t('settings', 'Error: This app cannot be enabled because it makes the server unstable')) + }) + }) + .catch((error) => { + this.setLoading(appId, false) + this.setLoading('install', false) + this.setError(appId, error.response.data.data.message) + this.appsApiFailure({ appId, error }) + }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) + }) + }, + + forceEnableApp(appId: string) { + this.setLoading(appId, true) + this.setLoading('install', true) + return confirmPassword().then(() => { + + return api.post(generateUrl('/apps/app_api/apps/force'), { appId }) + .then(() => { + location.reload() + }) + .catch((error) => { + this.setLoading(appId, false) + this.setLoading('install', false) + this.setError(appId, error.response.data.data.message) + this.appsApiFailure({ appId, error }) + }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) + }) + }, + + disableApp(appId: string) { + this.setLoading(appId, true) + return confirmPassword().then(() => { + + return api.get(generateUrl(`apps/app_api/apps/disable/${appId}`)) + .then(() => { + this.setLoading(appId, false) + const app = this.apps.find((app) => app.id === appId) + if (app) { + app.active = false + if (app.removable) { + app.canUnInstall = true + } + } + return true + }) + .catch((error) => { + this.setLoading(appId, false) + this.appsApiFailure({ appId, error }) + }) + }).catch(() => { + this.setLoading(appId, false) + }) + }, + + uninstallApp(appId: string, removeData: boolean) { + this.setLoading(appId, true) + return confirmPassword().then(() => { + + return api.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`)) + .then(() => { + this.setLoading(appId, false) + const app = this.apps.find((app) => app.id === appId) + if (app) { + app.active = false + app.needsDownload = true + app.installed = false + app.canUnInstall = false + app.canInstall = true + app.daemon = null + app.status = {} + if (app.update !== null) { + this.updateCount-- + } + app.update = undefined + } + return true + }) + .catch((error) => { + this.setLoading(appId, false) + this.appsApiFailure({ appId, error }) + }) + }) + }, + + updateApp(appId: string) { + this.setLoading(appId, true) + this.setLoading('install', true) + return confirmPassword().then(() => { + + return api.get(generateUrl(`/apps/app_api/apps/update/${appId}`)) + .then(() => { + this.setLoading(appId, false) + this.setLoading('install', false) + const app = this.apps.find((app) => app.id === appId) + if (app) { + const version = app.update + app.update = undefined + app.version = version || app.version + app.status = { + type: 'update', + action: 'deploy', + init: 0, + deploy: 0, + } as IExAppStatus + app.error = '' + } + this.updateCount-- + this.updateAppsStatus() + return true + }) + .catch((error) => { + this.setLoading(appId, false) + this.setLoading('install', false) + this.appsApiFailure({ appId, error }) + }) + }).catch(() => { + this.setLoading(appId, false) + this.setLoading('install', false) + }) + }, + + async fetchAllApps() { + this.loadingList = true + try { + const response = await api.get(generateUrl('/apps/app_api/apps/list')) + this.apps = response.data.apps + this.loadingList = false + return true + } catch (error) { + logger.error(error as string) + showError(t('settings', 'An error occurred during the request. Unable to proceed.')) + this.loadingList = false + } + }, + + async fetchAppStatus(appId: string) { + return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`)) + .then((response) => { + const app = this.apps.find((app) => app.id === appId) + if (app) { + app.status = response.data + } + const initializingOrDeployingApps = this.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps after setAppStatus', initializingOrDeployingApps) + if (initializingOrDeployingApps.length === 0) { + console.debug('clearing interval') + clearInterval(this.statusUpdater as number) + this.statusUpdater = null + } + if (Object.hasOwn(response.data, 'error') + && response.data.error !== '' + && initializingOrDeployingApps.length === 1) { + clearInterval(this.statusUpdater as number) + this.statusUpdater = null + } + }) + .catch((error) => { + this.appsApiFailure({ appId, error }) + this.apps = this.apps.filter((app) => app.id !== appId) + this.updateAppsStatus() + }) + }, + + async fetchDockerDaemons() { + try { + const { data } = await axios.get(generateUrl('/apps/app_api/daemons')) + this.defaultDaemon = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config) + this.dockerDaemons = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install') + } catch (error) { + logger.error('[app-api-store] Failed to fetch Docker daemons', { error }) + return false + } + return true + }, + + updateAppsStatus() { + clearInterval(this.statusUpdater as number) + const initializingOrDeployingApps = this.getInitializingOrDeployingApps + if (initializingOrDeployingApps.length === 0) { + return + } + this.statusUpdater = setInterval(() => { + const initializingOrDeployingApps = this.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps', initializingOrDeployingApps) + initializingOrDeployingApps.forEach(app => { + this.fetchAppStatus(app.id) + }) + }, 2000) as unknown as number + }, + }, +}) diff --git a/apps/settings/src/store/apps-store.ts b/apps/settings/src/store/apps-store.ts new file mode 100644 index 00000000000..eaf8a0d7f86 --- /dev/null +++ b/apps/settings/src/store/apps-store.ts @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreCategory } from '../app-types.ts' + +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' + +import axios from '@nextcloud/axios' + +import logger from '../logger' +import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts' + +const showApiError = () => showError(t('settings', 'An error occurred during the request. Unable to proceed.')) + +export const useAppsStore = defineStore('settings-apps', { + state: () => ({ + apps: [] as IAppstoreApp[], + categories: [] as IAppstoreCategory[], + updateCount: loadState<number>('settings', 'appstoreUpdateCount', 0), + loading: { + apps: false, + categories: false, + }, + loadingList: false, + gettingCategoriesPromise: null, + }), + + actions: { + async loadCategories(force = false) { + if (this.categories.length > 0 && !force) { + return + } + + try { + this.loading.categories = true + const { data: categories } = await axios.get<IAppstoreCategory[]>(generateUrl('settings/apps/categories')) + + for (const category of categories) { + category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? '' + } + + this.$patch({ + categories, + }) + } catch (error) { + logger.error(error as Error) + showApiError() + } finally { + this.loading.categories = false + } + }, + + async loadApps(force = false) { + if (this.apps.length > 0 && !force) { + return + } + + try { + this.loading.apps = true + const { data } = await axios.get<{ apps: IAppstoreApp[] }>(generateUrl('settings/apps/list')) + + this.$patch({ + apps: data.apps, + }) + } catch (error) { + logger.error(error as Error) + showApiError() + } finally { + this.loading.apps = false + } + }, + + getCategoryById(categoryId: string) { + return this.categories.find(({ id }) => id === categoryId) ?? null + }, + + getAppById(appId: string): IAppstoreApp|null { + return this.apps.find(({ id }) => id === appId) ?? null + }, + }, +}) diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js index e6ddd76aaec..e0068d3892e 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -1,45 +1,29 @@ /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import api from './api' +import api from './api.js' import Vue from 'vue' +import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { showError, showInfo } from '@nextcloud/dialogs' -import '@nextcloud/dialogs/styles/toast.scss' +import { loadState } from '@nextcloud/initial-state' const state = { apps: [], + bundles: loadState('settings', 'appstoreBundles', []), categories: [], - updateCount: 0, + updateCount: loadState('settings', 'appstoreUpdateCount', 0), loading: {}, - loadingList: false, + gettingCategoriesPromise: null, + appApiEnabled: loadState('settings', 'appApiEnabled', false), } const mutations = { APPS_API_FAILURE(state, error) { - showError(t('settings', 'An error occured during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true }) + showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true }) console.error(state, error) }, @@ -48,6 +32,10 @@ const mutations = { state.updateCount = updateCount }, + updateCategories(state, categoriesPromise) { + state.gettingCategoriesPromise = categoriesPromise + }, + setUpdateCount(state, updateCount) { state.updateCount = updateCount }, @@ -84,6 +72,16 @@ const mutations = { const app = state.apps.find(app => app.id === appId) app.active = true app.groups = groups + if (app.id === 'app_api') { + state.appApiEnabled = true + } + }, + + setInstallState(state, { appId, canInstall }) { + const app = state.apps.find(app => app.id === appId) + if (app) { + app.canInstall = canInstall === true + } }, disableApp(state, appId) { @@ -93,6 +91,9 @@ const mutations = { if (app.removable) { app.canUnInstall = true } + if (app.id === 'app_api') { + state.appApiEnabled = false + } }, uninstallApp(state, appId) { @@ -102,6 +103,9 @@ const mutations = { state.apps.find(app => app.id === appId).installed = false state.apps.find(app => app.id === appId).canUnInstall = false state.apps.find(app => app.id === appId).canInstall = true + if (appId === 'app_api') { + state.appApiEnabled = false + } }, updateApp(state, appId) { @@ -142,6 +146,9 @@ const mutations = { } const getters = { + isAppApiEnabled(state) { + return state.appApiEnabled + }, loading(state) { return function(id) { return state.loading[id] @@ -153,9 +160,15 @@ const getters = { getAllApps(state) { return state.apps }, + getAppBundles(state) { + return state.bundles + }, getUpdateCount(state) { return state.updateCount }, + getCategoryById: (state) => (selectedCategoryId) => { + return state.categories.find((category) => category.id === selectedCategoryId) + }, } const actions = { @@ -179,19 +192,19 @@ const actions = { }) // check for server health - return api.get(generateUrl('apps/files')) + return axios.get(generateUrl('apps/files/')) .then(() => { if (response.data.update_required) { showInfo( t( 'settings', - 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.' + 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.', ), { onClick: () => window.location.reload(), close: false, - } + }, ) setTimeout(function() { location.reload() @@ -200,10 +213,12 @@ const actions = { }) .catch(() => { if (!Array.isArray(appId)) { + showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable')) context.commit('setError', { appId: apps, error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'), }) + context.dispatch('disableApp', { appId }) } }) }) @@ -230,8 +245,7 @@ const actions = { context.commit('startLoading', 'install') return api.post(generateUrl('settings/apps/force'), { appId }) .then((response) => { - // TODO: find a cleaner solution - location.reload() + context.commit('setInstallState', { appId, canInstall: true }) }) .catch((error) => { context.commit('stopLoading', apps) @@ -242,6 +256,10 @@ const actions = { }) context.commit('APPS_API_FAILURE', { appId, error }) }) + .finally(() => { + context.commit('stopLoading', apps) + context.commit('stopLoading', 'install') + }) }).catch((error) => context.commit('API_FAILURE', { appId, error })) }, disableApp(context, { appId }) { @@ -313,18 +331,25 @@ const actions = { .catch((error) => context.commit('API_FAILURE', error)) }, - getCategories(context) { - context.commit('startLoading', 'categories') - return api.get(generateUrl('settings/apps/categories')) - .then((response) => { - if (response.data.length > 0) { - context.commit('appendCategories', response.data) + async getCategories(context, { shouldRefetchCategories = false } = {}) { + if (shouldRefetchCategories || !context.state.gettingCategoriesPromise) { + context.commit('startLoading', 'categories') + try { + const categoriesPromise = api.get(generateUrl('settings/apps/categories')) + context.commit('updateCategories', categoriesPromise) + const categoriesPromiseResponse = await categoriesPromise + if (categoriesPromiseResponse.data.length > 0) { + context.commit('appendCategories', categoriesPromiseResponse.data) context.commit('stopLoading', 'categories') return true } + context.commit('stopLoading', 'categories') return false - }) - .catch((error) => context.commit('API_FAILURE', error)) + } catch (error) { + context.commit('API_FAILURE', error) + } + } + return context.state.gettingCategoriesPromise }, } diff --git a/apps/settings/src/store/authtoken.ts b/apps/settings/src/store/authtoken.ts new file mode 100644 index 00000000000..daf5583ab8c --- /dev/null +++ b/apps/settings/src/store/authtoken.ts @@ -0,0 +1,200 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { confirmPassword } from '@nextcloud/password-confirmation' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' + +import axios from '@nextcloud/axios' +import logger from '../logger' + +import '@nextcloud/password-confirmation/dist/style.css' + +const BASE_URL = generateUrl('/settings/personal/authtokens') + +const confirm = () => { + return new Promise(resolve => { + window.OC.dialogs.confirm( + t('settings', 'Do you really want to wipe your data from this device?'), + t('settings', 'Confirm wipe'), + resolve, + true, + ) + }) +} + +export enum TokenType { + TEMPORARY_TOKEN = 0, + PERMANENT_TOKEN = 1, + WIPING_TOKEN = 2, +} + +export interface IToken { + id: number + canDelete: boolean + canRename: boolean + current?: true + /** + * Last activity as UNIX timestamp (in seconds) + */ + lastActivity: number + name: string + type: TokenType + scope: Record<string, boolean> +} + +export interface ITokenResponse { + /** + * The device token created + */ + deviceToken: IToken + /** + * User who is assigned with this token + */ + loginName: string + /** + * The token for authentication + */ + token: string +} + +export const useAuthTokenStore = defineStore('auth-token', { + state() { + return { + tokens: loadState<IToken[]>('settings', 'app_tokens', []), + } + }, + actions: { + /** + * Update a token on server + * @param token Token to update + */ + async updateToken(token: IToken) { + const { data } = await axios.put(`${BASE_URL}/${token.id}`, token) + return data + }, + + /** + * Add a new token + * @param name The token name + */ + async addToken(name: string) { + logger.debug('Creating a new app token') + + try { + await confirmPassword() + + const { data } = await axios.post<ITokenResponse>(BASE_URL, { name }) + this.tokens.push(data.deviceToken) + logger.debug('App token created') + return data + } catch (error) { + return null + } + }, + + /** + * Delete a given app token + * @param token Token to delete + */ + async deleteToken(token: IToken) { + logger.debug('Deleting app token', { token }) + + this.tokens = this.tokens.filter(({ id }) => id !== token.id) + + try { + await axios.delete(`${BASE_URL}/${token.id}`) + logger.debug('App token deleted') + return true + } catch (error) { + logger.error('Could not delete app token', { error }) + showError(t('settings', 'Could not delete the app token')) + // Restore + this.tokens.push(token) + } + return false + }, + + /** + * Wipe a token and the connected device + * @param token Token to wipe + */ + async wipeToken(token: IToken) { + logger.debug('Wiping app token', { token }) + + try { + await confirmPassword() + + if (!(await confirm())) { + logger.debug('Wipe aborted by user') + return + } + + await axios.post(`${BASE_URL}/wipe/${token.id}`) + logger.debug('App token marked for wipe', { token }) + + token.type = TokenType.WIPING_TOKEN + token.canRename = false // wipe tokens can not be renamed + return true + } catch (error) { + logger.error('Could not wipe app token', { error }) + showError(t('settings', 'Error while wiping the device with the token')) + } + return false + }, + + /** + * Rename an existing token + * @param token The token to rename + * @param newName The new name to set + */ + async renameToken(token: IToken, newName: string) { + logger.debug(`renaming app token ${token.id} from ${token.name} to '${newName}'`) + + const oldName = token.name + token.name = newName + + try { + await this.updateToken(token) + logger.debug('App token name updated') + return true + } catch (error) { + logger.error('Could not update app token name', { error }) + showError(t('settings', 'Error while updating device token name')) + // Restore + token.name = oldName + } + return false + }, + + /** + * Set scope of the token + * @param token Token to set scope + * @param scope scope to set + * @param value value to set + */ + async setTokenScope(token: IToken, scope: string, value: boolean) { + logger.debug('Updating app token scope', { token, scope, value }) + + const oldVal = token.scope[scope] + token.scope[scope] = value + + try { + await this.updateToken(token) + logger.debug('app token scope updated') + return true + } catch (error) { + logger.error('could not update app token scope', { error }) + showError(t('settings', 'Error while updating device token scope')) + // Restore + token.scope[scope] = oldVal + } + return false + }, + }, + +}) diff --git a/apps/settings/src/store/index.js b/apps/settings/src/store/index.js index 9d8733d430e..9ecda7e37ad 100644 --- a/apps/settings/src/store/index.js +++ b/apps/settings/src/store/index.js @@ -1,58 +1,43 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import Vue from 'vue' -import Vuex, { Store } from 'vuex' -import users from './users' -import apps from './apps' -import settings from './settings' -import oc from './oc' +import { Store } from 'vuex' +import users from './users.js' +import apps from './apps.js' +import settings from './users-settings.js' +import oc from './oc.js' import { showError } from '@nextcloud/dialogs' -Vue.use(Vuex) - const debug = process.env.NODE_ENV !== 'production' const mutations = { API_FAILURE(state, error) { try { const message = error.error.response.data.ocs.meta.message - showError(t('settings', 'An error occured during the request. Unable to proceed.') + '<br>' + message, { isHTML: true }) + showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '<br>' + message, { isHTML: true }) } catch (e) { - showError(t('settings', 'An error occured during the request. Unable to proceed.')) + showError(t('settings', 'An error occurred during the request. Unable to proceed.')) } console.error(state, error) }, } -export default new Store({ - modules: { - users, - apps, - settings, - oc, - }, - strict: debug, +let store = null - mutations, -}) +export const useStore = () => { + if (store === null) { + store = new Store({ + modules: { + users, + apps, + settings, + oc, + }, + strict: debug, + mutations, + }) + } + return store +} diff --git a/apps/settings/src/store/oc.js b/apps/settings/src/store/oc.js index d2197701aec..cb9b8782bce 100644 --- a/apps/settings/src/store/oc.js +++ b/apps/settings/src/store/oc.js @@ -1,27 +1,9 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import api from './api' +import api from './api.js' import { generateOcsUrl } from '@nextcloud/router' const state = {} diff --git a/apps/settings/src/store/settings.js b/apps/settings/src/store/settings.js deleted file mode 100644 index 28d40a08028..00000000000 --- a/apps/settings/src/store/settings.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -const state = { - serverData: {}, -} -const mutations = { - setServerData(state, data) { - state.serverData = data - }, -} -const getters = { - getServerData(state) { - return state.serverData - }, -} -const actions = {} - -export default { state, mutations, getters, actions } diff --git a/apps/settings/src/store/users-settings.js b/apps/settings/src/store/users-settings.js new file mode 100644 index 00000000000..6e52b8ec0f5 --- /dev/null +++ b/apps/settings/src/store/users-settings.js @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' + +const state = { + serverData: loadState('settings', 'usersSettings', {}), +} +const mutations = { + setServerData(state, data) { + state.serverData = data + }, +} +const getters = { + getServerData(state) { + return state.serverData + }, +} +const actions = {} + +export default { state, mutations, getters, actions } diff --git a/apps/settings/src/store/users.js b/apps/settings/src/store/users.js index ec15c8dd745..7e4b9c4aebb 100644 --- a/apps/settings/src/store/users.js +++ b/apps/settings/src/store/users.js @@ -1,49 +1,29 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import api from './api' +import { getBuilder } from '@nextcloud/browser-storage' +import { getCapabilities } from '@nextcloud/capabilities' +import { parseFileSize } from '@nextcloud/files' +import { showError } from '@nextcloud/dialogs' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' import axios from '@nextcloud/axios' -import { generateOcsUrl } from '@nextcloud/router' -import logger from '../logger' -const orderGroups = function(groups, orderBy) { - /* const SORT_USERCOUNT = 1; - * const SORT_GROUPNAME = 2; - * https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34 - */ - if (orderBy === 1) { - return groups.sort((a, b) => a.usercount - a.disabled < b.usercount - b.disabled) - } else { - return groups.sort((a, b) => a.name.localeCompare(b.name)) - } -} +import { GroupSorting } from '../constants/GroupManagement.ts' +import { naturalCollator } from '../utils/sorting.ts' +import api from './api.js' +import logger from '../logger.ts' + +const usersSettings = loadState('settings', 'usersSettings', {}) + +const localStorage = getBuilder('settings').persist(true).build() const defaults = { + /** + * @type {import('../views/user-types').IGroup} + */ group: { id: '', name: '', @@ -56,43 +36,55 @@ const defaults = { const state = { users: [], - groups: [], - orderBy: 1, + groups: [ + ...(usersSettings.getSubAdminGroups ?? []), + ...(usersSettings.systemGroups ?? []), + ], + orderBy: usersSettings.sortGroups ?? GroupSorting.UserCount, minPasswordLength: 0, usersOffset: 0, usersLimit: 25, - userCount: 0, + disabledUsersOffset: 0, + disabledUsersLimit: 25, + userCount: usersSettings.userCount ?? 0, + showConfig: { + showStoragePath: localStorage.getItem('account_settings__showStoragePath') === 'true', + showUserBackend: localStorage.getItem('account_settings__showUserBackend') === 'true', + showFirstLogin: localStorage.getItem('account_settings__showFirstLogin') === 'true', + showLastLogin: localStorage.getItem('account_settings__showLastLogin') === 'true', + showNewUserForm: localStorage.getItem('account_settings__showNewUserForm') === 'true', + showLanguages: localStorage.getItem('account_settings__showLanguages') === 'true', + }, } const mutations = { appendUsers(state, usersObj) { - // convert obj to array - const users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid])) + const existingUsers = state.users.map(({ id }) => id) + const newUsers = Object.values(usersObj) + .filter(({ id }) => !existingUsers.includes(id)) + + const users = state.users.concat(newUsers) state.usersOffset += state.usersLimit state.users = users }, + updateDisabledUsers(state, _usersObj) { + state.disabledUsersOffset += state.disabledUsersLimit + }, setPasswordPolicyMinLength(state, length) { state.minPasswordLength = length !== '' ? length : 0 }, - initGroups(state, { groups, orderBy, userCount }) { - state.groups = groups.map(group => Object.assign({}, defaults.group, group)) - state.orderBy = orderBy - state.userCount = userCount - state.groups = orderGroups(state.groups, state.orderBy) - - }, - addGroup(state, { gid, displayName }) { + /** + * @param {object} state store state + * @param {import('../views/user-types.js').IGroup} newGroup new group + */ + addGroup(state, newGroup) { try { - if (typeof state.groups.find((group) => group.id === gid) !== 'undefined') { + if (typeof state.groups.find((group) => group.id === newGroup.id) !== 'undefined') { return } // extend group to default values - const group = Object.assign({}, defaults.group, { - id: gid, - name: displayName, - }) - state.groups.push(group) - state.groups = orderGroups(state.groups, state.orderBy) + const group = Object.assign({}, defaults.group, newGroup) + state.groups.unshift(group) } catch (e) { console.error('Can\'t create group', e) } @@ -103,7 +95,6 @@ const mutations = { const updatedGroup = state.groups[groupIndex] updatedGroup.name = displayName state.groups.splice(groupIndex, 1, updatedGroup) - state.groups = orderGroups(state.groups, state.orderBy) } }, removeGroup(state, gid) { @@ -121,7 +112,6 @@ const mutations = { } const groups = user.groups groups.push(gid) - state.groups = orderGroups(state.groups, state.orderBy) }, removeUserGroup(state, { userid, gid }) { const group = state.groups.find(groupSearch => groupSearch.id === gid) @@ -132,7 +122,6 @@ const mutations = { } const groups = user.groups groups.splice(groups.indexOf(gid), 1) - state.groups = orderGroups(state.groups, state.orderBy) }, addUserSubAdmin(state, { userid, gid }) { const groups = state.users.find(user => user.id === userid).subadmin @@ -149,7 +138,7 @@ const mutations = { }, addUserData(state, response) { const user = response.data.ocs.data - state.users.push(user) + state.users.unshift(user) this.commit('updateUserCounts', { user, actionType: 'create' }) }, enableDisableUser(state, { userid, enabled }) { @@ -159,49 +148,70 @@ const mutations = { }, // update active/disabled counts, groups counts updateUserCounts(state, { user, actionType }) { + // 0 is a special value + if (state.userCount === 0) { + return + } + + const recentGroup = state.groups.find(group => group.id === '__nc_internal_recent') const disabledGroup = state.groups.find(group => group.id === 'disabled') switch (actionType) { case 'enable': case 'disable': disabledGroup.usercount += user.enabled ? -1 : 1 // update Disabled Users count + recentGroup.usercount += user.enabled ? 1 : -1 state.userCount += user.enabled ? 1 : -1 // update Active Users count user.groups.forEach(userGroup => { const group = state.groups.find(groupSearch => groupSearch.id === userGroup) + if (!group) { + return + } group.disabled += user.enabled ? -1 : 1 // update group disabled count }) break case 'create': + recentGroup.usercount++ state.userCount++ // increment Active Users count user.groups.forEach(userGroup => { - state.groups - .find(groupSearch => groupSearch.id === userGroup) - .usercount++ // increment group total count + const group = state.groups.find(groupSearch => groupSearch.id === userGroup) + if (!group) { + return + } + group.usercount++ // increment group total count }) break case 'remove': if (user.enabled) { + recentGroup.usercount-- state.userCount-- // decrement Active Users count user.groups.forEach(userGroup => { const group = state.groups.find(groupSearch => groupSearch.id === userGroup) + if (!group) { + console.warn('User group ' + userGroup + ' does not exist during user removal') + return + } group.usercount-- // decrement group total count }) } else { disabledGroup.usercount-- // decrement Disabled Users count user.groups.forEach(userGroup => { const group = state.groups.find(groupSearch => groupSearch.id === userGroup) + if (!group) { + return + } group.disabled-- // decrement group disabled count }) } break default: logger.error(`Unknown action type in updateUserCounts: '${actionType}'`) - // not throwing error to interupt execution as this is not fatal + // not throwing error to interrupt execution as this is not fatal } }, setUserData(state, { userid, key, value }) { if (key === 'quota') { - const humanValue = OC.Util.computerFileSize(value) + const humanValue = parseFileSize(value, true) state.users.find(user => user.id === userid)[key][key] = humanValue !== null ? humanValue : value } else { state.users.find(user => user.id === userid)[key] = value @@ -216,6 +226,41 @@ const mutations = { resetUsers(state) { state.users = [] state.usersOffset = 0 + state.disabledUsersOffset = 0 + }, + + /** + * Reset group list + * + * @param {object} state the store state + */ + resetGroups(state) { + state.groups = [ + ...(usersSettings.getSubAdminGroups ?? []), + ...(usersSettings.systemGroups ?? []), + ] + }, + + setShowConfig(state, { key, value }) { + localStorage.setItem(`account_settings__${key}`, JSON.stringify(value)) + state.showConfig[key] = value + }, + + setGroupSorting(state, sorting) { + const oldValue = state.orderBy + state.orderBy = sorting + + // Persist the value on the server + axios.post( + generateUrl('/settings/users/preferences/group.sortBy'), + { + value: String(sorting), + }, + ).catch((error) => { + state.orderBy = oldValue + showError(t('settings', 'Could not set group sorting')) + logger.error(error) + }) }, } @@ -226,9 +271,24 @@ const getters = { getGroups(state) { return state.groups }, - getSubadminGroups(state) { - // Can't be subadmin of admin or disabled - return state.groups.filter(group => group.id !== 'admin' && group.id !== 'disabled') + getSubAdminGroups() { + return usersSettings.subAdminGroups ?? [] + }, + + getSortedGroups(state) { + const groups = [...state.groups] + if (state.orderBy === GroupSorting.UserCount) { + return groups.sort((a, b) => { + const numA = a.usercount - a.disabled + const numB = b.usercount - b.disabled + return (numA < numB) ? 1 : (numB < numA ? -1 : naturalCollator.compare(a.name, b.name)) + }) + } else { + return groups.sort((a, b) => naturalCollator.compare(a.name, b.name)) + } + }, + getGroupSorting(state) { + return state.orderBy }, getPasswordPolicyMinLength(state) { return state.minPasswordLength @@ -239,9 +299,18 @@ const getters = { getUsersLimit(state) { return state.usersLimit }, + getDisabledUsersOffset(state) { + return state.disabledUsersOffset + }, + getDisabledUsersLimit(state) { + return state.disabledUsersLimit + }, getUserCount(state) { return state.userCount }, + getShowConfig(state) { + return state.showConfig + }, } const CancelToken = axios.CancelToken @@ -250,6 +319,41 @@ let searchRequestCancelSource = null const actions = { /** + * search users + * + * @param {object} context store context + * @param {object} options destructuring object + * @param {number} options.offset List offset to request + * @param {number} options.limit List number to return from offset + * @param {string} options.search Search amongst users + * @return {Promise} + */ + searchUsers(context, { offset, limit, search }) { + search = typeof search === 'string' ? search : '' + + return api.get(generateOcsUrl('cloud/users/details?offset={offset}&limit={limit}&search={search}', { offset, limit, search })).catch((error) => { + if (!axios.isCancel(error)) { + context.commit('API_FAILURE', error) + } + }) + }, + + /** + * Get user details + * + * @param {object} context store context + * @param {string} userId user id + * @return {Promise} + */ + getUser(context, userId) { + return api.get(generateOcsUrl(`cloud/users/${userId}`)).catch((error) => { + if (!axios.isCancel(error)) { + context.commit('API_FAILURE', error) + } + }) + }, + + /** * Get all users with full details * * @param {object} context store context @@ -266,6 +370,14 @@ const actions = { } searchRequestCancelSource = CancelToken.source() search = typeof search === 'string' ? search : '' + + /** + * Adding filters in the search bar such as in:files, in:users, etc. + * collides with this particular search, so we need to remove them + * here and leave only the original search query + */ + search = search.replace(/in:[^\s]+/g, '').trim() + group = typeof group === 'string' ? group : '' if (group !== '') { return api.get(generateOcsUrl('cloud/groups/{group}/users/details?offset={offset}&limit={limit}&search={search}', { group: encodeURIComponent(group), offset, limit, search }), { @@ -302,6 +414,55 @@ const actions = { }) }, + /** + * Get recent users with full details + * + * @param {object} context store context + * @param {object} options destructuring object + * @param {number} options.offset List offset to request + * @param {number} options.limit List number to return from offset + * @param {string} options.search Search query + * @return {Promise<number>} + */ + async getRecentUsers(context, { offset, limit, search }) { + const url = generateOcsUrl('cloud/users/recent?offset={offset}&limit={limit}&search={search}', { offset, limit, search }) + try { + const response = await api.get(url) + const usersCount = Object.keys(response.data.ocs.data.users).length + if (usersCount > 0) { + context.commit('appendUsers', response.data.ocs.data.users) + } + return usersCount + } catch (error) { + context.commit('API_FAILURE', error) + } + }, + + /** + * Get disabled users with full details + * + * @param {object} context store context + * @param {object} options destructuring object + * @param {number} options.offset List offset to request + * @param {number} options.limit List number to return from offset + * @param options.search + * @return {Promise<number>} + */ + async getDisabledUsers(context, { offset, limit, search }) { + const url = generateOcsUrl('cloud/users/disabled?offset={offset}&limit={limit}&search={search}', { offset, limit, search }) + try { + const response = await api.get(url) + const usersCount = Object.keys(response.data.ocs.data.users).length + if (usersCount > 0) { + context.commit('appendUsers', response.data.ocs.data.users) + context.commit('updateDisabledUsers', response.data.ocs.data.users) + } + return usersCount + } catch (error) { + context.commit('API_FAILURE', error) + } + }, + getGroups(context, { offset, limit, search }) { search = typeof search === 'string' ? search : '' const limitParam = limit === -1 ? '' : `&limit=${limit}` @@ -309,7 +470,7 @@ const actions = { .then((response) => { if (Object.keys(response.data.ocs.data.groups).length > 0) { response.data.ocs.data.groups.forEach(function(group) { - context.commit('addGroup', { gid: group, displayName: group }) + context.commit('addGroup', { id: group, name: group }) }) return true } @@ -358,9 +519,9 @@ const actions = { }, getPasswordPolicyMinLength(context) { - if (OC.getCapabilities().password_policy && OC.getCapabilities().password_policy.minLength) { - context.commit('setPasswordPolicyMinLength', OC.getCapabilities().password_policy.minLength) - return OC.getCapabilities().password_policy.minLength + if (getCapabilities().password_policy && getCapabilities().password_policy.minLength) { + context.commit('setPasswordPolicyMinLength', getCapabilities().password_policy.minLength) + return getCapabilities().password_policy.minLength } return false }, @@ -376,7 +537,7 @@ const actions = { return api.requireAdmin().then((response) => { return api.post(generateOcsUrl('cloud/groups'), { groupid: gid }) .then((response) => { - context.commit('addGroup', { gid, displayName: gid }) + context.commit('addGroup', { id: gid, name: gid }) return { gid, displayName: gid } }) .catch((error) => { throw error }) @@ -391,7 +552,7 @@ const actions = { /** * Rename group * - * @param {Object} context store context + * @param {object} context store context * @param {string} groupid Group id * @param {string} displayName Group display name * @return {Promise} @@ -507,11 +668,14 @@ const actions = { * @param {string} userid User id * @return {Promise} */ - wipeUserDevices(context, userid) { - return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl('cloud/users/{userid}/wipe', { userid })) - .catch((error) => { throw error }) - }).catch((error) => context.commit('API_FAILURE', { userid, error })) + async wipeUserDevices(context, userid) { + try { + await api.requireAdmin() + return await api.post(generateOcsUrl('cloud/users/{userid}/wipe', { userid })) + } catch (error) { + context.commit('API_FAILURE', { userid, error }) + return Promise.reject(new Error('Failed to wipe user devices')) + } }, /** @@ -544,11 +708,12 @@ const actions = { * @param {string} options.subadmin User subadmin groups * @param {string} options.quota User email * @param {string} options.language User language + * @param {string} options.manager User manager * @return {Promise} */ - addUser({ commit, dispatch }, { userid, password, displayName, email, groups, subadmin, quota, language }) { + addUser({ commit, dispatch }, { userid, password, displayName, email, groups, subadmin, quota, language, manager }) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl('cloud/users'), { userid, password, displayName, email, groups, subadmin, quota, language }) + return api.post(generateOcsUrl('cloud/users'), { userid, password, displayName, email, groups, subadmin, quota, language, manager }) .then((response) => dispatch('addUserData', userid || response.data.ocs.data.id)) .catch((error) => { throw error }) }).catch((error) => { @@ -600,24 +765,27 @@ const actions = { * @param {string} options.value Value of the change * @return {Promise} */ - setUserData(context, { userid, key, value }) { - const allowedEmpty = ['email', 'displayname'] - if (['email', 'language', 'quota', 'displayname', 'password'].indexOf(key) !== -1) { - // We allow empty email or displayname - if (typeof value === 'string' - && ( - (allowedEmpty.indexOf(key) === -1 && value.length > 0) - || allowedEmpty.indexOf(key) !== -1 - ) - ) { - return api.requireAdmin().then((response) => { - return api.put(generateOcsUrl('cloud/users/{userid}', { userid }), { key, value }) - .then((response) => context.commit('setUserData', { userid, key, value })) - .catch((error) => { throw error }) - }).catch((error) => context.commit('API_FAILURE', { userid, error })) - } + async setUserData(context, { userid, key, value }) { + const allowedEmpty = ['email', 'displayname', 'manager'] + const validKeys = ['email', 'language', 'quota', 'displayname', 'password', 'manager'] + + if (!validKeys.includes(key)) { + throw new Error('Invalid request data') + } + + // If value is empty and the key doesn't allow empty values, throw error + if (value === '' && !allowedEmpty.includes(key)) { + throw new Error('Value cannot be empty for this field') + } + + try { + await api.requireAdmin() + await api.put(generateOcsUrl('cloud/users/{userid}', { userid }), { key, value }) + return context.commit('setUserData', { userid, key, value }) + } catch (error) { + context.commit('API_FAILURE', { userid, error }) + throw error } - return Promise.reject(new Error('Invalid request data')) }, /** |