diff options
Diffstat (limited to 'apps/settings/src/store')
-rw-r--r-- | apps/settings/src/store/admin-security.js | 24 | ||||
-rw-r--r-- | apps/settings/src/store/api.js | 26 | ||||
-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 | 113 | ||||
-rw-r--r-- | apps/settings/src/store/authtoken.ts | 200 | ||||
-rw-r--r-- | apps/settings/src/store/index.js | 66 | ||||
-rw-r--r-- | apps/settings/src/store/oc.js | 35 | ||||
-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 | 584 |
11 files changed, 1168 insertions, 353 deletions
diff --git a/apps/settings/src/store/admin-security.js b/apps/settings/src/store/admin-security.js index c71f40a1e85..2cb5eb101ef 100644 --- a/apps/settings/src/store/admin-security.js +++ b/apps/settings/src/store/admin-security.js @@ -1,26 +1,10 @@ /** - * @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author 2019 Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * 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' -import Vuex from 'vuex' +import Vuex, { Store } from 'vuex' Vue.use(Vuex) @@ -42,7 +26,7 @@ const mutations = { }, } -export default new Vuex.Store({ +export default new Store({ strict: process.env.NODE_ENV !== 'production', state, mutations, diff --git a/apps/settings/src/store/api.js b/apps/settings/src/store/api.js index 9a03fe68ef9..f36d44cc5c0 100644 --- a/apps/settings/src/store/api.js +++ b/apps/settings/src/store/api.js @@ -1,27 +1,11 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * 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 @@ -58,7 +42,7 @@ export default { * .catch((error) => {throw error;}); * }).catch((error) => {requireAdmin OR API failure}); * - * @returns {Promise} + * @return {Promise} */ requireAdmin() { return confirmPassword() 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 db174df2526..e0068d3892e 100644 --- a/apps/settings/src/store/apps.js +++ b/apps/settings/src/store/apps.js @@ -1,41 +1,29 @@ -/* - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * 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 { 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) { - OC.Notification.showHtml(t('settings', 'An error occured during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { timeout: 7 }) + 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) }, @@ -44,6 +32,10 @@ const mutations = { state.updateCount = updateCount }, + updateCategories(state, categoriesPromise) { + state.gettingCategoriesPromise = categoriesPromise + }, + setUpdateCount(state, updateCount) { state.updateCount = updateCount }, @@ -80,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) { @@ -89,6 +91,9 @@ const mutations = { if (app.removable) { app.canUnInstall = true } + if (app.id === 'app_api') { + state.appApiEnabled = false + } }, uninstallApp(state, appId) { @@ -98,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) { @@ -138,6 +146,9 @@ const mutations = { } const getters = { + isAppApiEnabled(state) { + return state.appApiEnabled + }, loading(state) { return function(id) { return state.loading[id] @@ -149,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 = { @@ -175,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) { - OC.dialogs.info( + 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.', ), - t('settings', 'App update'), - function() { - window.location.reload() + { + onClick: () => window.location.reload(), + close: false, + }, - true ) setTimeout(function() { location.reload() @@ -196,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 can not be enabled because it makes the server unstable'), + error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'), }) + context.dispatch('disableApp', { appId }) } }) }) @@ -226,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) @@ -238,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 }) { @@ -309,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 9f4313413eb..9ecda7e37ad 100644 --- a/apps/settings/src/store/index.js +++ b/apps/settings/src/store/index.js @@ -1,34 +1,14 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * 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 from 'vuex' -import users from './users' -import apps from './apps' -import settings from './settings' -import oc from './oc' - -Vue.use(Vuex) +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' const debug = process.env.NODE_ENV !== 'production' @@ -36,22 +16,28 @@ const mutations = { API_FAILURE(state, error) { try { const message = error.error.response.data.ocs.meta.message - OC.Notification.showHtml(t('settings', 'An error occured during the request. Unable to proceed.') + '<br>' + message, { timeout: 7 }) + showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '<br>' + message, { isHTML: true }) } catch (e) { - OC.Notification.showTemporary(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 Vuex.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 9e7a4e6655b..cb9b8782bce 100644 --- a/apps/settings/src/store/oc.js +++ b/apps/settings/src/store/oc.js @@ -1,26 +1,9 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * 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 = {} @@ -28,18 +11,18 @@ const mutations = {} const getters = {} const actions = { /** - * Set application config in database - * - * @param {Object} context store context - * @param {Object} options destructuring object + * Set application config in database + * + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.app Application name * @param {boolean} options.key Config key * @param {boolean} options.value Value to set - * @returns{Promise} + * @return {Promise} */ setAppConfig(context, { app, key, value }) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl(`apps/provisioning_api/api/v1/config/apps/${app}/${key}`, 2), { value }) + return api.post(generateOcsUrl('apps/provisioning_api/api/v1/config/apps/{app}/{key}', { app, key }), { value }) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { app, key, value, error })) }, diff --git a/apps/settings/src/store/settings.js b/apps/settings/src/store/settings.js deleted file mode 100644 index cab7a811b33..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 GNU AGPL version 3 or any later version - * - * 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 7877c3f3a52..7e4b9c4aebb 100644 --- a/apps/settings/src/store/users.js +++ b/apps/settings/src/store/users.js @@ -1,42 +1,29 @@ /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * 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' -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: '', @@ -49,47 +36,67 @@ 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) } }, + renameGroup(state, { gid, displayName }) { + const groupIndex = state.groups.findIndex(groupSearch => groupSearch.id === gid) + if (groupIndex >= 0) { + const updatedGroup = state.groups[groupIndex] + updatedGroup.name = displayName + state.groups.splice(groupIndex, 1, updatedGroup) + } + }, removeGroup(state, gid) { const groupIndex = state.groups.findIndex(groupSearch => groupSearch.id === gid) if (groupIndex >= 0) { @@ -105,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) @@ -116,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 @@ -128,27 +133,85 @@ const mutations = { }, deleteUser(state, userid) { const userIndex = state.users.findIndex(user => user.id === userid) + this.commit('updateUserCounts', { user: state.users[userIndex], actionType: 'remove' }) state.users.splice(userIndex, 1) }, addUserData(state, response) { - state.users.push(response.data.ocs.data) + const user = response.data.ocs.data + state.users.unshift(user) + this.commit('updateUserCounts', { user, actionType: 'create' }) }, enableDisableUser(state, { userid, enabled }) { const user = state.users.find(user => user.id === userid) user.enabled = enabled - // increment or not - if (state.userCount > 0) { - state.groups.find(group => group.id === 'disabled').usercount += enabled ? -1 : 1 - state.userCount += enabled ? 1 : -1 - user.groups.forEach(group => { - // Increment disabled count - state.groups.find(groupSearch => groupSearch.id === group).disabled += enabled ? -1 : 1 + this.commit('updateUserCounts', { user, actionType: enabled ? 'enable' : 'disable' }) + }, + // 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 => { + 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 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 @@ -157,11 +220,47 @@ const mutations = { /** * Reset users list - * @param {Object} state the store state + * + * @param {object} state the store state */ 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) + }) }, } @@ -172,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 @@ -185,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 @@ -196,15 +319,50 @@ 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 - * @param {Object} options destructuring object - * @param {int} options.offset List offset to request - * @param {int} options.limit List number to return from offset + * @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 * @param {string} options.group Get users from group - * @returns {Promise} + * @return {Promise} */ getUsers(context, { offset, limit, search, group }) { if (searchRequestCancelSource) { @@ -212,17 +370,25 @@ 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/${encodeURIComponent(encodeURIComponent(group))}/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2), { + return api.get(generateOcsUrl('cloud/groups/{group}/users/details?offset={offset}&limit={limit}&search={search}', { group: encodeURIComponent(group), offset, limit, search }), { cancelToken: searchRequestCancelSource.token, }) .then((response) => { - if (Object.keys(response.data.ocs.data.users).length > 0) { + const usersCount = Object.keys(response.data.ocs.data.users).length + if (usersCount > 0) { context.commit('appendUsers', response.data.ocs.data.users) - return Object.keys(response.data.ocs.data.users).length >= limit } - return false + return usersCount }) .catch((error) => { if (!axios.isCancel(error)) { @@ -231,15 +397,15 @@ const actions = { }) } - return api.get(generateOcsUrl(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2), { + return api.get(generateOcsUrl('cloud/users/details?offset={offset}&limit={limit}&search={search}', { offset, limit, search }), { cancelToken: searchRequestCancelSource.token, }) .then((response) => { - if (Object.keys(response.data.ocs.data.users).length > 0) { + const usersCount = Object.keys(response.data.ocs.data.users).length + if (usersCount > 0) { context.commit('appendUsers', response.data.ocs.data.users) - return Object.keys(response.data.ocs.data.users).length >= limit } - return false + return usersCount }) .catch((error) => { if (!axios.isCancel(error)) { @@ -248,14 +414,63 @@ 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}` - return api.get(generateOcsUrl(`cloud/groups?offset=${offset}&search=${search}${limitParam}`, 2)) + return api.get(generateOcsUrl('cloud/groups?offset={offset}&search={search}', { offset, search }) + limitParam) .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 } @@ -267,15 +482,16 @@ const actions = { /** * Get all users with full details * - * @param {Object} context store context - * @param {Object} options destructuring object - * @param {int} options.offset List offset to request - * @param {int} options.limit List number to return from offset - * @returns {Promise} + * @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 - + * @return {Promise} */ getUsersFromList(context, { offset, limit, search }) { search = typeof search === 'string' ? search : '' - return api.get(generateOcsUrl(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2)) + return api.get(generateOcsUrl('cloud/users/details?offset={offset}&limit={limit}&search={search}', { offset, limit, search })) .then((response) => { if (Object.keys(response.data.ocs.data.users).length > 0) { context.commit('appendUsers', response.data.ocs.data.users) @@ -289,22 +505,23 @@ const actions = { /** * Get all users with full details from a groupid * - * @param {Object} context store context - * @param {Object} options destructuring object - * @param {int} options.offset List offset to request - * @param {int} options.limit List number to return from offset - * @returns {Promise} + * @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.groupid - + * @return {Promise} */ getUsersFromGroup(context, { groupid, offset, limit }) { - return api.get(generateOcsUrl(`cloud/users/${encodeURIComponent(encodeURIComponent(groupid))}/details?offset=${offset}&limit=${limit}`, 2)) + return api.get(generateOcsUrl('cloud/users/{groupId}/details?offset={offset}&limit={limit}', { groupId: encodeURIComponent(groupid), offset, limit })) .then((response) => context.commit('getUsersFromList', response.data.ocs.data.users)) .catch((error) => context.commit('API_FAILURE', error)) }, 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 }, @@ -312,15 +529,15 @@ const actions = { /** * Add group * - * @param {Object} context store context + * @param {object} context store context * @param {string} gid Group id - * @returns {Promise} + * @return {Promise} */ addGroup(context, gid) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl('cloud/groups', 2), { groupid: gid }) + 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 }) @@ -333,15 +550,39 @@ const actions = { }, /** + * Rename group + * + * @param {object} context store context + * @param {string} groupid Group id + * @param {string} displayName Group display name + * @return {Promise} + */ + renameGroup(context, { groupid, displayName }) { + return api.requireAdmin().then((response) => { + return api.put(generateOcsUrl('cloud/groups/{groupId}', { groupId: encodeURIComponent(groupid) }), { key: 'displayname', value: displayName }) + .then((response) => { + context.commit('renameGroup', { gid: groupid, displayName }) + return { groupid, displayName } + }) + .catch((error) => { throw error }) + }).catch((error) => { + context.commit('API_FAILURE', { groupid, error }) + // let's throw one more time to prevent the view + // from renaming the group + throw error + }) + }, + + /** * Remove group * - * @param {Object} context store context + * @param {object} context store context * @param {string} gid Group id - * @returns {Promise} + * @return {Promise} */ removeGroup(context, gid) { return api.requireAdmin().then((response) => { - return api.delete(generateOcsUrl(`cloud/groups/${encodeURIComponent(encodeURIComponent(gid))}`, 2)) + return api.delete(generateOcsUrl('cloud/groups/{groupId}', { groupId: encodeURIComponent(gid) })) .then((response) => context.commit('removeGroup', gid)) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { gid, error })) @@ -350,15 +591,15 @@ const actions = { /** * Add user to group * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.gid Group id - * @returns {Promise} + * @return {Promise} */ addUserGroup(context, { userid, gid }) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl(`cloud/users/${userid}/groups`, 2), { groupid: gid }) + return api.post(generateOcsUrl('cloud/users/{userid}/groups', { userid }), { groupid: gid }) .then((response) => context.commit('addUserGroup', { userid, gid })) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) @@ -367,15 +608,15 @@ const actions = { /** * Remove user from group * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.gid Group id - * @returns {Promise} + * @return {Promise} */ removeUserGroup(context, { userid, gid }) { return api.requireAdmin().then((response) => { - return api.delete(generateOcsUrl(`cloud/users/${userid}/groups`, 2), { groupid: gid }) + return api.delete(generateOcsUrl('cloud/users/{userid}/groups', { userid }), { groupid: gid }) .then((response) => context.commit('removeUserGroup', { userid, gid })) .catch((error) => { throw error }) }).catch((error) => { @@ -389,15 +630,15 @@ const actions = { /** * Add user to group admin * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.gid Group id - * @returns {Promise} + * @return {Promise} */ addUserSubAdmin(context, { userid, gid }) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl(`cloud/users/${userid}/subadmins`, 2), { groupid: gid }) + return api.post(generateOcsUrl('cloud/users/{userid}/subadmins', { userid }), { groupid: gid }) .then((response) => context.commit('addUserSubAdmin', { userid, gid })) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) @@ -406,15 +647,15 @@ const actions = { /** * Remove user from group admin * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.gid Group id - * @returns {Promise} + * @return {Promise} */ removeUserSubAdmin(context, { userid, gid }) { return api.requireAdmin().then((response) => { - return api.delete(generateOcsUrl(`cloud/users/${userid}/subadmins`, 2), { groupid: gid }) + return api.delete(generateOcsUrl('cloud/users/{userid}/subadmins', { userid }), { groupid: gid }) .then((response) => context.commit('removeUserSubAdmin', { userid, gid })) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) @@ -423,27 +664,30 @@ const actions = { /** * Mark all user devices for remote wipe * - * @param {Object} context store context + * @param {object} context store context * @param {string} userid User id - * @returns {Promise} + * @return {Promise} */ - wipeUserDevices(context, userid) { - return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl(`cloud/users/${userid}/wipe`, 2)) - .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')) + } }, /** * Delete a user * - * @param {Object} context store context + * @param {object} context store context * @param {string} userid User id - * @returns {Promise} + * @return {Promise} */ deleteUser(context, userid) { return api.requireAdmin().then((response) => { - return api.delete(generateOcsUrl(`cloud/users/${userid}`, 2)) + return api.delete(generateOcsUrl('cloud/users/{userid}', { userid })) .then((response) => context.commit('deleteUser', userid)) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) @@ -452,8 +696,10 @@ const actions = { /** * Add a user * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {Function} context.commit - + * @param {Function} context.dispatch - + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.password User password * @param {string} options.displayName User display name @@ -461,11 +707,13 @@ const actions = { * @param {string} options.groups User groups * @param {string} options.subadmin User subadmin groups * @param {string} options.quota User email - * @returns {Promise} + * @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', 2), { 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) => { @@ -477,30 +725,31 @@ const actions = { /** * Get user data and commit addition * - * @param {Object} context store context + * @param {object} context store context * @param {string} userid User id - * @returns {Promise} + * @return {Promise} */ addUserData(context, userid) { return api.requireAdmin().then((response) => { - return api.get(generateOcsUrl(`cloud/users/${userid}`, 2)) + return api.get(generateOcsUrl('cloud/users/{userid}', { userid })) .then((response) => context.commit('addUserData', response)) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) }, - /** Enable or disable user + /** + * Enable or disable user * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {boolean} options.enabled User enablement status - * @returns {Promise} + * @return {Promise} */ enableDisableUser(context, { userid, enabled = true }) { const userStatus = enabled ? 'enable' : 'disable' return api.requireAdmin().then((response) => { - return api.put(generateOcsUrl(`cloud/users/${userid}/${userStatus}`, 2)) + return api.put(generateOcsUrl('cloud/users/{userid}/{userStatus}', { userid, userStatus })) .then((response) => context.commit('enableDisableUser', { userid, enabled })) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) @@ -509,43 +758,46 @@ const actions = { /** * Edit user data * - * @param {Object} context store context - * @param {Object} options destructuring object + * @param {object} context store context + * @param {object} options destructuring object * @param {string} options.userid User id * @param {string} options.key User field to edit * @param {string} options.value Value of the change - * @returns {Promise} + * @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}`, 2), { 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')) }, /** * Send welcome mail * - * @param {Object} context store context + * @param {object} context store context * @param {string} userid User id - * @returns {Promise} + * @return {Promise} */ sendWelcomeMail(context, userid) { return api.requireAdmin().then((response) => { - return api.post(generateOcsUrl(`cloud/users/${userid}/welcome`, 2)) + return api.post(generateOcsUrl('cloud/users/{userid}/welcome', { userid })) .then(response => true) .catch((error) => { throw error }) }).catch((error) => context.commit('API_FAILURE', { userid, error })) |