From e1133ec9261c128c0f874cb3b590ce150a87f9a9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Mar 2024 00:48:10 +0100 Subject: [PATCH] feat(files_external): Migrate settings to Vue Template parameters are migrated to initial state, common state between admin and user settings is shared in the CommonSettingsTrait. The template is cleaned and replaced with only a stub for the Vue mount. Code only used for the frontend of the settings is moved from the MountConfig to the CommonSettingsTrait (the missing dependency messages). On the frontend a wrapper view is created that currently holds the global credentials settings and the external storages settings. - The global credentials sections is now a stand-alone sections - fully implemented. - The external storages section holds the table + user config + warnings on missing dependencies The legacy UI is temporarly renamed but will be removed in a following commit. Signed-off-by: Ferdinand Thiessen --- apps/files_external/css/settings.css | 4 - apps/files_external/css/settings.css.map | 1 - .../css/settings.css.map.license | 2 - apps/files_external/css/settings.scss | 194 -- apps/files_external/img/app-dark.svg | 2 +- apps/files_external/js/legacy-settings.js | 1514 ++++++++++++++++ apps/files_external/js/templates.js | 47 - .../js/templates/credentialsDialog.handlebars | 8 - apps/files_external/lib/MountConfig.php | 48 - apps/files_external/lib/Settings/Admin.php | 37 +- .../lib/Settings/CommonSettingsTrait.php | 98 +- apps/files_external/lib/Settings/Personal.php | 30 +- .../src/components/ExternalStorageTable.vue | 40 + .../src/components/UserMountSettings.vue | 120 ++ apps/files_external/src/settings-main.ts | 10 + apps/files_external/src/settings.js | 1600 ----------------- apps/files_external/src/store/storages.ts | 57 + apps/files_external/src/types.d.ts | 21 + apps/files_external/src/utils/logger.ts | 3 + .../src/views/ExternalStoragesSection.vue | 88 + .../src/views/FilesExternalSettings.vue | 13 + .../src/views/GlobalCredentialsSection.vue | 103 ++ .../templates/legacy-settings.php | 240 +++ apps/files_external/templates/settings.php | 185 +- build/frontend/vite.config.ts | 2 +- 25 files changed, 2324 insertions(+), 2143 deletions(-) delete mode 100644 apps/files_external/css/settings.css delete mode 100644 apps/files_external/css/settings.css.map delete mode 100644 apps/files_external/css/settings.css.map.license delete mode 100644 apps/files_external/css/settings.scss create mode 100644 apps/files_external/js/legacy-settings.js delete mode 100644 apps/files_external/js/templates.js delete mode 100644 apps/files_external/js/templates/credentialsDialog.handlebars create mode 100644 apps/files_external/src/components/ExternalStorageTable.vue create mode 100644 apps/files_external/src/components/UserMountSettings.vue create mode 100644 apps/files_external/src/settings-main.ts delete mode 100644 apps/files_external/src/settings.js create mode 100644 apps/files_external/src/store/storages.ts create mode 100644 apps/files_external/src/types.d.ts create mode 100644 apps/files_external/src/utils/logger.ts create mode 100644 apps/files_external/src/views/ExternalStoragesSection.vue create mode 100644 apps/files_external/src/views/FilesExternalSettings.vue create mode 100644 apps/files_external/src/views/GlobalCredentialsSection.vue create mode 100644 apps/files_external/templates/legacy-settings.php 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'),