diff --git a/apps/files_external/css/settings.css b/apps/files_external/css/settings.css deleted file mode 100644 index 2542c8007e9..00000000000 --- a/apps/files_external/css/settings.css +++ /dev/null @@ -1,4 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */#files_external{margin-bottom:0px}#externalStorage{margin:15px 0 20px 0}#externalStorage tr.externalStorageLoading>td{text-align:center}#externalStorage td{height:50px}#externalStorage td.mountOptionsToggle,#externalStorage td.remove,#externalStorage td.save{position:relative;padding:0 !important;width:44px}#externalStorage td.mountOptionsToggle [class^=icon-],#externalStorage td.mountOptionsToggle [class*=" icon-"],#externalStorage td.remove [class^=icon-],#externalStorage td.remove [class*=" icon-"],#externalStorage td.save [class^=icon-],#externalStorage td.save [class*=" icon-"]{width:44px;height:44px;margin:3px;opacity:.5;padding:14px;vertical-align:text-bottom;cursor:pointer}#externalStorage td.mountOptionsToggle [class^=icon-]:hover,#externalStorage td.mountOptionsToggle [class*=" icon-"]:hover,#externalStorage td.remove [class^=icon-]:hover,#externalStorage td.remove [class*=" icon-"]:hover,#externalStorage td.save [class^=icon-]:hover,#externalStorage td.save [class*=" icon-"]:hover{opacity:1}#externalStorage td.mountPoint,#externalStorage td.backend,#externalStorage td.authentication,#externalStorage td.configuration{min-width:160px;width:15%}#externalStorage td.status{display:table-cell;vertical-align:middle;width:43px}#externalStorage td.status>span{display:inline-block;height:28px;width:28px;vertical-align:text-bottom;border-radius:50%;cursor:pointer}#externalStorage td>input:not(.applicableToAllUsers),#externalStorage td>select{width:100%}#externalStorage td>img{padding-top:7px;opacity:.5}#externalStorage td>img:hover{cursor:pointer;opacity:1}#externalStorage .popovermenu li>.menuitem{width:fit-content !important}#addMountPoint>td{border:none}#addMountPoint>td.applicable{visibility:hidden}#addMountPoint>td.hidden{visibility:hidden}#selectBackend{margin-inline-start:-10px;width:150px}#externalStorage td.configuration,#externalStorage td.backend{white-space:normal}#externalStorage td.configuration>*{white-space:nowrap}#externalStorage td.configuration input.added{margin-inline-end:6px}#externalStorage label>input[type=checkbox]{margin-inline-end:3px}#externalStorage td.configuration label{width:100%;display:inline-flex;align-items:center}#externalStorage td.configuration input.disabled-success{background-color:rgba(134,255,110,.9)}#externalStorage td.applicable label{display:inline-flex;align-items:center}#externalStorage td.applicable div.chzn-container{position:relative;top:3px}#externalStorage .select2-container.applicableUsers{width:100% !important}#userMountingBackends{padding-inline-start:25px}.files-external-select2 .select2-results .select2-result-label{height:32px;padding:3px}.files-external-select2 .select2-results .select2-result-label>span{display:block;position:relative}.files-external-select2 .select2-results .select2-result-label .avatardiv{display:inline-block}.files-external-select2 .select2-results .select2-result-label .avatardiv+span{position:absolute;top:5px;margin-inline-start:10px}.files-external-select2 .select2-results .select2-result-label .avatardiv[data-type=group]+span{vertical-align:top;top:6px;position:absolute;max-width:80%;inset-inline-start:30px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#externalStorage .select2-container .select2-search-choice{display:flex}#externalStorage .select2-container .select2-search-choice .select2-search-choice-close{display:block;inset-inline-start:auto;position:relative;width:20px}#externalStorage .mountOptionsToggle .dropdown{width:auto}.nav-icon-external-storage{background-image:var(--icon-external-dark)}.global_credentials__personal{margin:10px auto;text-align:center;width:min(400px,100vw)}/*# sourceMappingURL=settings.css.map */ diff --git a/apps/files_external/css/settings.css.map b/apps/files_external/css/settings.css.map deleted file mode 100644 index 8bbd1e75612..00000000000 --- a/apps/files_external/css/settings.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sourceRoot":"","sources":["settings.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA,GAIA,gBACC,kBAGD,iBACC,qBAEA,8CACC,kBAGD,oBACC,YAEA,2FAGC,kBACA,qBACA,WACA,yRAEC,WACA,YACA,WACA,WACA,aACA,2BACA,eACA,6TACC,UAKH,gIAIC,gBACA,UAGD,2BAEC,mBACA,sBAEA,WAEA,gCACC,qBACA,YACA,WACA,2BACA,kBACA,eAIF,gFACC,WAGD,wBACC,gBACA,WAEA,8BACC,eACA,UAKH,2CACC,6BAIF,8BAEA,+CAEA,2CAEA,eACC,0BACA,YAGD,8DAEC,mBAGD,oCACC,mBAGD,8CACC,sBAGD,4CACC,sBAGD,wCACC,WACA,oBACA,mBAGD,yDACC,sCAGD,qCACC,oBACA,mBAGD,kDACC,kBACA,QAGD,oDACC,sBAGD,sBACC,0BAGD,+DACC,YACA,YAGD,oEACC,cACA,kBAGD,0EACC,qBAGD,+EACC,kBACA,QACA,yBAGD,gGACC,mBACA,QACA,kBACA,cACA,wBACA,uBACA,mBACA,gBAGD,2DACC,aACA,wFACC,cACA,wBACA,kBACA,WAIF,+CACC,WAGD,2BACC,2CAGD,8BACI,iBACA,kBACA","file":"settings.css"} \ No newline at end of file diff --git a/apps/files_external/css/settings.css.map.license b/apps/files_external/css/settings.css.map.license deleted file mode 100644 index 3e87d1c6a63..00000000000 --- a/apps/files_external/css/settings.css.map.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors -SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/files_external/css/settings.scss b/apps/files_external/css/settings.scss deleted file mode 100644 index f83c74a9122..00000000000 --- a/apps/files_external/css/settings.scss +++ /dev/null @@ -1,194 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -#files_external { - margin-bottom: 0px; -} - -#externalStorage { - margin: 15px 0 20px 0; - - tr.externalStorageLoading > td { - text-align: center; - } - - td { - height: 50px; - - &.mountOptionsToggle, - &.remove, - &.save { - position: relative; - padding: 0 !important; - width: 44px; - [class^='icon-'], - [class*=' icon-'] { - width: 44px; - height: 44px; - margin: 3px; - opacity: 0.5; - padding: 14px; - vertical-align: text-bottom; - cursor: pointer; - &:hover { - opacity: 1; - } - } - } - - &.mountPoint, - &.backend, - &.authentication, - &.configuration { - min-width: 160px; - width: 15%; - } - - &.status { - /* overwrite conflicting core styles */ - display: table-cell; - vertical-align: middle; - /* ensure width does not change even if internal span is not displayed */ - width: 43px; - - > span { - display: inline-block; - height: 28px; - width: 28px; - vertical-align: text-bottom; - border-radius: 50%; - cursor: pointer; - } - } - - > input:not(.applicableToAllUsers), & > select { - width: 100%; - } - - > img { - padding-top: 7px; - opacity: 0.5; - - &:hover { - cursor:pointer; - opacity: 1; - } - } - } - - .popovermenu li > .menuitem { - width: fit-content !important; - } -} - -#addMountPoint>td { border:none; } - -#addMountPoint>td.applicable { visibility:hidden; } - -#addMountPoint>td.hidden { visibility:hidden; } - -#selectBackend { - margin-inline-start: -10px; - width: 150px; -} - -#externalStorage td.configuration, -#externalStorage td.backend { - white-space: normal; -} - -#externalStorage td.configuration > * { - white-space: nowrap; -} - -#externalStorage td.configuration input.added { - margin-inline-end: 6px; -} - -#externalStorage label > input[type="checkbox"] { - margin-inline-end: 3px; -} - -#externalStorage td.configuration label { - width: 100%; - display: inline-flex; - align-items: center; -} - -#externalStorage td.configuration input.disabled-success { - background-color: rgba(134, 255, 110, 0.9); -} - -#externalStorage td.applicable label { - display: inline-flex; - align-items: center; -} - -#externalStorage td.applicable div.chzn-container { - position: relative; - top: 3px; -} - -#externalStorage .select2-container.applicableUsers { - width: 100% !important; -} - -#userMountingBackends { - padding-inline-start: 25px; -} - -.files-external-select2 .select2-results .select2-result-label { - height: 32px; - padding: 3px; -} - -.files-external-select2 .select2-results .select2-result-label > span { - display: block; - position: relative; -} - -.files-external-select2 .select2-results .select2-result-label .avatardiv { - display:inline-block; -} - -.files-external-select2 .select2-results .select2-result-label .avatardiv + span { - position: absolute; - top: 5px; - margin-inline-start: 10px; -} - -.files-external-select2 .select2-results .select2-result-label .avatardiv[data-type="group"] + span { - vertical-align: top; - top: 6px; - position: absolute; - max-width: 80%; - inset-inline-start: 30px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -#externalStorage .select2-container .select2-search-choice { - display: flex; - .select2-search-choice-close { - display: block; - inset-inline-start: auto; - position: relative; - width: 20px; - } -} - -#externalStorage .mountOptionsToggle .dropdown { - width: auto; -} - -.nav-icon-external-storage { - background-image: var(--icon-external-dark); -} - -.global_credentials__personal { - margin: 10px auto; - text-align: center; - width: min(400px, 100vw); -} diff --git a/apps/files_external/img/app-dark.svg b/apps/files_external/img/app-dark.svg index a74c26b4499..b53f48158cb 100644 --- a/apps/files_external/img/app-dark.svg +++ b/apps/files_external/img/app-dark.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/files_external/js/legacy-settings.js b/apps/files_external/js/legacy-settings.js new file mode 100644 index 00000000000..5e4a7094521 --- /dev/null +++ b/apps/files_external/js/legacy-settings.js @@ -0,0 +1,1514 @@ +/* + * Copyright (c) 2014 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(){ + + /** + * Returns the selection of applicable users in the given configuration row + * + * @param $row configuration row + * @return array array of user names + */ + function getSelection($row) { + var values = $row.find('.applicableUsers').select2('val'); + if (!values || values.length === 0) { + values = []; + } + return values; + } + + function getSelectedApplicable($row) { + var users = []; + var groups = []; + var multiselect = getSelection($row); + $.each(multiselect, function(index, value) { + // FIXME: don't rely on string parts to detect groups... + var 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 }; + } + + function highlightBorder($element, highlight) { + $element.toggleClass('warning-input', highlight); + return highlight; + } + + function isInputValid($input) { + var optional = $input.hasClass('optional'); + switch ($input.attr('type')) { + case 'text': + case 'password': + if ($input.val() === '' && !optional) { + return false; + } + break; + } + return true; + } + + 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} array of jQuery elements + * @param {number} userListLimit page size for result list + */ + function initApplicableUsersMultiselect($elements, userListLimit) { + var 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: function (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: function (data) { + if (data.status === 'success') { + + var results = []; + var 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' }); + }); + + + var more = (userCount >= userListLimit) || (data.groups.length >= userListLimit); + return {results: results, more: more}; + } else { + //FIXME add error handling + } + } + }, + initSelection: function(element, callback) { + var users = {}; + users['users'] = []; + var toSplit = element.val().split(","); + for (var 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) { + var results = []; + if (data.status === 'success') { + $.each(data.users, function(user, displayname) { + if (displayname !== false) { + results.push({name:user, displayname:displayname, type:'user'}); + } + }); + callback(results); + } else { + //FIXME add error handling + } + }); + }, + id: function(element) { + return element.name; + }, + formatResult: function (element) { + var $result = $('
'+escapeHTML(element.displayname)+'
'); + var $div = $result.find('.avatardiv') + .attr('data-type', element.type) + .attr('data-name', element.name) + .attr('data-displayname', element.displayname); + if (element.type === 'group') { + var url = OC.imagePath('core','actions/group'); + $div.html(''); + } + return $result.get(0).outerHTML; + }, + formatSelection: function (element) { + if (element.type === 'group') { + return ''+escapeHTML(element.displayname+' '+t('files_external', '(Group)'))+''; + } else { + return ''+escapeHTML(element.displayname)+''; + } + }, + escapeMarkup: function (m) { return m; } // we escape the markup in formatResult and formatSelection + }).on('select2-loaded', function() { + $.each($('.avatardiv'), function(i, div) { + var $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); + }); + } + + /** + * @class OCA.Files_External.Settings.StorageConfig + * + * @classdesc External storage config + */ + var 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. + */ + backendOptions: null, + + /** + * Mount-specific options + * + * @type Object. + */ + mountOptions: null, + + /** + * Creates or saves the storage. + * + * @param {Function} [options.success] success callback, receives result as argument + * @param {Function} [options.error] error callback + */ + save: function(options) { + var self = this; + var url = OC.generateUrl(this._url); + var method = 'POST'; + if (_.isNumber(this.id)) { + method = 'PUT'; + url = OC.generateUrl(this._url + '/{id}', {id: this.id}); + } + + $.ajax({ + type: method, + url: url, + contentType: 'application/json', + data: JSON.stringify(this.getData()), + success: function(result) { + self.id = result.id; + if (_.isFunction(options.success)) { + options.success(result); + } + }, + error: options.error + }); + }, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData: function() { + var 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 + */ + recheck: function(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 + */ + destroy: function(options) { + if (!_.isNumber(this.id)) { + // the storage hasn't even been created => success + if (_.isFunction(options.success)) { + options.success(); + } + return; + } + $.ajax({ + type: 'DELETE', + url: OC.generateUrl(this._url + '/{id}', {id: this.id}), + success: options.success, + error: options.error + }); + }, + + /** + * Validate this model + * + * @return {boolean} false if errors exist, true otherwise + */ + validate: function() { + if (this.mountPoint === '') { + return false; + } + if (!this.backend) { + return false; + } + if (this.errors) { + return false; + } + return true; + } + }; + + /** + * @class OCA.Files_External.Settings.GlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc Global external storage config + */ + var 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. + */ + applicableUsers: null, + + /** + * Applicable groups + * + * @type Array. + */ + applicableGroups: null, + + /** + * Storage priority + * + * @type int + */ + priority: null, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData: function() { + var data = StorageConfig.prototype.getData.apply(this, arguments); + return _.extend(data, { + applicableUsers: this.applicableUsers, + applicableGroups: this.applicableGroups, + priority: this.priority, + }); + } + }); + + /** + * @class OCA.Files_External.Settings.UserStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ + var UserStorageConfig = function(id) { + this.id = id; + }; + UserStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ { + _url: 'apps/files_external/userstorages' + }); + + /** + * @class OCA.Files_External.Settings.UserGlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ + var 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 + */ + var MountOptionsDropdown = function() { + }; + /** + * @memberof OCA.Files_External.Settings + */ + MountOptionsDropdown.prototype = { + /** + * Dropdown element + * + * @var Object + */ + $el: null, + + /** + * Show dropdown + * + * @param {Object} $container container + * @param {Object} mountOptions mount options + * @param {Array} visibleOptions enabled mount options + */ + show: function($container, mountOptions, visibleOptions) { + if (MountOptionsDropdown._last) { + MountOptionsDropdown._last.hide(); + } + + var $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; + + var storage = $container[0].parentNode.className; + + this.setOptions(mountOptions, visibleOptions, storage); + + this.$el.appendTo($container); + MountOptionsDropdown._last = this; + + this.$el.trigger('show'); + }, + + hide: function() { + 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: function() { + var options = {}; + + this.$el.find('input, select').each(function() { + var $this = $(this); + var key = $this.attr('name'); + var 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 + */ + setOptions: function(options, visibleOptions, storage) { + if (storage === 'owncloud') { + var ind = visibleOptions.indexOf('encrypt'); + if (ind > 0) { + visibleOptions.splice(ind, 1); + } + } + var $el = this.$el; + _.each(options, function(value, key) { + var $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){ + var $row = $(row); + var 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 + */ + var MountConfigListView = function($el, options) { + this.initialize($el, options); + }; + + MountConfigListView.ParameterFlags = { + OPTIONAL: 1, + USER_PROVIDED: 2 + }; + + MountConfigListView.ParameterTypes = { + TEXT: 0, + BOOLEAN: 1, + PASSWORD: 2, + HIDDEN: 3 + }; + + /** + * @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. + */ + _allBackends: null, + + /** + * List of all supported authentication mechanisms + * + * @type 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: function($el, options) { + var self = this; + 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 + */ + whenSelectBackend: function(callback) { + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + var backend = $(tr).find('.backend').data('identifier'); + callback($(tr), backend); + }); + this.on('selectBackend', callback); + }, + whenSelectAuthMechanism: function(callback) { + var self = this; + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + var authMechanism = $(tr).find('.selectAuthMechanism').val(); + callback($(tr), authMechanism, self._allAuthMechanisms[authMechanism]['scheme']); + }); + this.on('selectAuthMechanism', callback); + }, + + /** + * Initialize DOM event handlers + */ + _initEvents: function() { + var self = this; + + var 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: function(event) { + var self = this; + var $target = $(event.target); + if ($target.closest('.dropdown').length) { + // ignore dropdown events + return; + } + highlightInput($target); + var $tr = $target.closest('tr'); + this.updateStatus($tr, null); + }, + + _onSelectBackend: function(event) { + var $target = $(event.target); + var $tr = $target.closest('tr'); + + var storageConfig = new this._storageConfigClass(); + storageConfig.mountPoint = $tr.find('.mountPoint input').val(); + storageConfig.backend = $target.val(); + $tr.find('.mountPoint input').val(''); + + var 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: function(event) { + var $target = $(event.target); + var $tr = $target.closest('tr'); + var authMechanism = $target.val(); + + var onCompletion = jQuery.Deferred(); + this.configureAuthMechanism($tr, authMechanism, onCompletion); + onCompletion.resolve(); + + this.saveStorageConfig($tr); + }, + + _onChangeApplicableToAllUsers: function(event) { + var $target = $(event.target); + var $tr = $target.closest('tr'); + var 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: function($tr, authMechanism, onCompletion) { + var authMechanismConfiguration = this._allAuthMechanisms[authMechanism]; + var $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: function(storageConfig, onCompletion, deferAppend) { + var mountPoint = storageConfig.mountPoint; + var backend = this._allBackends[storageConfig.backend]; + + if (!backend) { + backend = { + name: 'Unknown: ' + storageConfig.backend, + invalid: true + }; + } + + // FIXME: Replace with a proper Handlebar template + var $template = this.$el.find('tr#addMountPoint'); + var $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, 'Unknown backend: ' + backend.name); + } + return $tr; + } + + var selectAuthMechanism = $(''); + var 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( + $('') + ); + } + }); + if (storageConfig.authMechanism) { + selectAuthMechanism.val(storageConfig.authMechanism); + } else { + storageConfig.authMechanism = selectAuthMechanism.val(); + } + $tr.find('td.authentication').append(selectAuthMechanism); + + var $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() { + var input = $(this); + var 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); + } + }); + } + + var 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); + + var priorityEl = $(''); + $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: function() { + var self = this; + + var onLoaded1 = $.Deferred(); + var 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: function(result) { + var onCompletion = jQuery.Deferred(); + var $rows = $(); + Object.values(result).forEach(function(storageParams) { + var storageConfig; + var 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); + var $tr = self.newStorage(storageConfig, onCompletion,true); + + // userglobal storages must be at the top of the list + $tr.detach(); + self.$el.prepend($tr); + + var $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')); + } + $rows = $rows.add($tr); + }); + initApplicableUsersMultiselect(self.$el.find('.applicableUsers'), this._userListLimit); + self.$el.find('tr#addMountPoint').before($rows); + var 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(); + } + + var url = this._storageConfigClass.prototype._url; + + $.ajax({ + type: 'GET', + url: OC.generateUrl(url), + contentType: 'application/json', + success: function(result) { + result = Object.values(result); + var onCompletion = jQuery.Deferred(); + var $rows = $(); + result.forEach(function(storageParams) { + storageParams.mountPoint = (storageParams.mountPoint === '/')? '/' : storageParams.mountPoint.substr(1); // trim leading slash + var storageConfig = new self._storageConfigClass(); + _.extend(storageConfig, storageParams); + var $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: function($td, parameter, placeholder, classes) { + var 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; + } + } + + var newElement; + + var trimmedPlaceholder = placeholder.value; + if (placeholder.type === MountConfigListView.ParameterTypes.PASSWORD) { + newElement = $(''); + } else if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { + var checkboxId = _.uniqueId('checkbox_'); + newElement = $('
'); + } else if (placeholder.type === MountConfigListView.ParameterTypes.HIDDEN) { + newElement = $(''); + } else { + newElement = $(''); + } + + 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: function($tr) { + var storageId = $tr.data('id'); + if (!storageId) { + // new entry + storageId = null; + } + + var 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(); + + var classOptions = {}; + var configuration = $tr.find('.configuration input'); + var missingOptions = []; + $.each(configuration, function(index, input) { + var $input = $(input); + var 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) { + var multiselect = getSelectedApplicable($tr); + var users = multiselect.users || []; + var groups = multiselect.groups || []; + var 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); + } + + var 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: function($tr) { + var self = this; + var configId = $tr.data('id'); + if (!_.isNumber(configId)) { + // deleting unsaved storage + $tr.remove(); + return; + } + var 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: function () { + $tr.remove(); + }, + error: function () { + self.updateStatus($tr, StorageConfig.Status.ERROR); + } + }); + } + }); + }, + + /** + * Saves the storage from the given tr + * + * @param $tr storage row + * @param Function callback callback to call after save + * @param concurrentTimer only update if the timer matches this + */ + saveStorageConfig:function($tr, callback, concurrentTimer) { + var self = this; + var storage = this.getStorageConfig($tr); + if (!storage || !storage.validate()) { + return false; + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS); + storage.save({ + success: function(result) { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + self.updateStatus($tr, result.status); + $tr.data('id', result.id); + + if (_.isFunction(callback)) { + callback(storage); + } + } + }, + error: function() { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + self.updateStatus($tr, StorageConfig.Status.ERROR); + } + } + }); + }, + + /** + * Recheck storage availability + * + * @param {jQuery} $tr storage row + * @return {boolean} success + */ + recheckStorageConfig: function($tr) { + var self = this; + var storage = this.getStorageConfig($tr); + if (!storage.validate()) { + return false; + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS); + storage.recheck({ + success: function(result) { + self.updateStatus($tr, result.status, result.statusMessage); + }, + error: function() { + self.updateStatus($tr, StorageConfig.Status.ERROR); + } + }); + }, + + /** + * Update status display + * + * @param {jQuery} $tr + * @param {number} status + * @param {string} message + */ + updateStatus: function($tr, status, message) { + var $statusSpan = $tr.find('.status span'); + switch (status) { + case null: + // remove status + 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 (typeof message === 'string') { + $statusSpan.attr('title', message); + $statusSpan.tooltip(); + } else { + $statusSpan.tooltip('dispose'); + } + }, + + /** + * Suggest mount point name that doesn't conflict with the existing names in the list + * + * @param {string} defaultMountPoint default name + */ + _suggestMountPoint: function(defaultMountPoint) { + var $el = this.$el; + var pos = defaultMountPoint.indexOf('/'); + if (pos !== -1) { + defaultMountPoint = defaultMountPoint.substring(0, pos); + } + defaultMountPoint = defaultMountPoint.replace(/\s+/g, ''); + var i = 1; + var append = ''; + var 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: function($tr) { + var self = this; + var storage = this.getStorageConfig($tr); + var $toggle = $tr.find('.mountOptionsToggle'); + var dropDown = new MountOptionsDropdown(); + var 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) { + var $target = $(event.target); + if ($target.closest('.popovermenu').length) { + return; + } + dropDown.hide(); + }); + + dropDown.$el.on('hide', function() { + var 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() { + var enabled = $('#files_external').attr('data-encryption-enabled'); + var canCreateLocal = $('#files_external').attr('data-can-create-local'); + var encryptionEnabled = (enabled ==='true')? true: false; + var mountConfigListView = new MountConfigListView($('#externalStorage'), { + encryptionEnabled: encryptionEnabled, + canCreateLocal: (canCreateLocal === 'true') ? true: false, + }); + mountConfigListView.loadStorages(); + + // TODO: move this into its own View class + var $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'); + + var userMountingBackends = $('input[name="allowUserMountingBackends\\[\\]"]:checked').map(function(){ + return $(this).val(); + }).get(); + var 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', function() { + var $form = $(this); + var uid = $form.find('[name=uid]').val(); + var user = $form.find('[name=username]').val(); + var password = $form.find('[name=password]').val(); + var $submit = $form.find('[type=submit]'); + $submit.val(t('files_external', 'Saving …')); + $.ajax({ + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + uid: uid, + user: user, + password: password + }), + url: OC.generateUrl('apps/files_external/globalcredentials'), + dataType: 'json', + success: function() { + $submit.val(t('files_external', 'Saved')); + setTimeout(function(){ + $submit.val(t('files_external', 'Save')); + }, 2500); + } + }); + 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; + + })(); + \ No newline at end of file diff --git a/apps/files_external/js/templates.js b/apps/files_external/js/templates.js deleted file mode 100644 index 4f89ec78359..00000000000 --- a/apps/files_external/js/templates.js +++ /dev/null @@ -1,47 +0,0 @@ -(function() { - var template = Handlebars.template, templates = OCA.Files_External.Templates = OCA.Files_External.Templates || {}; -templates['credentialsDialog'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { - var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=container.hooks.helperMissing, alias3="function", alias4=container.escapeExpression, lookupProperty = container.lookupProperty || function(parent, propertyName) { - if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { - return parent[propertyName]; - } - return undefined - }; - - return "
\n
" - + alias4(((helper = (helper = lookupProperty(helpers,"credentials_text") || (depth0 != null ? lookupProperty(depth0,"credentials_text") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"credentials_text","hash":{},"data":data,"loc":{"start":{"line":2,"column":6},"end":{"line":2,"column":26}}}) : helper))) - + "
\n
\n \n \n
\n
\n
\n"; -},"useData":true}); -templates['mountOptionsDropDown'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { - var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=container.hooks.helperMissing, alias3="function", alias4=container.escapeExpression, lookupProperty = container.lookupProperty || function(parent, propertyName) { - if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { - return parent[propertyName]; - } - return undefined - }; - - return "
\n
    \n
  • \n \n \n \n \n
  • \n
  • \n \n \n \n \n
  • \n
  • \n \n \n \n \n
  • \n
  • \n \n \n \n \n
  • \n
  • \n \n \n \n \n
  • \n
  • \n \n \n \n \n
  • \n
  • \n \n " - + alias4(((helper = (helper = lookupProperty(helpers,"deleteLabel") || (depth0 != null ? lookupProperty(depth0,"deleteLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"deleteLabel","hash":{},"data":data,"loc":{"start":{"line":44,"column":10},"end":{"line":44,"column":25}}}) : helper))) - + "\n \n
  • \n
\n
\n"; -},"useData":true}); -})(); \ No newline at end of file diff --git a/apps/files_external/js/templates/credentialsDialog.handlebars b/apps/files_external/js/templates/credentialsDialog.handlebars deleted file mode 100644 index c04ceef096b..00000000000 --- a/apps/files_external/js/templates/credentialsDialog.handlebars +++ /dev/null @@ -1,8 +0,0 @@ -
-
{{credentials_text}}
-
- - -
-
-
diff --git a/apps/files_external/lib/MountConfig.php b/apps/files_external/lib/MountConfig.php index 5637ee71ec1..93ddb45971b 100644 --- a/apps/files_external/lib/MountConfig.php +++ b/apps/files_external/lib/MountConfig.php @@ -18,10 +18,8 @@ use OCA\Files_External\Service\UserStoragesService; use OCP\AppFramework\QueryException; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; -use OCP\IL10N; use OCP\Security\ISecureRandom; use OCP\Server; -use OCP\Util; use phpseclib\Crypt\AES; use Psr\Log\LoggerInterface; @@ -110,52 +108,6 @@ class MountConfig { return StorageNotAvailableException::STATUS_ERROR; } - /** - * Get backend dependency message - * TODO: move into AppFramework along with templates - * - * @param Backend[] $backends - */ - public static function dependencyMessage(array $backends): string { - $l = Util::getL10N('files_external'); - $message = ''; - $dependencyGroups = []; - - foreach ($backends as $backend) { - foreach ($backend->checkDependencies() as $dependency) { - $dependencyMessage = $dependency->getMessage(); - if ($dependencyMessage !== null) { - $message .= '

' . $dependencyMessage . '

'; - } else { - $dependencyGroups[$dependency->getDependency()][] = $backend; - } - } - } - - foreach ($dependencyGroups as $module => $dependants) { - $backends = implode(', ', array_map(function (Backend $backend): string { - return '"' . $backend->getText() . '"'; - }, $dependants)); - $message .= '

' . MountConfig::getSingleDependencyMessage($l, $module, $backends) . '

'; - } - - return $message; - } - - /** - * Returns a dependency missing message - */ - private static function getSingleDependencyMessage(IL10N $l, string $module, string $backend): string { - switch (strtolower($module)) { - case 'curl': - return $l->t('The cURL support in PHP is not enabled or installed. Mounting of %s is not possible. Please ask your system administrator to install it.', [$backend]); - case 'ftp': - return $l->t('The FTP support in PHP is not enabled or installed. Mounting of %s is not possible. Please ask your system administrator to install it.', [$backend]); - default: - return $l->t('"%1$s" is not installed. Mounting of %2$s is not possible. Please ask your system administrator to install it.', [$module, $backend]); - } - } - /** * Encrypt passwords in the given config options * diff --git a/apps/files_external/lib/Settings/Admin.php b/apps/files_external/lib/Settings/Admin.php index 591fb9bc591..685094adca6 100644 --- a/apps/files_external/lib/Settings/Admin.php +++ b/apps/files_external/lib/Settings/Admin.php @@ -7,11 +7,13 @@ namespace OCA\Files_External\Settings; use OCA\Files_External\Lib\Auth\Password\GlobalAuth; -use OCA\Files_External\MountConfig; +use OCA\Files_External\Lib\Backend\Backend; use OCA\Files_External\Service\BackendService; use OCA\Files_External\Service\GlobalStoragesService; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; use OCP\Encryption\IManager; +use OCP\IURLGenerator; use OCP\Settings\ISettings; class Admin implements ISettings { @@ -22,6 +24,8 @@ class Admin implements ISettings { private GlobalStoragesService $globalStoragesService, private BackendService $backendService, private GlobalAuth $globalAuth, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, ) { } @@ -29,20 +33,29 @@ class Admin implements ISettings { * @return TemplateResponse */ public function getForm() { - $parameters = [ - 'encryptionEnabled' => $this->encryptionManager->isEnabled(), - 'visibilityType' => BackendService::VISIBILITY_ADMIN, - 'storages' => $this->globalStoragesService->getStorages(), - 'backends' => $this->backendService->getAvailableBackends(), - 'authMechanisms' => $this->backendService->getAuthMechanisms(), - 'dependencies' => MountConfig::dependencyMessage($this->backendService->getBackends()), + // Shared settings (user & admin) + $this->setInitialState(BackendService::VISIBILITY_ADMIN); + + // Admin specific + $backends = $this->backendService->getAvailableBackends(); + $allowedBackends = array_filter($backends, fn (Backend $backend) => $backend->isVisibleFor(BackendService::VISIBILITY_PERSONAL)); + $this->initialState->provideInitialState('user-mounting', [ 'allowUserMounting' => $this->backendService->isUserMountingAllowed(), - 'globalCredentials' => $this->globalAuth->getAuth(''), - 'globalCredentialsUid' => '', - ]; + 'allowedBackends' => array_values(array_map(fn (Backend $backend) => $backend->getIdentifier(), $allowedBackends)), + 'backends' => array_values( + array_map( + fn (Backend $backend) => [ + 'id' => $backend->getIdentifier(), + 'displayName' => $backend->getText(), + 'deprecated' => $backend->getDeprecateTo()?->getIdentifier(), + ], + $backends, + ), + ), + ]); $this->loadScriptsAndStyles(); - return new TemplateResponse('files_external', 'settings', $parameters, ''); + return new TemplateResponse('files_external', 'settings', renderAs: ''); } /** diff --git a/apps/files_external/lib/Settings/CommonSettingsTrait.php b/apps/files_external/lib/Settings/CommonSettingsTrait.php index b52cc9419a2..dd5d7fa3796 100644 --- a/apps/files_external/lib/Settings/CommonSettingsTrait.php +++ b/apps/files_external/lib/Settings/CommonSettingsTrait.php @@ -3,34 +3,58 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen - * - * @author Ferdinand Thiessen - * - * @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 . - * + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Files_External\Settings; +use OCA\Files_External\Lib\Auth\Password\GlobalAuth; +use OCA\Files_External\Lib\Backend\Backend; use OCA\Files_External\Service\BackendService; +use OCP\AppFramework\Services\IInitialState; +use OCP\IURLGenerator; use OCP\Util; trait CommonSettingsTrait { - protected BackendService $backendService; + private BackendService $backendService; + + private IInitialState $initialState; + + private IURLGenerator $urlGenerator; + + private GlobalAuth $globalAuth; + + private ?string $userId = null; + + /** + * Set the initial state for the user / admin settings + * + * @param int $visibilityType The visibility type used to determine which options to show (admin vs user settings) + */ + protected function setInitialState(int $visibilityType) { + $allowUserMounting = $this->backendService->isUserMountingAllowed(); + $isAdmin = $visibilityType === BackendService::VISIBILITY_ADMIN; + $canCreateMounts = $isAdmin || $allowUserMounting; + + $this->initialState->provideInitialState('settings', [ + /** Link to external files documentation */ + 'docUrl' => $this->urlGenerator->linkToDocs('admin-external-storage'), + /** List of backend dependency or missing module issues to be shown on the fronend */ + 'dependencyIssues' => $canCreateMounts ? $this->dependencyMessage() : null, + /** Is this the admin settings or just user settings */ + 'isAdmin' => $isAdmin, + ]); + + $this->initialState->provideInitialState( + 'global-credentials', + array_merge( + /** User ID of the credentials - empty string for global admin defined */ + ['uid' => $this->userId ?? '' ], + /** username and password configured */ + $this->globalAuth->getAuth($this->userId ?? ''), + ), + ); + } /** * Load the frontend script including the custom backend dependencies @@ -52,4 +76,36 @@ trait CommonSettingsTrait { } } } + + /** + * Get backend dependency error messages + * @return array{messages: string[], modules: array} + */ + private function dependencyMessage(): array { + $messages = []; + $dependencyGroups = []; + + // Try all backends and check their dependencies + foreach ($this->backendService->getAvailableBackends() as $backend) { + foreach ($backend->checkDependencies() as $dependency) { + $dependencyMessage = $dependency->getMessage(); + if ($dependencyMessage !== null) { + // There is a custom message so we use that + $messages[] = $dependencyMessage; + } else { + // No custom message so just add the dependency and add the backend to the list of dependants + $dependencyGroups[$dependency->getDependency()][] = $backend; + } + } + } + + $backendDisplayName = fn (Backend $backend) => $backend->getText(); + + // Create a mapping [ 'dependency' => ['backendName1', ... ]] + $missingModules = array_map(fn (array $dependants) => array_map($backendDisplayName, $dependants), $dependencyGroups); + return [ + 'messages' => $messages, + 'modules' => $missingModules, + ]; + } } diff --git a/apps/files_external/lib/Settings/Personal.php b/apps/files_external/lib/Settings/Personal.php index 767794e1dcc..b84f5969fed 100644 --- a/apps/files_external/lib/Settings/Personal.php +++ b/apps/files_external/lib/Settings/Personal.php @@ -7,46 +7,32 @@ namespace OCA\Files_External\Settings; use OCA\Files_External\Lib\Auth\Password\GlobalAuth; -use OCA\Files_External\MountConfig; use OCA\Files_External\Service\BackendService; -use OCA\Files_External\Service\UserGlobalStoragesService; use OCP\AppFramework\Http\TemplateResponse; -use OCP\Encryption\IManager; -use OCP\IUserSession; +use OCP\AppFramework\Services\IInitialState; +use OCP\IURLGenerator; use OCP\Settings\ISettings; class Personal implements ISettings { use CommonSettingsTrait; public function __construct( - private IManager $encryptionManager, - private UserGlobalStoragesService $userGlobalStoragesService, + ?string $userId, private BackendService $backendService, private GlobalAuth $globalAuth, - private IUserSession $userSession, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, ) { + $this->userId = $userId; } /** * @return TemplateResponse */ public function getForm() { - $uid = $this->userSession->getUser()->getUID(); - - $parameters = [ - 'encryptionEnabled' => $this->encryptionManager->isEnabled(), - 'visibilityType' => BackendService::VISIBILITY_PERSONAL, - 'storages' => $this->userGlobalStoragesService->getStorages(), - 'backends' => $this->backendService->getAvailableBackends(), - 'authMechanisms' => $this->backendService->getAuthMechanisms(), - 'dependencies' => MountConfig::dependencyMessage($this->backendService->getBackends()), - 'allowUserMounting' => $this->backendService->isUserMountingAllowed(), - 'globalCredentials' => $this->globalAuth->getAuth($uid), - 'globalCredentialsUid' => $uid, - ]; + $this->setInitialState(BackendService::VISIBILITY_PERSONAL); $this->loadScriptsAndStyles(); - - return new TemplateResponse('files_external', 'settings', $parameters, ''); + return new TemplateResponse('files_external', 'settings', renderAs: ''); } /** diff --git a/apps/files_external/src/components/ExternalStorageTable.vue b/apps/files_external/src/components/ExternalStorageTable.vue new file mode 100644 index 00000000000..2415662ae1e --- /dev/null +++ b/apps/files_external/src/components/ExternalStorageTable.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/files_external/src/components/UserMountSettings.vue b/apps/files_external/src/components/UserMountSettings.vue new file mode 100644 index 00000000000..d96e056ccb1 --- /dev/null +++ b/apps/files_external/src/components/UserMountSettings.vue @@ -0,0 +1,120 @@ + + + + + + diff --git a/apps/files_external/src/settings-main.ts b/apps/files_external/src/settings-main.ts new file mode 100644 index 00000000000..8cc6ec67a31 --- /dev/null +++ b/apps/files_external/src/settings-main.ts @@ -0,0 +1,10 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import FilesExternalApp from './views/FilesExternalSettings.vue' + +const app = createApp(FilesExternalApp) +app.mount('#files-external') diff --git a/apps/files_external/src/settings.js b/apps/files_external/src/settings.js deleted file mode 100644 index cac60445377..00000000000 --- a/apps/files_external/src/settings.js +++ /dev/null @@ -1,1600 +0,0 @@ -/* eslint-disable no-undef */ -/** - * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import axios, { isAxiosError } from '@nextcloud/axios' -import { showError } from '@nextcloud/dialogs' -import { t } from '@nextcloud/l10n' -import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation' -import { generateUrl } from '@nextcloud/router' -import _ from 'underscore' - -// we cannot use this as we need the global jQuery here for select2 -// import $ 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} 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/ajax/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) { - 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 } - }, - }, - 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 = $('
' + escapeHTML(element.displayname) + '
') - 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('') - } - return $result.get(0).outerHTML - }, - formatSelection(element) { - if (element.type === 'group') { - return '' + escapeHTML(element.displayname + ' ' + t('files_external', '(Group)')) + '' - } else { - return '' + escapeHTML(element.displayname) + '' - } - }, - 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 - */ -function StorageConfig(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. - */ - backendOptions: null, - - /** - * Mount-specific options - * - * @type 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 - */ -function GlobalStorageConfig(id) { - this.id = id - this.applicableUsers = [] - this.applicableGroups = [] -} -/** - * @namespace 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. - */ - applicableUsers: null, - - /** - * Applicable groups - * - * @type Array. - */ - 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 - */ -function UserStorageConfig(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 - */ -function UserGlobalStorageConfig(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 - */ -function MountOptionsDropdown() { -} -/** - * @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) - - this._initialOptions = JSON.stringify(this.getOptions()) - 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 - */ -function MountConfigListView($el, options) { - this.initialize($el, options) -} - -MountConfigListView.ParameterFlags = { - OPTIONAL: 1, - USER_PROVIDED: 2, - HIDDEN: 4, -} - -MountConfigListView.ParameterTypes = { - TEXT: 0, - BOOLEAN: 1, - PASSWORD: 2, -} - -/** - * @namespace 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. - */ - _allBackends: null, - - /** - * List of all supported authentication mechanisms - * - * @type 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 = $.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 = $.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 {$.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 {$.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 = $('') - 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($('')) - } - }) - 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 = $('') - $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 = $.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 = $.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 = $('') - } else if (placeholder.type === MountConfigListView.ParameterTypes.PASSWORD) { - newElement = $('') - } else if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { - const checkboxId = _.uniqueId('checkbox_') - newElement = $('
') - } else { - newElement = $('') - } - - 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?') - + ' ' - + t('files_external', 'It will make the storage unavailable in {instanceName} 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, - instanceName: window.OC.theme.name, - }), - 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 newOptions = dropDown.getOptions() - const newOptionsStr = JSON.stringify(newOptions) - $('body').off('mouseup.mountOptionsDropdown') - $tr.find('td.mountOptionsToggle>.icon-more').attr('aria-expanded', 'false') - if (dropDown._initialOptions !== newOptionsStr) { - $tr.find('input.mountOptions').val(newOptionsStr) - 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/store/storages.ts b/apps/files_external/src/store/storages.ts new file mode 100644 index 00000000000..cb152a43d83 --- /dev/null +++ b/apps/files_external/src/store/storages.ts @@ -0,0 +1,57 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IStorage } from '../types.d.ts' + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' + +export const useStorages = defineStore('files_external--storages', { + state() { + return { + globalStorages: [] as IStorage[], + userStorages: [] as IStorage[], + } + }, + + getters: { + allStorages(state) { + return [...state.globalStorages, state.userStorages] + }, + }, + + actions: { + async loadGlobalStorages() { + const url = 'apps/files_external/globalstorages' + const { data } = await axios.get(generateUrl(url)) + + this.globalStorages = data + }, + }, + /* result = Object.values(result); + var onCompletion = jQuery.Deferred(); + var $rows = $(); + result.forEach(function(storageParams) { + storageParams.mountPoint = (storageParams.mountPoint === '/')? '/' : storageParams.mountPoint.substr(1); // trim leading slash + var storageConfig = new self._storageConfigClass(); + _.extend(storageConfig, storageParams); + var $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(); + } + }, */ +}) diff --git a/apps/files_external/src/types.d.ts b/apps/files_external/src/types.d.ts new file mode 100644 index 00000000000..c209929a08e --- /dev/null +++ b/apps/files_external/src/types.d.ts @@ -0,0 +1,21 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface IStorage { + id?: number + + mountPoint: string + backend: string + authMechanism: string + backendOptions: Record + priority?: number + applicableUsers?: string[] + applicableGroups?: string[] + mountOptions?: Record + status?: number + statusMessage?: string + userProvided: bool + type: 'personal' | 'system' +} diff --git a/apps/files_external/src/utils/logger.ts b/apps/files_external/src/utils/logger.ts new file mode 100644 index 00000000000..d96d19c02e7 --- /dev/null +++ b/apps/files_external/src/utils/logger.ts @@ -0,0 +1,3 @@ +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder().setApp('files_external').build() diff --git a/apps/files_external/src/views/ExternalStoragesSection.vue b/apps/files_external/src/views/ExternalStoragesSection.vue new file mode 100644 index 00000000000..3e558043d49 --- /dev/null +++ b/apps/files_external/src/views/ExternalStoragesSection.vue @@ -0,0 +1,88 @@ + + + + + + diff --git a/apps/files_external/src/views/FilesExternalSettings.vue b/apps/files_external/src/views/FilesExternalSettings.vue new file mode 100644 index 00000000000..b3c74716f51 --- /dev/null +++ b/apps/files_external/src/views/FilesExternalSettings.vue @@ -0,0 +1,13 @@ + + + + diff --git a/apps/files_external/src/views/GlobalCredentialsSection.vue b/apps/files_external/src/views/GlobalCredentialsSection.vue new file mode 100644 index 00000000000..bab9786565d --- /dev/null +++ b/apps/files_external/src/views/GlobalCredentialsSection.vue @@ -0,0 +1,103 @@ + + + + + + + diff --git a/apps/files_external/templates/legacy-settings.php b/apps/files_external/templates/legacy-settings.php new file mode 100644 index 00000000000..57d4c575f9d --- /dev/null +++ b/apps/files_external/templates/legacy-settings.php @@ -0,0 +1,240 @@ +t('Enable encryption'); +$l->t('Enable previews'); +$l->t('Enable sharing'); +$l->t('Check for changes'); +$l->t('Never'); +$l->t('Once every direct access'); +$l->t('Read only'); + +script('files_external', [ + 'settings', + 'templates' +]); +style('files_external', 'settings'); + +// load custom JS +foreach ($_['backends'] as $backend) { + /** @var Backend $backend */ + $scripts = $backend->getCustomJs(); + foreach ($scripts as $script) { + script('files_external', $script); + } +} +foreach ($_['authMechanisms'] as $authMechanism) { + /** @var AuthMechanism $authMechanism */ + $scripts = $authMechanism->getCustomJs(); + foreach ($scripts as $script) { + script('files_external', $script); + } +} + +function writeParameterInput($parameter, $options, $classes = []) { + $value = ''; + if (isset($options[$parameter->getName()])) { + $value = $options[$parameter->getName()]; + } + $placeholder = $parameter->getText(); + $is_optional = $parameter->isFlagSet(DefinitionParameter::FLAG_OPTIONAL); + + switch ($parameter->getType()) { + case DefinitionParameter::VALUE_PASSWORD: ?> + + class="" + data-parameter="getName()); ?>" + value="" + placeholder="" + /> + + +
+ +
+ + class="" + data-parameter="getName()); ?>" + value="" + /> + + + class="" + data-parameter="getName()); ?>" + value="" + placeholder="" + /> + + + + +getConfig()->getSystemValue('files_external_allow_create_new_local', true); +?> +
+

t('External storage')); ?>

+ +

t('External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices. You may also allow people to mount their own external storage services.')); ?>

+ + '> + + + + + + + + ' . $l->t('Available for') . ''); + } ?> + + + + + + + + + + + style="display: none;" + + > + + + + + + + + + + + + +
t('Folder name')); ?>t('External storage')); ?>t('Authentication')); ?>t('Configuration')); ?>   
+ +
+ + + + + '> + +
+ +
+
+ + + /> + + +

class="hidden"> + isAllowedVisibleFor(BackendService::VISIBILITY_PERSONAL); + }); + ?> + + getDeprecateTo()): ?> + + + isVisibleFor(BackendService::VISIBILITY_PERSONAL)) { + print_unescaped(' checked="checked"'); + } ?> /> +
+ + + +

+ +
+ +
+ +
diff --git a/apps/files_external/templates/settings.php b/apps/files_external/templates/settings.php index d2da54fdd21..30f78d072bb 100644 --- a/apps/files_external/templates/settings.php +++ b/apps/files_external/templates/settings.php @@ -1,186 +1,7 @@ t('Enable encryption'); -$l->t('Enable previews'); -$l->t('Enable sharing'); -$l->t('Check for changes'); -$l->t('Never'); -$l->t('Once every direct access'); -$l->t('Read only'); - -\OCP\Util::addScript('files_external', 'settings'); -\OCP\Util::addScript('files_external', 'templates'); -style('files_external', 'settings'); - -// load custom JS -foreach ($_['backends'] as $backend) { - /** @var Backend $backend */ - $scripts = $backend->getCustomJs(); - foreach ($scripts as $script) { - script('files_external', $script); - } -} -foreach ($_['authMechanisms'] as $authMechanism) { - /** @var AuthMechanism $authMechanism */ - $scripts = $authMechanism->getCustomJs(); - foreach ($scripts as $script) { - script('files_external', $script); - } -} - ?> - - - -getSystemValue('files_external_allow_create_new_local', true); -?> -
-

t('External storage')); ?>

- -

t('External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices. You may also allow people to mount their own external storage services.')); ?>

- - '> - - - - - - - - ' . $l->t('Available for') . ''); - } ?> - - - - - - - - - - - style="display: none;" - - > - - - - - - - - - - - - -
t('Folder name')); ?>t('External storage')); ?>t('Authentication')); ?>t('Configuration')); ?>   
- -
- - - - - '> - -
- -
-
- - - /> - - -

class="hidden"> - isAllowedVisibleFor(BackendService::VISIBILITY_PERSONAL); - }); - ?> - - getDeprecateTo()): ?> - - - isVisibleFor(BackendService::VISIBILITY_PERSONAL)) { - print_unescaped(' checked="checked"'); - } ?> /> -
- - - -

- -
- -
- -
+
diff --git a/build/frontend/vite.config.ts b/build/frontend/vite.config.ts index dd5c0e73992..8ab278e69f7 100644 --- a/build/frontend/vite.config.ts +++ b/build/frontend/vite.config.ts @@ -22,7 +22,7 @@ const modules = { }, files_external: { init: resolve(import.meta.dirname, 'apps/files_external/src', 'init.ts'), - settings: resolve(import.meta.dirname, 'apps/files_external/src', 'settings.js'), + settings: resolve(import.meta.dirname, 'apps/files_external/src', 'settings-main.ts'), }, files_reminders: { init: resolve(import.meta.dirname, 'apps/files_reminders/src', 'files-init.ts'),