diff options
Diffstat (limited to 'apps/files_external/src')
-rw-r--r-- | apps/files_external/src/actions/enterCredentialsAction.spec.ts | 30 | ||||
-rw-r--r-- | apps/files_external/src/actions/enterCredentialsAction.ts | 117 | ||||
-rw-r--r-- | apps/files_external/src/actions/inlineStorageCheckAction.ts | 99 | ||||
-rw-r--r-- | apps/files_external/src/actions/openInFilesAction.spec.ts | 32 | ||||
-rw-r--r-- | apps/files_external/src/actions/openInFilesAction.ts | 24 | ||||
-rw-r--r-- | apps/files_external/src/css/fileEntryStatus.scss | 12 | ||||
-rw-r--r-- | apps/files_external/src/init.ts | 23 | ||||
-rw-r--r-- | apps/files_external/src/services/externalStorage.ts | 27 | ||||
-rw-r--r-- | apps/files_external/src/settings.js | 1579 | ||||
-rw-r--r-- | apps/files_external/src/utils/credentialsUtils.ts | 21 | ||||
-rw-r--r-- | apps/files_external/src/utils/externalStorageUtils.spec.ts | 23 | ||||
-rw-r--r-- | apps/files_external/src/utils/externalStorageUtils.ts | 21 | ||||
-rw-r--r-- | apps/files_external/src/views/CredentialsDialog.vue | 86 |
13 files changed, 1815 insertions, 279 deletions
diff --git a/apps/files_external/src/actions/enterCredentialsAction.spec.ts b/apps/files_external/src/actions/enterCredentialsAction.spec.ts index d1aefd08efe..5d1ff05e229 100644 --- a/apps/files_external/src/actions/enterCredentialsAction.spec.ts +++ b/apps/files_external/src/actions/enterCredentialsAction.spec.ts @@ -1,28 +1,12 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { action } from './enterCredentialsAction' -import { expect } from '@jest/globals' -import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files' import type { StorageConfig } from '../services/externalStorage' + +import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files' +import { describe, expect, test } from 'vitest' +import { action } from './enterCredentialsAction' import { STORAGE_STATUS } from '../utils/credentialsUtils' const view = { @@ -53,7 +37,7 @@ describe('Enter credentials action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('credentials-external-storage') expect(action.displayName([storage], externalStorageView)).toBe('Enter missing credentials') - expect(action.iconSvgInline([storage], externalStorageView)).toBe('<svg>SvgMock</svg>') + expect(action.iconSvgInline([storage], externalStorageView)).toMatch(/<svg.+<\/svg>/) expect(action.default).toBe(DefaultType.DEFAULT) expect(action.order).toBe(-1000) expect(action.inline!(storage, externalStorageView)).toBe(true) diff --git a/apps/files_external/src/actions/enterCredentialsAction.ts b/apps/files_external/src/actions/enterCredentialsAction.ts index 162a359f488..580f15ad876 100644 --- a/apps/files_external/src/actions/enterCredentialsAction.ts +++ b/apps/files_external/src/actions/enterCredentialsAction.ts @@ -1,56 +1,68 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ // eslint-disable-next-line n/no-extraneous-import -import type { AxiosResponse } from 'axios' +import type { AxiosResponse } from '@nextcloud/axios' import type { Node } from '@nextcloud/files' import type { StorageConfig } from '../services/externalStorage' -import { generateOcsUrl, generateUrl } from '@nextcloud/router' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation' +import { generateUrl } from '@nextcloud/router' +import { showError, showSuccess, spawnDialog } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import LoginSvg from '@mdi/svg/svg/login.svg?raw' -import Vue from 'vue' +import Vue, { defineAsyncComponent } from 'vue' import { FileAction, DefaultType } from '@nextcloud/files' import { STORAGE_STATUS, isMissingAuthConfig } from '../utils/credentialsUtils' import { isNodeExternalStorage } from '../utils/externalStorageUtils' -type OCSAuthResponse = { - ocs: { - meta: { - status: string - statuscode: number - message: string - }, +// Add password confirmation interceptors as +// the backend requires the user to confirm their password +addPasswordConfirmationInterceptors(axios) + +type CredentialResponse = { + login?: string, + password?: string, +} + +/** + * Set credentials for external storage + * + * @param node The node for which to set the credentials + * @param login The username + * @param password The password + */ +async function setCredentials(node: Node, login: string, password: string): Promise<null|true> { + const configResponse = await axios.request({ + method: 'PUT', + url: generateUrl('apps/files_external/userglobalstorages/{id}', { id: node.attributes.id }), + confirmPassword: PwdConfirmationMode.Strict, data: { - user?: string, - password?: string, - } + backendOptions: { user: login, password }, + }, + }) as AxiosResponse<StorageConfig> + + const config = configResponse.data + if (config.status !== STORAGE_STATUS.SUCCESS) { + showError(t('files_external', 'Unable to update this external storage config. {statusMessage}', { + statusMessage: config?.statusMessage || '', + })) + return null } + + // Success update config attribute + showSuccess(t('files_external', 'New configuration successfully saved')) + Vue.set(node.attributes, 'config', config) + return true } +export const ACTION_CREDENTIALS_EXTERNAL_STORAGE = 'credentials-external-storage' + export const action = new FileAction({ - id: 'credentials-external-storage', + id: ACTION_CREDENTIALS_EXTERNAL_STORAGE, displayName: () => t('files', 'Enter missing credentials'), iconSvgInline: () => LoginSvg, @@ -74,30 +86,23 @@ export const action = new FileAction({ }, async exec(node: Node) { - // always resolve auth request, we'll process the data afterwards - // Using fetch as axios have integrated auth handling and X-Requested-With header - const response = await fetch(generateOcsUrl('/apps/files_external/api/v1/auth'), { - headers: new Headers({ Accept: 'application/json' }), - credentials: 'include', - }) - - const data = (await response?.json() || {}) as OCSAuthResponse - if (data.ocs.data.user && data.ocs.data.password) { - const configResponse = await axios.put(generateUrl('apps/files_external/userglobalstorages/{id}', node.attributes), { - backendOptions: data.ocs.data, - }) as AxiosResponse<StorageConfig> - - const config = configResponse.data - if (config.status !== STORAGE_STATUS.SUCCESS) { - showError(t('files_external', 'Unable to update this external storage config. {statusMessage}', { - statusMessage: config?.statusMessage || '', + const { login, password } = await new Promise<CredentialResponse>(resolve => spawnDialog( + defineAsyncComponent(() => import('../views/CredentialsDialog.vue')), + {}, + (args) => { + resolve(args as CredentialResponse) + }, + )) + + if (login && password) { + try { + await setCredentials(node, login, password) + showSuccess(t('files_external', 'Credentials successfully set')) + } catch (error) { + showError(t('files_external', 'Error while setting credentials: {error}', { + error: (error as Error).message, })) - return null } - - // Success update config attribute - showSuccess(t('files_external', 'New configuration successfully saved')) - Vue.set(node.attributes, 'config', config) } return null diff --git a/apps/files_external/src/actions/inlineStorageCheckAction.ts b/apps/files_external/src/actions/inlineStorageCheckAction.ts index 46e38eab47e..27c9b0bcabb 100644 --- a/apps/files_external/src/actions/inlineStorageCheckAction.ts +++ b/apps/files_external/src/actions/inlineStorageCheckAction.ts @@ -1,28 +1,12 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ // eslint-disable-next-line n/no-extraneous-import -import type { AxiosError } from 'axios' +import type { AxiosError } from '@nextcloud/axios' import type { Node } from '@nextcloud/files' +import { FileAction } from '@nextcloud/files' import { showWarning } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import AlertSvg from '@mdi/svg/svg/alert-circle.svg?raw' @@ -32,7 +16,6 @@ import '../css/fileEntryStatus.scss' import { getStatus, type StorageConfig } from '../services/externalStorage' import { isMissingAuthConfig, STORAGE_STATUS } from '../utils/credentialsUtils' import { isNodeExternalStorage } from '../utils/externalStorageUtils' -import { FileAction } from '@nextcloud/files' export const action = new FileAction({ id: 'check-external-storage', @@ -47,47 +30,55 @@ export const action = new FileAction({ /** * Use this function to check the storage availability * We then update the node attributes directly. + * + * @param node The node to render inline */ async renderInline(node: Node) { - let config = null as any as StorageConfig - try { - const response = await getStatus(node.attributes.id, node.attributes.scope === 'system') - config = response.data - Vue.set(node.attributes, 'config', config) + const span = document.createElement('span') + span.className = 'files-list__row-status' + span.innerHTML = t('files_external', 'Checking storage …') + + let config = null as unknown as StorageConfig + getStatus(node.attributes.id, node.attributes.scope === 'system') + .then(response => { + + config = response.data + Vue.set(node.attributes, 'config', config) + + if (config.status !== STORAGE_STATUS.SUCCESS) { + throw new Error(config?.statusMessage || t('files_external', 'There was an error with this external storage.')) + } - if (config.status !== STORAGE_STATUS.SUCCESS) { - throw new Error(config?.statusMessage || t('files_external', 'There was an error with this external storage.')) - } + span.remove() + }) + .catch(error => { + // If axios failed or if something else prevented + // us from getting the config + if ((error as AxiosError).response && !config) { + showWarning(t('files_external', 'We were unable to check the external storage {basename}', { + basename: node.basename, + })) + } - return null - } catch (error) { - // If axios failed or if something else prevented - // us from getting the config - if ((error as AxiosError).response && !config) { - showWarning(t('files_external', 'We were unable to check the external storage {basename}', { - basename: node.basename, - })) - return null - } + // Reset inline status + span.innerHTML = '' - // Checking if we really have an error - const isWarning = isMissingAuthConfig(config) - const overlay = document.createElement('span') - overlay.classList.add(`files-list__row-status--${isWarning ? 'warning' : 'error'}`) + // Checking if we really have an error + const isWarning = !config ? false : isMissingAuthConfig(config) + const overlay = document.createElement('span') + overlay.classList.add(`files-list__row-status--${isWarning ? 'warning' : 'error'}`) - const span = document.createElement('span') - span.className = 'files-list__row-status' + // Only show an icon for errors, warning like missing credentials + // have a dedicated inline action button + if (!isWarning) { + span.innerHTML = AlertSvg + span.title = (error as Error).message + } - // Only show an icon for errors, warning like missing credentials - // have a dedicated inline action button - if (!isWarning) { - span.innerHTML = AlertSvg - span.title = (error as Error).message - } + span.prepend(overlay) + }) - span.prepend(overlay) - return span - } + return span }, order: 10, diff --git a/apps/files_external/src/actions/openInFilesAction.spec.ts b/apps/files_external/src/actions/openInFilesAction.spec.ts index ef04436024b..aa9573eca77 100644 --- a/apps/files_external/src/actions/openInFilesAction.spec.ts +++ b/apps/files_external/src/actions/openInFilesAction.spec.ts @@ -1,28 +1,12 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { action } from './openInFilesAction' -import { expect } from '@jest/globals' import { Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' + import type { StorageConfig } from '../services/externalStorage' +import { action } from './openInFilesAction' import { STORAGE_STATUS } from '../utils/credentialsUtils' const view = { @@ -90,7 +74,8 @@ describe('Open in files action enabled tests', () => { describe('Open in files action execute tests', () => { test('Open in files', async () => { - const goToRouteMock = jest.fn() + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } const storage = new Folder({ @@ -114,7 +99,8 @@ describe('Open in files action execute tests', () => { }) test('Open in files broken storage', async () => { - const confirmMock = jest.fn() + const confirmMock = vi.fn() + // @ts-expect-error We only mock what is needed window.OC = { dialogs: { confirm: confirmMock } } const storage = new Folder({ diff --git a/apps/files_external/src/actions/openInFilesAction.ts b/apps/files_external/src/actions/openInFilesAction.ts index 36b434fee9c..e5f065e4871 100644 --- a/apps/files_external/src/actions/openInFilesAction.ts +++ b/apps/files_external/src/actions/openInFilesAction.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { Node } from '@nextcloud/files' import type { StorageConfig } from '../services/externalStorage' @@ -27,6 +10,7 @@ import { translate as t } from '@nextcloud/l10n' import { FileAction, DefaultType } from '@nextcloud/files' import { STORAGE_STATUS } from '../utils/credentialsUtils' +import { getCurrentUser } from '@nextcloud/auth' export const action = new FileAction({ id: 'open-in-files-external-storage', @@ -49,7 +33,7 @@ export const action = new FileAction({ t('files_external', 'External mount error'), (redirect) => { if (redirect === true) { - const scope = node.attributes.scope === 'personal' ? 'user' : 'admin' + const scope = getCurrentUser()?.isAdmin ? 'admin' : 'user' window.location.href = generateUrl(`/settings/${scope}/externalstorages`) } }, diff --git a/apps/files_external/src/css/fileEntryStatus.scss b/apps/files_external/src/css/fileEntryStatus.scss index d7b5063bceb..295234032bb 100644 --- a/apps/files_external/src/css/fileEntryStatus.scss +++ b/apps/files_external/src/css/fileEntryStatus.scss @@ -1,9 +1,16 @@ +/*! + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ .files-list__row-status { display: flex; - width: 44px; + min-width: 44px; justify-content: center; align-items: center; height: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; svg { width: 24px; @@ -19,8 +26,7 @@ position: absolute; display: block; top: 0; - left: 0; - right: 0; + inset-inline: 0; bottom: 0; opacity: .1; z-index: -1; diff --git a/apps/files_external/src/init.ts b/apps/files_external/src/init.ts index ccce2448dfe..a8a265500dd 100644 --- a/apps/files_external/src/init.ts +++ b/apps/files_external/src/init.ts @@ -1,28 +1,11 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { View, getNavigation, Column, registerFileAction } from '@nextcloud/files' -import FolderNetworkSvg from '@mdi/svg/svg/folder-network.svg?raw' +import FolderNetworkSvg from '@mdi/svg/svg/folder-network-outline.svg?raw' import { action as enterCredentialsAction } from './actions/enterCredentialsAction' import { action as inlineStorageCheckAction } from './actions/inlineStorageCheckAction' diff --git a/apps/files_external/src/services/externalStorage.ts b/apps/files_external/src/services/externalStorage.ts index ea4db8a1fe8..fe4271ae94a 100644 --- a/apps/files_external/src/services/externalStorage.ts +++ b/apps/files_external/src/services/externalStorage.ts @@ -1,28 +1,11 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ // eslint-disable-next-line n/no-extraneous-import -import type { AxiosResponse } from 'axios' -import type { OCSResponse } from '../../../files_sharing/src/services/SharingService' +import type { AxiosResponse } from '@nextcloud/axios' import type { ContentsWithRoot } from '@nextcloud/files' +import type { OCSResponse } from '@nextcloud/typings/ocs' import { Folder, Permission } from '@nextcloud/files' import { generateOcsUrl, generateRemoteUrl, generateUrl } from '@nextcloud/router' @@ -83,7 +66,7 @@ const entryToFolder = (ocsEntry: MountEntry): Folder => { } export const getContents = async (): Promise<ContentsWithRoot> => { - const response = await axios.get(generateOcsUrl('apps/files_external/api/v1/mounts')) as AxiosResponse<OCSResponse<MountEntry>> + const response = await axios.get(generateOcsUrl('apps/files_external/api/v1/mounts')) as AxiosResponse<OCSResponse<MountEntry[]>> const contents = response.data.ocs.data.map(entryToFolder) return { diff --git a/apps/files_external/src/settings.js b/apps/files_external/src/settings.js new file mode 100644 index 00000000000..033696c9d24 --- /dev/null +++ b/apps/files_external/src/settings.js @@ -0,0 +1,1579 @@ +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation' +import { generateUrl } from '@nextcloud/router' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import axios, { isAxiosError } from '@nextcloud/axios' + +import jQuery from 'jquery' + +addPasswordConfirmationInterceptors(axios) + +/** + * Returns the selection of applicable users in the given configuration row + * + * @param $row configuration row + * @return array array of user names + */ +function getSelection($row) { + let values = $row.find('.applicableUsers').select2('val') + if (!values || values.length === 0) { + values = [] + } + return values +} + +/** + * + * @param $row + */ +function getSelectedApplicable($row) { + const users = [] + const groups = [] + const multiselect = getSelection($row) + $.each(multiselect, function(index, value) { + // FIXME: don't rely on string parts to detect groups... + const pos = (value.indexOf) ? value.indexOf('(group)') : -1 + if (pos !== -1) { + groups.push(value.substr(0, pos)) + } else { + users.push(value) + } + }) + + // FIXME: this should be done in the multiselect change event instead + $row.find('.applicable') + .data('applicable-groups', groups) + .data('applicable-users', users) + + return { users, groups } +} + +/** + * + * @param $element + * @param highlight + */ +function highlightBorder($element, highlight) { + $element.toggleClass('warning-input', highlight) + return highlight +} + +/** + * + * @param $input + */ +function isInputValid($input) { + const optional = $input.hasClass('optional') + switch ($input.attr('type')) { + case 'text': + case 'password': + if ($input.val() === '' && !optional) { + return false + } + break + } + return true +} + +/** + * + * @param $input + */ +function highlightInput($input) { + switch ($input.attr('type')) { + case 'text': + case 'password': + return highlightBorder($input, !isInputValid($input)) + } +} + +/** + * Initialize select2 plugin on the given elements + * + * @param {Array<object>} array of jQuery elements + * @param $elements + * @param {number} userListLimit page size for result list + */ +function initApplicableUsersMultiselect($elements, userListLimit) { + const escapeHTML = function(text) { + return text.toString() + .split('&').join('&') + .split('<').join('<') + .split('>').join('>') + .split('"').join('"') + .split('\'').join(''') + } + if (!$elements.length) { + return + } + return $elements.select2({ + placeholder: t('files_external', 'Type to select account or group.'), + allowClear: true, + multiple: true, + toggleSelect: true, + dropdownCssClass: 'files-external-select2', + // minimumInputLength: 1, + ajax: { + url: OC.generateUrl('apps/files_external/applicable'), + dataType: 'json', + quietMillis: 100, + data(term, page) { // page is the one-based page number tracked by Select2 + return { + pattern: term, // search term + limit: userListLimit, // page size + offset: userListLimit * (page - 1), // page number starts with 0 + } + }, + results(data) { + if (data.status === 'success') { + + const results = [] + let userCount = 0 // users is an object + + // add groups + $.each(data.groups, function(gid, group) { + results.push({ name: gid + '(group)', displayname: group, type: 'group' }) + }) + // add users + $.each(data.users, function(id, user) { + userCount++ + results.push({ name: id, displayname: user, type: 'user' }) + }) + + const more = (userCount >= userListLimit) || (data.groups.length >= userListLimit) + return { results, more } + } else { + // FIXME add error handling + } + }, + }, + initSelection(element, callback) { + const users = {} + users.users = [] + const toSplit = element.val().split(',') + for (let i = 0; i < toSplit.length; i++) { + users.users.push(toSplit[i]) + } + + $.ajax(OC.generateUrl('displaynames'), { + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(users), + dataType: 'json', + }).done(function(data) { + const results = [] + if (data.status === 'success') { + $.each(data.users, function(user, displayname) { + if (displayname !== false) { + results.push({ name: user, displayname, type: 'user' }) + } + }) + callback(results) + } else { + // FIXME add error handling + } + }) + }, + id(element) { + return element.name + }, + formatResult(element) { + const $result = $('<span><div class="avatardiv"></div><span>' + escapeHTML(element.displayname) + '</span></span>') + const $div = $result.find('.avatardiv') + .attr('data-type', element.type) + .attr('data-name', element.name) + .attr('data-displayname', element.displayname) + if (element.type === 'group') { + const url = OC.imagePath('core', 'actions/group') + $div.html('<img width="32" height="32" src="' + url + '">') + } + return $result.get(0).outerHTML + }, + formatSelection(element) { + if (element.type === 'group') { + return '<span title="' + escapeHTML(element.name) + '" class="group">' + escapeHTML(element.displayname + ' ' + t('files_external', '(Group)')) + '</span>' + } else { + return '<span title="' + escapeHTML(element.name) + '" class="user">' + escapeHTML(element.displayname) + '</span>' + } + }, + escapeMarkup(m) { return m }, // we escape the markup in formatResult and formatSelection + }).on('select2-loaded', function() { + $.each($('.avatardiv'), function(i, div) { + const $div = $(div) + if ($div.data('type') === 'user') { + $div.avatar($div.data('name'), 32) + } + }) + }).on('change', function(event) { + highlightBorder($(event.target).closest('.applicableUsersContainer').find('.select2-choices'), !event.val.length) + }) +} + +/** + * @param id + * @class OCA.Files_External.Settings.StorageConfig + * + * @classdesc External storage config + */ +const StorageConfig = function(id) { + this.id = id + this.backendOptions = {} +} +// Keep this in sync with \OCA\Files_External\MountConfig::STATUS_* +StorageConfig.Status = { + IN_PROGRESS: -1, + SUCCESS: 0, + ERROR: 1, + INDETERMINATE: 2, +} +StorageConfig.Visibility = { + NONE: 0, + PERSONAL: 1, + ADMIN: 2, + DEFAULT: 3, +} +/** + * @memberof OCA.Files_External.Settings + */ +StorageConfig.prototype = { + _url: null, + + /** + * Storage id + * + * @type int + */ + id: null, + + /** + * Mount point + * + * @type string + */ + mountPoint: '', + + /** + * Backend + * + * @type string + */ + backend: null, + + /** + * Authentication mechanism + * + * @type string + */ + authMechanism: null, + + /** + * Backend-specific configuration + * + * @type Object.<string,object> + */ + backendOptions: null, + + /** + * Mount-specific options + * + * @type Object.<string,object> + */ + mountOptions: null, + + /** + * Creates or saves the storage. + * + * @param {Function} [options.success] success callback, receives result as argument + * @param {Function} [options.error] error callback + * @param options + */ + save(options) { + let url = OC.generateUrl(this._url) + let method = 'POST' + if (_.isNumber(this.id)) { + method = 'PUT' + url = OC.generateUrl(this._url + '/{id}', { id: this.id }) + } + + this._save(method, url, options) + }, + + /** + * Private implementation of the save function (called after potential password confirmation) + * @param {string} method + * @param {string} url + * @param {{success: Function, error: Function}} options + */ + async _save(method, url, options) { + try { + const response = await axios.request({ + confirmPassword: PwdConfirmationMode.Strict, + method, + url, + data: this.getData(), + }) + const result = response.data + this.id = result.id + options.success(result) + } catch (error) { + options.error(error) + } + }, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData() { + const data = { + mountPoint: this.mountPoint, + backend: this.backend, + authMechanism: this.authMechanism, + backendOptions: this.backendOptions, + testOnly: true, + } + if (this.id) { + data.id = this.id + } + if (this.mountOptions) { + data.mountOptions = this.mountOptions + } + return data + }, + + /** + * Recheck the storage + * + * @param {Function} [options.success] success callback, receives result as argument + * @param {Function} [options.error] error callback + * @param options + */ + recheck(options) { + if (!_.isNumber(this.id)) { + if (_.isFunction(options.error)) { + options.error() + } + return + } + $.ajax({ + type: 'GET', + url: OC.generateUrl(this._url + '/{id}', { id: this.id }), + data: { testOnly: true }, + success: options.success, + error: options.error, + }) + }, + + /** + * Deletes the storage + * + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback + * @param options + */ + async destroy(options) { + if (!_.isNumber(this.id)) { + // the storage hasn't even been created => success + if (_.isFunction(options.success)) { + options.success() + } + return + } + + try { + await axios.request({ + method: 'DELETE', + url: OC.generateUrl(this._url + '/{id}', { id: this.id }), + confirmPassword: PwdConfirmationMode.Strict, + }) + options.success() + } catch (e) { + options.error(e) + } + }, + + /** + * Validate this model + * + * @return {boolean} false if errors exist, true otherwise + */ + validate() { + if (this.mountPoint === '') { + return false + } + if (!this.backend) { + return false + } + if (this.errors) { + return false + } + return true + }, +} + +/** + * @param id + * @class OCA.Files_External.Settings.GlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc Global external storage config + */ +const GlobalStorageConfig = function(id) { + this.id = id + this.applicableUsers = [] + this.applicableGroups = [] +} +/** + * @memberOf OCA.Files_External.Settings + */ +GlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.GlobalStorageConfig.prototype */ { + _url: 'apps/files_external/globalstorages', + + /** + * Applicable users + * + * @type Array.<string> + */ + applicableUsers: null, + + /** + * Applicable groups + * + * @type Array.<string> + */ + applicableGroups: null, + + /** + * Storage priority + * + * @type int + */ + priority: null, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData() { + const data = StorageConfig.prototype.getData.apply(this, arguments) + return _.extend(data, { + applicableUsers: this.applicableUsers, + applicableGroups: this.applicableGroups, + priority: this.priority, + }) + }, + }) + +/** + * @param id + * @class OCA.Files_External.Settings.UserStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ +const UserStorageConfig = function(id) { + this.id = id +} +UserStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ { + _url: 'apps/files_external/userstorages', + }) + +/** + * @param id + * @class OCA.Files_External.Settings.UserGlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ +const UserGlobalStorageConfig = function(id) { + this.id = id +} +UserGlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ { + + _url: 'apps/files_external/userglobalstorages', + }) + +/** + * @class OCA.Files_External.Settings.MountOptionsDropdown + * + * @classdesc Dropdown for mount options + * + * @param {object} $container container DOM object + */ +const MountOptionsDropdown = function() { +} +/** + * @memberof OCA.Files_External.Settings + */ +MountOptionsDropdown.prototype = { + /** + * Dropdown element + * + * @member Object + */ + $el: null, + + /** + * Show dropdown + * + * @param {object} $container container + * @param {object} mountOptions mount options + * @param {Array} visibleOptions enabled mount options + */ + show($container, mountOptions, visibleOptions) { + if (MountOptionsDropdown._last) { + MountOptionsDropdown._last.hide() + } + + const $el = $(OCA.Files_External.Templates.mountOptionsDropDown({ + mountOptionsEncodingLabel: t('files_external', 'Compatibility with Mac NFD encoding (slow)'), + mountOptionsEncryptLabel: t('files_external', 'Enable encryption'), + mountOptionsPreviewsLabel: t('files_external', 'Enable previews'), + mountOptionsSharingLabel: t('files_external', 'Enable sharing'), + mountOptionsFilesystemCheckLabel: t('files_external', 'Check for changes'), + mountOptionsFilesystemCheckOnce: t('files_external', 'Never'), + mountOptionsFilesystemCheckDA: t('files_external', 'Once every direct access'), + mountOptionsReadOnlyLabel: t('files_external', 'Read only'), + deleteLabel: t('files_external', 'Disconnect'), + })) + this.$el = $el + + const storage = $container[0].parentNode.className + + this.setOptions(mountOptions, visibleOptions, storage) + + this.$el.appendTo($container) + MountOptionsDropdown._last = this + + this.$el.trigger('show') + }, + + hide() { + if (this.$el) { + this.$el.trigger('hide') + this.$el.remove() + this.$el = null + MountOptionsDropdown._last = null + } + }, + + /** + * Returns the mount options from the dropdown controls + * + * @return {object} options mount options + */ + getOptions() { + const options = {} + + this.$el.find('input, select').each(function() { + const $this = $(this) + const key = $this.attr('name') + let value = null + if ($this.attr('type') === 'checkbox') { + value = $this.prop('checked') + } else { + value = $this.val() + } + if ($this.attr('data-type') === 'int') { + value = parseInt(value, 10) + } + options[key] = value + }) + return options + }, + + /** + * Sets the mount options to the dropdown controls + * + * @param {object} options mount options + * @param {Array} visibleOptions enabled mount options + * @param storage + */ + setOptions(options, visibleOptions, storage) { + if (storage === 'owncloud') { + const ind = visibleOptions.indexOf('encrypt') + if (ind > 0) { + visibleOptions.splice(ind, 1) + } + } + const $el = this.$el + _.each(options, function(value, key) { + const $optionEl = $el.find('input, select').filterAttr('name', key) + if ($optionEl.attr('type') === 'checkbox') { + if (_.isString(value)) { + value = (value === 'true') + } + $optionEl.prop('checked', !!value) + } else { + $optionEl.val(value) + } + }) + $el.find('.optionRow').each(function(i, row) { + const $row = $(row) + const optionId = $row.find('input, select').attr('name') + if (visibleOptions.indexOf(optionId) === -1 && !$row.hasClass('persistent')) { + $row.hide() + } else { + $row.show() + } + }) + }, +} + +/** + * @class OCA.Files_External.Settings.MountConfigListView + * + * @classdesc Mount configuration list view + * + * @param {object} $el DOM object containing the list + * @param {object} [options] + * @param {number} [options.userListLimit] page size in applicable users dropdown + */ +const MountConfigListView = function($el, options) { + this.initialize($el, options) +} + +MountConfigListView.ParameterFlags = { + OPTIONAL: 1, + USER_PROVIDED: 2, + HIDDEN: 4, +} + +MountConfigListView.ParameterTypes = { + TEXT: 0, + BOOLEAN: 1, + PASSWORD: 2, +} + +/** + * @memberOf OCA.Files_External.Settings + */ +MountConfigListView.prototype = _.extend({ + + /** + * jQuery element containing the config list + * + * @type Object + */ + $el: null, + + /** + * Storage config class + * + * @type Class + */ + _storageConfigClass: null, + + /** + * Flag whether the list is about user storage configs (true) + * or global storage configs (false) + * + * @type bool + */ + _isPersonal: false, + + /** + * Page size in applicable users dropdown + * + * @type int + */ + _userListLimit: 30, + + /** + * List of supported backends + * + * @type Object.<string,Object> + */ + _allBackends: null, + + /** + * List of all supported authentication mechanisms + * + * @type Object.<string,Object> + */ + _allAuthMechanisms: null, + + _encryptionEnabled: false, + + /** + * @param {object} $el DOM object containing the list + * @param {object} [options] + * @param {number} [options.userListLimit] page size in applicable users dropdown + */ + initialize($el, options) { + this.$el = $el + this._isPersonal = ($el.data('admin') !== true) + if (this._isPersonal) { + this._storageConfigClass = OCA.Files_External.Settings.UserStorageConfig + } else { + this._storageConfigClass = OCA.Files_External.Settings.GlobalStorageConfig + } + + if (options && !_.isUndefined(options.userListLimit)) { + this._userListLimit = options.userListLimit + } + + this._encryptionEnabled = options.encryptionEnabled + this._canCreateLocal = options.canCreateLocal + + // read the backend config that was carefully crammed + // into the data-configurations attribute of the select + this._allBackends = this.$el.find('.selectBackend').data('configurations') + this._allAuthMechanisms = this.$el.find('#addMountPoint .authentication').data('mechanisms') + + this._initEvents() + }, + + /** + * Custom JS event handlers + * Trigger callback for all existing configurations + * @param callback + */ + whenSelectBackend(callback) { + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + const backend = $(tr).find('.backend').data('identifier') + callback($(tr), backend) + }) + this.on('selectBackend', callback) + }, + whenSelectAuthMechanism(callback) { + const self = this + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + const authMechanism = $(tr).find('.selectAuthMechanism').val() + callback($(tr), authMechanism, self._allAuthMechanisms[authMechanism].scheme) + }) + this.on('selectAuthMechanism', callback) + }, + + /** + * Initialize DOM event handlers + */ + _initEvents() { + const self = this + + const onChangeHandler = _.bind(this._onChange, this) + // this.$el.on('input', 'td input', onChangeHandler); + this.$el.on('keyup', 'td input', onChangeHandler) + this.$el.on('paste', 'td input', onChangeHandler) + this.$el.on('change', 'td input:checkbox', onChangeHandler) + this.$el.on('change', '.applicable', onChangeHandler) + + this.$el.on('click', '.status>span', function() { + self.recheckStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.mountOptionsToggle .icon-delete', function() { + self.deleteStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.save>.icon-checkmark', function() { + self.saveStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.mountOptionsToggle>.icon-more', function() { + $(this).attr('aria-expanded', 'true') + self._showMountOptionsDropdown($(this).closest('tr')) + }) + + this.$el.on('change', '.selectBackend', _.bind(this._onSelectBackend, this)) + this.$el.on('change', '.selectAuthMechanism', _.bind(this._onSelectAuthMechanism, this)) + + this.$el.on('change', '.applicableToAllUsers', _.bind(this._onChangeApplicableToAllUsers, this)) + }, + + _onChange(event) { + const $target = $(event.target) + if ($target.closest('.dropdown').length) { + // ignore dropdown events + return + } + highlightInput($target) + const $tr = $target.closest('tr') + this.updateStatus($tr, null) + }, + + _onSelectBackend(event) { + const $target = $(event.target) + let $tr = $target.closest('tr') + + const storageConfig = new this._storageConfigClass() + storageConfig.mountPoint = $tr.find('.mountPoint input').val() + storageConfig.backend = $target.val() + $tr.find('.mountPoint input').val('') + + $tr.find('.selectBackend').prop('selectedIndex', 0) + + const onCompletion = jQuery.Deferred() + $tr = this.newStorage(storageConfig, onCompletion) + $tr.find('.applicableToAllUsers').prop('checked', false).trigger('change') + onCompletion.resolve() + + $tr.find('td.configuration').children().not('[type=hidden]').first().focus() + this.saveStorageConfig($tr) + }, + + _onSelectAuthMechanism(event) { + const $target = $(event.target) + const $tr = $target.closest('tr') + const authMechanism = $target.val() + + const onCompletion = jQuery.Deferred() + this.configureAuthMechanism($tr, authMechanism, onCompletion) + onCompletion.resolve() + + this.saveStorageConfig($tr) + }, + + _onChangeApplicableToAllUsers(event) { + const $target = $(event.target) + const $tr = $target.closest('tr') + const checked = $target.is(':checked') + + $tr.find('.applicableUsersContainer').toggleClass('hidden', checked) + if (!checked) { + $tr.find('.applicableUsers').select2('val', '', true) + } + + this.saveStorageConfig($tr) + }, + + /** + * Configure the storage config with a new authentication mechanism + * + * @param {jQuery} $tr config row + * @param {string} authMechanism + * @param {jQuery.Deferred} onCompletion + */ + configureAuthMechanism($tr, authMechanism, onCompletion) { + const authMechanismConfiguration = this._allAuthMechanisms[authMechanism] + const $td = $tr.find('td.configuration') + $td.find('.auth-param').remove() + + $.each(authMechanismConfiguration.configuration, _.partial( + this.writeParameterInput, $td, _, _, ['auth-param'], + ).bind(this)) + + this.trigger('selectAuthMechanism', + $tr, authMechanism, authMechanismConfiguration.scheme, onCompletion, + ) + }, + + /** + * Create a config row for a new storage + * + * @param {StorageConfig} storageConfig storage config to pull values from + * @param {jQuery.Deferred} onCompletion + * @param {boolean} deferAppend + * @return {jQuery} created row + */ + newStorage(storageConfig, onCompletion, deferAppend) { + let mountPoint = storageConfig.mountPoint + let backend = this._allBackends[storageConfig.backend] + + if (!backend) { + backend = { + name: 'Unknown: ' + storageConfig.backend, + invalid: true, + } + } + + // FIXME: Replace with a proper Handlebar template + const $template = this.$el.find('tr#addMountPoint') + const $tr = $template.clone() + if (!deferAppend) { + $tr.insertBefore($template) + } + + $tr.data('storageConfig', storageConfig) + $tr.show() + $tr.find('td.mountOptionsToggle, td.save, td.remove').removeClass('hidden') + $tr.find('td').last().removeAttr('style') + $tr.removeAttr('id') + $tr.find('select#selectBackend') + if (!deferAppend) { + initApplicableUsersMultiselect($tr.find('.applicableUsers'), this._userListLimit) + } + + if (storageConfig.id) { + $tr.data('id', storageConfig.id) + } + + $tr.find('.backend').text(backend.name) + if (mountPoint === '') { + mountPoint = this._suggestMountPoint(backend.name) + } + $tr.find('.mountPoint input').val(mountPoint) + $tr.addClass(backend.identifier) + $tr.find('.backend').data('identifier', backend.identifier) + + if (backend.invalid || (backend.identifier === 'local' && !this._canCreateLocal)) { + $tr.find('[name=mountPoint]').prop('disabled', true) + $tr.find('.applicable,.mountOptionsToggle').empty() + $tr.find('.save').empty() + if (backend.invalid) { + this.updateStatus($tr, false, t('files_external', 'Unknown backend: {backendName}', { backendName: backend.name })) + } + return $tr + } + + const selectAuthMechanism = $('<select class="selectAuthMechanism"></select>') + const neededVisibility = (this._isPersonal) ? StorageConfig.Visibility.PERSONAL : StorageConfig.Visibility.ADMIN + $.each(this._allAuthMechanisms, function(authIdentifier, authMechanism) { + if (backend.authSchemes[authMechanism.scheme] && (authMechanism.visibility & neededVisibility)) { + selectAuthMechanism.append( + $('<option value="' + authMechanism.identifier + '" data-scheme="' + authMechanism.scheme + '">' + authMechanism.name + '</option>'), + ) + } + }) + if (storageConfig.authMechanism) { + selectAuthMechanism.val(storageConfig.authMechanism) + } else { + storageConfig.authMechanism = selectAuthMechanism.val() + } + $tr.find('td.authentication').append(selectAuthMechanism) + + const $td = $tr.find('td.configuration') + $.each(backend.configuration, _.partial(this.writeParameterInput, $td).bind(this)) + + this.trigger('selectBackend', $tr, backend.identifier, onCompletion) + this.configureAuthMechanism($tr, storageConfig.authMechanism, onCompletion) + + if (storageConfig.backendOptions) { + $td.find('input, select').each(function() { + const input = $(this) + const val = storageConfig.backendOptions[input.data('parameter')] + if (val !== undefined) { + if (input.is('input:checkbox')) { + input.prop('checked', val) + } + input.val(storageConfig.backendOptions[input.data('parameter')]) + highlightInput(input) + } + }) + } + + let applicable = [] + if (storageConfig.applicableUsers) { + applicable = applicable.concat(storageConfig.applicableUsers) + } + if (storageConfig.applicableGroups) { + applicable = applicable.concat( + _.map(storageConfig.applicableGroups, function(group) { + return group + '(group)' + }), + ) + } + if (applicable.length) { + $tr.find('.applicableUsers').val(applicable).trigger('change') + $tr.find('.applicableUsersContainer').removeClass('hidden') + } else { + // applicable to all + $tr.find('.applicableUsersContainer').addClass('hidden') + } + $tr.find('.applicableToAllUsers').prop('checked', !applicable.length) + + const priorityEl = $('<input type="hidden" class="priority" value="' + backend.priority + '" />') + $tr.append(priorityEl) + + if (storageConfig.mountOptions) { + $tr.find('input.mountOptions').val(JSON.stringify(storageConfig.mountOptions)) + } else { + // FIXME default backend mount options + $tr.find('input.mountOptions').val(JSON.stringify({ + encrypt: true, + previews: true, + enable_sharing: false, + filesystem_check_changes: 1, + encoding_compatibility: false, + readonly: false, + })) + } + + return $tr + }, + + /** + * Load storages into config rows + */ + loadStorages() { + const self = this + + const onLoaded1 = $.Deferred() + const onLoaded2 = $.Deferred() + + this.$el.find('.externalStorageLoading').removeClass('hidden') + $.when(onLoaded1, onLoaded2).always(() => { + self.$el.find('.externalStorageLoading').addClass('hidden') + }) + + if (this._isPersonal) { + // load userglobal storages + $.ajax({ + type: 'GET', + url: OC.generateUrl('apps/files_external/userglobalstorages'), + data: { testOnly: true }, + contentType: 'application/json', + success(result) { + result = Object.values(result) + const onCompletion = jQuery.Deferred() + let $rows = $() + result.forEach(function(storageParams) { + let storageConfig + const isUserGlobal = storageParams.type === 'system' && self._isPersonal + storageParams.mountPoint = storageParams.mountPoint.substr(1) // trim leading slash + if (isUserGlobal) { + storageConfig = new UserGlobalStorageConfig() + } else { + storageConfig = new self._storageConfigClass() + } + _.extend(storageConfig, storageParams) + const $tr = self.newStorage(storageConfig, onCompletion, true) + + // userglobal storages must be at the top of the list + $tr.detach() + self.$el.prepend($tr) + + const $authentication = $tr.find('.authentication') + $authentication.text($authentication.find('select option:selected').text()) + + // disable any other inputs + $tr.find('.mountOptionsToggle, .remove').empty() + $tr.find('input:not(.user_provided), select:not(.user_provided)').attr('disabled', 'disabled') + + if (isUserGlobal) { + $tr.find('.configuration').find(':not(.user_provided)').remove() + } else { + // userglobal storages do not expose configuration data + $tr.find('.configuration').text(t('files_external', 'Admin defined')) + } + + // don't recheck config automatically when there are a large number of storages + if (result.length < 20) { + self.recheckStorageConfig($tr) + } else { + self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status')) + } + $rows = $rows.add($tr) + }) + initApplicableUsersMultiselect(self.$el.find('.applicableUsers'), this._userListLimit) + self.$el.find('tr#addMountPoint').before($rows) + const mainForm = $('#files_external') + if (result.length === 0 && mainForm.attr('data-can-create') === 'false') { + mainForm.hide() + $('a[href="#external-storage"]').parent().hide() + $('.emptycontent').show() + } + onCompletion.resolve() + onLoaded1.resolve() + }, + }) + } else { + onLoaded1.resolve() + } + + const url = this._storageConfigClass.prototype._url + + $.ajax({ + type: 'GET', + url: OC.generateUrl(url), + contentType: 'application/json', + success(result) { + result = Object.values(result) + const onCompletion = jQuery.Deferred() + let $rows = $() + result.forEach(function(storageParams) { + storageParams.mountPoint = (storageParams.mountPoint === '/') ? '/' : storageParams.mountPoint.substr(1) // trim leading slash + const storageConfig = new self._storageConfigClass() + _.extend(storageConfig, storageParams) + const $tr = self.newStorage(storageConfig, onCompletion, true) + + // don't recheck config automatically when there are a large number of storages + if (result.length < 20) { + self.recheckStorageConfig($tr) + } else { + self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status')) + } + $rows = $rows.add($tr) + }) + initApplicableUsersMultiselect($rows.find('.applicableUsers'), this._userListLimit) + self.$el.find('tr#addMountPoint').before($rows) + onCompletion.resolve() + onLoaded2.resolve() + }, + }) + }, + + /** + * @param {jQuery} $td + * @param {string} parameter + * @param {string} placeholder + * @param {Array} classes + * @return {jQuery} newly created input + */ + writeParameterInput($td, parameter, placeholder, classes) { + const hasFlag = function(flag) { + return (placeholder.flags & flag) === flag + } + classes = $.isArray(classes) ? classes : [] + classes.push('added') + if (hasFlag(MountConfigListView.ParameterFlags.OPTIONAL)) { + classes.push('optional') + } + + if (hasFlag(MountConfigListView.ParameterFlags.USER_PROVIDED)) { + if (this._isPersonal) { + classes.push('user_provided') + } else { + return + } + } + + let newElement + + const trimmedPlaceholder = placeholder.value + if (hasFlag(MountConfigListView.ParameterFlags.HIDDEN)) { + newElement = $('<input type="hidden" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />') + } else if (placeholder.type === MountConfigListView.ParameterTypes.PASSWORD) { + newElement = $('<input type="password" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />') + } else if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { + const checkboxId = _.uniqueId('checkbox_') + newElement = $('<div><label><input type="checkbox" id="' + checkboxId + '" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />' + trimmedPlaceholder + '</label></div>') + } else { + newElement = $('<input type="text" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />') + } + + if (placeholder.defaultValue) { + if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { + newElement.find('input').prop('checked', placeholder.defaultValue) + } else { + newElement.val(placeholder.defaultValue) + } + } + + if (placeholder.tooltip) { + newElement.attr('title', placeholder.tooltip) + } + + highlightInput(newElement) + $td.append(newElement) + return newElement + }, + + /** + * Gets the storage model from the given row + * + * @param $tr row element + * @return {OCA.Files_External.StorageConfig} storage model instance + */ + getStorageConfig($tr) { + let storageId = $tr.data('id') + if (!storageId) { + // new entry + storageId = null + } + + let storage = $tr.data('storageConfig') + if (!storage) { + storage = new this._storageConfigClass(storageId) + } + storage.errors = null + storage.mountPoint = $tr.find('.mountPoint input').val() + storage.backend = $tr.find('.backend').data('identifier') + storage.authMechanism = $tr.find('.selectAuthMechanism').val() + + const classOptions = {} + const configuration = $tr.find('.configuration input') + const missingOptions = [] + $.each(configuration, function(index, input) { + const $input = $(input) + const parameter = $input.data('parameter') + if ($input.attr('type') === 'button') { + return + } + if (!isInputValid($input) && !$input.hasClass('optional')) { + missingOptions.push(parameter) + return + } + if ($(input).is(':checkbox')) { + if ($(input).is(':checked')) { + classOptions[parameter] = true + } else { + classOptions[parameter] = false + } + } else { + classOptions[parameter] = $(input).val() + } + }) + + storage.backendOptions = classOptions + if (missingOptions.length) { + storage.errors = { + backendOptions: missingOptions, + } + } + + // gather selected users and groups + if (!this._isPersonal) { + const multiselect = getSelectedApplicable($tr) + const users = multiselect.users || [] + const groups = multiselect.groups || [] + const isApplicableToAllUsers = $tr.find('.applicableToAllUsers').is(':checked') + + if (isApplicableToAllUsers) { + storage.applicableUsers = [] + storage.applicableGroups = [] + } else { + storage.applicableUsers = users + storage.applicableGroups = groups + + if (!storage.applicableUsers.length && !storage.applicableGroups.length) { + if (!storage.errors) { + storage.errors = {} + } + storage.errors.requiredApplicable = true + } + } + + storage.priority = parseInt($tr.find('input.priority').val() || '100', 10) + } + + const mountOptions = $tr.find('input.mountOptions').val() + if (mountOptions) { + storage.mountOptions = JSON.parse(mountOptions) + } + + return storage + }, + + /** + * Deletes the storage from the given tr + * + * @param $tr storage row + * @param Function callback callback to call after save + */ + deleteStorageConfig($tr) { + const self = this + const configId = $tr.data('id') + if (!_.isNumber(configId)) { + // deleting unsaved storage + $tr.remove() + return + } + const storage = new this._storageConfigClass(configId) + + OC.dialogs.confirm(t('files_external', 'Are you sure you want to disconnect this external storage? It will make the storage unavailable in Nextcloud and will lead to a deletion of these files and folders on any sync client that is currently connected but will not delete any files and folders on the external storage itself.', { + storage: this.mountPoint, + }), t('files_external', 'Delete storage?'), function(confirm) { + if (confirm) { + self.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + + storage.destroy({ + success() { + $tr.remove() + }, + error(result) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + }, + }) + } + }) + }, + + /** + * Saves the storage from the given tr + * + * @param $tr storage row + * @param Function callback callback to call after save + * @param callback + * @param concurrentTimer only update if the timer matches this + */ + saveStorageConfig($tr, callback, concurrentTimer) { + const self = this + const storage = this.getStorageConfig($tr) + if (!storage || !storage.validate()) { + return false + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + storage.save({ + success(result) { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + self.updateStatus($tr, result.status, result.statusMessage) + $tr.data('id', result.id) + + if (_.isFunction(callback)) { + callback(storage) + } + } + }, + error(result) { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + } + }, + }) + }, + + /** + * Recheck storage availability + * + * @param {jQuery} $tr storage row + * @return {boolean} success + */ + recheckStorageConfig($tr) { + const self = this + const storage = this.getStorageConfig($tr) + if (!storage.validate()) { + return false + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + storage.recheck({ + success(result) { + self.updateStatus($tr, result.status, result.statusMessage) + }, + error(result) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + }, + }) + }, + + /** + * Update status display + * + * @param {jQuery} $tr + * @param {number} status + * @param {string} message + */ + updateStatus($tr, status, message) { + const $statusSpan = $tr.find('.status span') + switch (status) { + case null: + // remove status + $statusSpan.hide() + break + case StorageConfig.Status.IN_PROGRESS: + $statusSpan.attr('class', 'icon-loading-small') + break + case StorageConfig.Status.SUCCESS: + $statusSpan.attr('class', 'success icon-checkmark-white') + break + case StorageConfig.Status.INDETERMINATE: + $statusSpan.attr('class', 'indeterminate icon-info-white') + break + default: + $statusSpan.attr('class', 'error icon-error-white') + } + if (status !== null) { + $statusSpan.show() + } + if (typeof message !== 'string') { + message = t('files_external', 'Click to recheck the configuration') + } + $statusSpan.attr('title', message) + }, + + /** + * Suggest mount point name that doesn't conflict with the existing names in the list + * + * @param {string} defaultMountPoint default name + */ + _suggestMountPoint(defaultMountPoint) { + const $el = this.$el + const pos = defaultMountPoint.indexOf('/') + if (pos !== -1) { + defaultMountPoint = defaultMountPoint.substring(0, pos) + } + defaultMountPoint = defaultMountPoint.replace(/\s+/g, '') + let i = 1 + let append = '' + let match = true + while (match && i < 20) { + match = false + $el.find('tbody td.mountPoint input').each(function(index, mountPoint) { + if ($(mountPoint).val() === defaultMountPoint + append) { + match = true + return false + } + }) + if (match) { + append = i + i++ + } else { + break + } + } + return defaultMountPoint + append + }, + + /** + * Toggles the mount options dropdown + * + * @param {object} $tr configuration row + */ + _showMountOptionsDropdown($tr) { + const self = this + const storage = this.getStorageConfig($tr) + const $toggle = $tr.find('.mountOptionsToggle') + const dropDown = new MountOptionsDropdown() + const visibleOptions = [ + 'previews', + 'filesystem_check_changes', + 'enable_sharing', + 'encoding_compatibility', + 'readonly', + 'delete', + ] + if (this._encryptionEnabled) { + visibleOptions.push('encrypt') + } + dropDown.show($toggle, storage.mountOptions || [], visibleOptions) + $('body').on('mouseup.mountOptionsDropdown', function(event) { + const $target = $(event.target) + if ($target.closest('.popovermenu').length) { + return + } + dropDown.hide() + }) + + dropDown.$el.on('hide', function() { + const mountOptions = dropDown.getOptions() + $('body').off('mouseup.mountOptionsDropdown') + $tr.find('input.mountOptions').val(JSON.stringify(mountOptions)) + $tr.find('td.mountOptionsToggle>.icon-more').attr('aria-expanded', 'false') + self.saveStorageConfig($tr) + }) + }, +}, OC.Backbone.Events) + +window.addEventListener('DOMContentLoaded', function() { + const enabled = $('#files_external').attr('data-encryption-enabled') + const canCreateLocal = $('#files_external').attr('data-can-create-local') + const encryptionEnabled = (enabled === 'true') + const mountConfigListView = new MountConfigListView($('#externalStorage'), { + encryptionEnabled, + canCreateLocal: (canCreateLocal === 'true'), + }) + mountConfigListView.loadStorages() + + // TODO: move this into its own View class + const $allowUserMounting = $('#allowUserMounting') + $allowUserMounting.bind('change', function() { + OC.msg.startSaving('#userMountingMsg') + if (this.checked) { + OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'yes') + $('input[name="allowUserMountingBackends\\[\\]"]').prop('checked', true) + $('#userMountingBackends').removeClass('hidden') + $('input[name="allowUserMountingBackends\\[\\]"]').eq(0).trigger('change') + } else { + OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'no') + $('#userMountingBackends').addClass('hidden') + } + OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } }) + }) + + $('input[name="allowUserMountingBackends\\[\\]"]').bind('change', function() { + OC.msg.startSaving('#userMountingMsg') + + let userMountingBackends = $('input[name="allowUserMountingBackends\\[\\]"]:checked').map(function() { + return $(this).val() + }).get() + const deprecatedBackends = $('input[name="allowUserMountingBackends\\[\\]"][data-deprecate-to]').map(function() { + if ($.inArray($(this).data('deprecate-to'), userMountingBackends) !== -1) { + return $(this).val() + } + return null + }).get() + userMountingBackends = userMountingBackends.concat(deprecatedBackends) + + OCP.AppConfig.setValue('files_external', 'user_mounting_backends', userMountingBackends.join()) + OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } }) + + // disable allowUserMounting + if (userMountingBackends.length === 0) { + $allowUserMounting.prop('checked', false) + $allowUserMounting.trigger('change') + + } + }) + + $('#global_credentials').on('submit', async function(event) { + event.preventDefault() + const $form = $(this) + const $submit = $form.find('[type=submit]') + $submit.val(t('files_external', 'Saving …')) + + const uid = $form.find('[name=uid]').val() + const user = $form.find('[name=username]').val() + const password = $form.find('[name=password]').val() + + try { + await axios.request({ + method: 'POST', + data: { + uid, + user, + password, + }, + url: generateUrl('apps/files_external/globalcredentials'), + confirmPassword: PwdConfirmationMode.Strict, + }) + + $submit.val(t('files_external', 'Saved')) + setTimeout(function() { + $submit.val(t('files_external', 'Save')) + }, 2500) + } catch (error) { + $submit.val(t('files_external', 'Save')) + if (isAxiosError(error)) { + const message = error.response?.data?.message || t('files_external', 'Failed to save global credentials') + showError(t('files_external', 'Failed to save global credentials: {message}', { message })) + } + } + + return false + }) + + // global instance + OCA.Files_External.Settings.mountConfig = mountConfigListView + + /** + * Legacy + * + * @namespace + * @deprecated use OCA.Files_External.Settings.mountConfig instead + */ + OC.MountConfig = { + saveStorage: _.bind(mountConfigListView.saveStorageConfig, mountConfigListView), + } +}) + +// export + +OCA.Files_External = OCA.Files_External || {} +/** + * @namespace + */ +OCA.Files_External.Settings = OCA.Files_External.Settings || {} + +OCA.Files_External.Settings.GlobalStorageConfig = GlobalStorageConfig +OCA.Files_External.Settings.UserStorageConfig = UserStorageConfig +OCA.Files_External.Settings.MountConfigListView = MountConfigListView diff --git a/apps/files_external/src/utils/credentialsUtils.ts b/apps/files_external/src/utils/credentialsUtils.ts index e92acf3c4ff..5c19c7bce44 100644 --- a/apps/files_external/src/utils/credentialsUtils.ts +++ b/apps/files_external/src/utils/credentialsUtils.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { StorageConfig } from '../services/externalStorage' diff --git a/apps/files_external/src/utils/externalStorageUtils.spec.ts b/apps/files_external/src/utils/externalStorageUtils.spec.ts index 35b3c9d22f9..a6a29e27a7c 100644 --- a/apps/files_external/src/utils/externalStorageUtils.spec.ts +++ b/apps/files_external/src/utils/externalStorageUtils.spec.ts @@ -1,26 +1,9 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { expect } from '@jest/globals' import { File, Folder, Permission } from '@nextcloud/files' +import { describe, expect, test } from 'vitest' import { isNodeExternalStorage } from './externalStorageUtils' describe('Is node an external storage', () => { diff --git a/apps/files_external/src/utils/externalStorageUtils.ts b/apps/files_external/src/utils/externalStorageUtils.ts index ffc4f9efb02..4407def5ce7 100644 --- a/apps/files_external/src/utils/externalStorageUtils.ts +++ b/apps/files_external/src/utils/externalStorageUtils.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { FileType, Node } from '@nextcloud/files' import type { MountEntry } from '../services/externalStorage' diff --git a/apps/files_external/src/views/CredentialsDialog.vue b/apps/files_external/src/views/CredentialsDialog.vue new file mode 100644 index 00000000000..1d506628f6d --- /dev/null +++ b/apps/files_external/src/views/CredentialsDialog.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog :buttons="dialogButtons" + class="external-storage-auth" + close-on-click-outside + data-cy-external-storage-auth + is-form + :name="t('files_external', 'Storage credentials')" + out-transition + @submit="$emit('close', {login, password})" + @update:open="$emit('close')"> + <!-- Header --> + <NcNoteCard class="external-storage-auth__header" + :text="t('files_external', 'To access the storage, you need to provide the authentication credentials.')" + type="info" /> + + <!-- Login --> + <NcTextField ref="login" + class="external-storage-auth__login" + data-cy-external-storage-auth-dialog-login + :label="t('files_external', 'Login')" + :placeholder="t('files_external', 'Enter the storage login')" + minlength="2" + name="login" + required + :value.sync="login" /> + + <!-- Password --> + <NcPasswordField ref="password" + class="external-storage-auth__password" + data-cy-external-storage-auth-dialog-password + :label="t('files_external', 'Password')" + :placeholder="t('files_external', 'Enter the storage password')" + name="password" + required + :value.sync="password" /> + </NcDialog> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' + +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default defineComponent({ + name: 'CredentialsDialog', + + components: { + NcDialog, + NcNoteCard, + NcTextField, + NcPasswordField, + }, + + setup() { + return { + t, + } + }, + + data() { + return { + login: '', + password: '', + } + }, + + computed: { + dialogButtons() { + return [{ + label: t('files_external', 'Confirm'), + type: 'primary', + nativeType: 'submit', + }] + }, + }, +}) +</script> |