nextcloud/apps/files_external/src/settings.js
Ferdinand Thiessen 7cc564bfd1 fix(l10n): use productName instead of hardcoded 'Nextcloud' where possible
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2025-09-03 16:24:50 +02:00

1588 lines
43 KiB
JavaScript

/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import axios, { isAxiosError } from '@nextcloud/axios'
import jQuery from 'jquery'
addPasswordConfirmationInterceptors(axios)
/**
* Returns the selection of applicable users in the given configuration row
*
* @param $row configuration row
* @return array array of user names
*/
function getSelection($row) {
let values = $row.find('.applicableUsers').select2('val')
if (!values || values.length === 0) {
values = []
}
return values
}
/**
*
* @param $row
*/
function getSelectedApplicable($row) {
const users = []
const groups = []
const multiselect = getSelection($row)
$.each(multiselect, function(index, value) {
// FIXME: don't rely on string parts to detect groups...
const pos = (value.indexOf) ? value.indexOf('(group)') : -1
if (pos !== -1) {
groups.push(value.substr(0, pos))
} else {
users.push(value)
}
})
// FIXME: this should be done in the multiselect change event instead
$row.find('.applicable')
.data('applicable-groups', groups)
.data('applicable-users', users)
return { users, groups }
}
/**
*
* @param $element
* @param highlight
*/
function highlightBorder($element, highlight) {
$element.toggleClass('warning-input', highlight)
return highlight
}
/**
*
* @param $input
*/
function isInputValid($input) {
const optional = $input.hasClass('optional')
switch ($input.attr('type')) {
case 'text':
case 'password':
if ($input.val() === '' && !optional) {
return false
}
break
}
return true
}
/**
*
* @param $input
*/
function highlightInput($input) {
switch ($input.attr('type')) {
case 'text':
case 'password':
return highlightBorder($input, !isInputValid($input))
}
}
/**
* Initialize select2 plugin on the given elements
*
* @param {Array<object>} array of jQuery elements
* @param $elements
* @param {number} userListLimit page size for result list
*/
function initApplicableUsersMultiselect($elements, userListLimit) {
const escapeHTML = function(text) {
return text.toString()
.split('&').join('&amp;')
.split('<').join('&lt;')
.split('>').join('&gt;')
.split('"').join('&quot;')
.split('\'').join('&#039;')
}
if (!$elements.length) {
return
}
return $elements.select2({
placeholder: t('files_external', 'Type to select account or group.'),
allowClear: true,
multiple: true,
toggleSelect: true,
dropdownCssClass: 'files-external-select2',
// minimumInputLength: 1,
ajax: {
url: OC.generateUrl('apps/files_external/applicable'),
dataType: 'json',
quietMillis: 100,
data(term, page) { // page is the one-based page number tracked by Select2
return {
pattern: term, // search term
limit: userListLimit, // page size
offset: userListLimit * (page - 1), // page number starts with 0
}
},
results(data) {
if (data.status === 'success') {
const results = []
let userCount = 0 // users is an object
// add groups
$.each(data.groups, function(gid, group) {
results.push({ name: gid + '(group)', displayname: group, type: 'group' })
})
// add users
$.each(data.users, function(id, user) {
userCount++
results.push({ name: id, displayname: user, type: 'user' })
})
const more = (userCount >= userListLimit) || (data.groups.length >= userListLimit)
return { results, more }
} else {
// FIXME add error handling
}
},
},
initSelection(element, callback) {
const users = {}
users.users = []
const toSplit = element.val().split(',')
for (let i = 0; i < toSplit.length; i++) {
users.users.push(toSplit[i])
}
$.ajax(OC.generateUrl('displaynames'), {
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(users),
dataType: 'json',
}).done(function(data) {
const results = []
if (data.status === 'success') {
$.each(data.users, function(user, displayname) {
if (displayname !== false) {
results.push({ name: user, displayname, type: 'user' })
}
})
callback(results)
} else {
// FIXME add error handling
}
})
},
id(element) {
return element.name
},
formatResult(element) {
const $result = $('<span><div class="avatardiv"></div><span>' + escapeHTML(element.displayname) + '</span></span>')
const $div = $result.find('.avatardiv')
.attr('data-type', element.type)
.attr('data-name', element.name)
.attr('data-displayname', element.displayname)
if (element.type === 'group') {
const url = OC.imagePath('core', 'actions/group')
$div.html('<img width="32" height="32" src="' + url + '">')
}
return $result.get(0).outerHTML
},
formatSelection(element) {
if (element.type === 'group') {
return '<span title="' + escapeHTML(element.name) + '" class="group">' + escapeHTML(element.displayname + ' ' + t('files_external', '(Group)')) + '</span>'
} else {
return '<span title="' + escapeHTML(element.name) + '" class="user">' + escapeHTML(element.displayname) + '</span>'
}
},
escapeMarkup(m) { return m }, // we escape the markup in formatResult and formatSelection
}).on('select2-loaded', function() {
$.each($('.avatardiv'), function(i, div) {
const $div = $(div)
if ($div.data('type') === 'user') {
$div.avatar($div.data('name'), 32)
}
})
}).on('change', function(event) {
highlightBorder($(event.target).closest('.applicableUsersContainer').find('.select2-choices'), !event.val.length)
})
}
/**
* @param id
* @class OCA.Files_External.Settings.StorageConfig
*
* @classdesc External storage config
*/
const StorageConfig = function(id) {
this.id = id
this.backendOptions = {}
}
// Keep this in sync with \OCA\Files_External\MountConfig::STATUS_*
StorageConfig.Status = {
IN_PROGRESS: -1,
SUCCESS: 0,
ERROR: 1,
INDETERMINATE: 2,
}
StorageConfig.Visibility = {
NONE: 0,
PERSONAL: 1,
ADMIN: 2,
DEFAULT: 3,
}
/**
* @memberof OCA.Files_External.Settings
*/
StorageConfig.prototype = {
_url: null,
/**
* Storage id
*
* @type int
*/
id: null,
/**
* Mount point
*
* @type string
*/
mountPoint: '',
/**
* Backend
*
* @type string
*/
backend: null,
/**
* Authentication mechanism
*
* @type string
*/
authMechanism: null,
/**
* Backend-specific configuration
*
* @type Object.<string,object>
*/
backendOptions: null,
/**
* Mount-specific options
*
* @type Object.<string,object>
*/
mountOptions: null,
/**
* Creates or saves the storage.
*
* @param {Function} [options.success] success callback, receives result as argument
* @param {Function} [options.error] error callback
* @param options
*/
save(options) {
let url = OC.generateUrl(this._url)
let method = 'POST'
if (_.isNumber(this.id)) {
method = 'PUT'
url = OC.generateUrl(this._url + '/{id}', { id: this.id })
}
this._save(method, url, options)
},
/**
* Private implementation of the save function (called after potential password confirmation)
* @param {string} method
* @param {string} url
* @param {{success: Function, error: Function}} options
*/
async _save(method, url, options) {
try {
const response = await axios.request({
confirmPassword: PwdConfirmationMode.Strict,
method,
url,
data: this.getData(),
})
const result = response.data
this.id = result.id
options.success(result)
} catch (error) {
options.error(error)
}
},
/**
* Returns the data from this object
*
* @return {Array} JSON array of the data
*/
getData() {
const data = {
mountPoint: this.mountPoint,
backend: this.backend,
authMechanism: this.authMechanism,
backendOptions: this.backendOptions,
testOnly: true,
}
if (this.id) {
data.id = this.id
}
if (this.mountOptions) {
data.mountOptions = this.mountOptions
}
return data
},
/**
* Recheck the storage
*
* @param {Function} [options.success] success callback, receives result as argument
* @param {Function} [options.error] error callback
* @param options
*/
recheck(options) {
if (!_.isNumber(this.id)) {
if (_.isFunction(options.error)) {
options.error()
}
return
}
$.ajax({
type: 'GET',
url: OC.generateUrl(this._url + '/{id}', { id: this.id }),
data: { testOnly: true },
success: options.success,
error: options.error,
})
},
/**
* Deletes the storage
*
* @param {Function} [options.success] success callback
* @param {Function} [options.error] error callback
* @param options
*/
async destroy(options) {
if (!_.isNumber(this.id)) {
// the storage hasn't even been created => success
if (_.isFunction(options.success)) {
options.success()
}
return
}
try {
await axios.request({
method: 'DELETE',
url: OC.generateUrl(this._url + '/{id}', { id: this.id }),
confirmPassword: PwdConfirmationMode.Strict,
})
options.success()
} catch (e) {
options.error(e)
}
},
/**
* Validate this model
*
* @return {boolean} false if errors exist, true otherwise
*/
validate() {
if (this.mountPoint === '') {
return false
}
if (!this.backend) {
return false
}
if (this.errors) {
return false
}
return true
},
}
/**
* @param id
* @class OCA.Files_External.Settings.GlobalStorageConfig
* @augments OCA.Files_External.Settings.StorageConfig
*
* @classdesc Global external storage config
*/
const GlobalStorageConfig = function(id) {
this.id = id
this.applicableUsers = []
this.applicableGroups = []
}
/**
* @memberOf OCA.Files_External.Settings
*/
GlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype,
/** @lends OCA.Files_External.Settings.GlobalStorageConfig.prototype */ {
_url: 'apps/files_external/globalstorages',
/**
* Applicable users
*
* @type Array.<string>
*/
applicableUsers: null,
/**
* Applicable groups
*
* @type Array.<string>
*/
applicableGroups: null,
/**
* Storage priority
*
* @type int
*/
priority: null,
/**
* Returns the data from this object
*
* @return {Array} JSON array of the data
*/
getData() {
const data = StorageConfig.prototype.getData.apply(this, arguments)
return _.extend(data, {
applicableUsers: this.applicableUsers,
applicableGroups: this.applicableGroups,
priority: this.priority,
})
},
})
/**
* @param id
* @class OCA.Files_External.Settings.UserStorageConfig
* @augments OCA.Files_External.Settings.StorageConfig
*
* @classdesc User external storage config
*/
const UserStorageConfig = function(id) {
this.id = id
}
UserStorageConfig.prototype = _.extend({}, StorageConfig.prototype,
/** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ {
_url: 'apps/files_external/userstorages',
})
/**
* @param id
* @class OCA.Files_External.Settings.UserGlobalStorageConfig
* @augments OCA.Files_External.Settings.StorageConfig
*
* @classdesc User external storage config
*/
const UserGlobalStorageConfig = function(id) {
this.id = id
}
UserGlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype,
/** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ {
_url: 'apps/files_external/userglobalstorages',
})
/**
* @class OCA.Files_External.Settings.MountOptionsDropdown
*
* @classdesc Dropdown for mount options
*
* @param {object} $container container DOM object
*/
const MountOptionsDropdown = function() {
}
/**
* @memberof OCA.Files_External.Settings
*/
MountOptionsDropdown.prototype = {
/**
* Dropdown element
*
* @member Object
*/
$el: null,
/**
* Show dropdown
*
* @param {object} $container container
* @param {object} mountOptions mount options
* @param {Array} visibleOptions enabled mount options
*/
show($container, mountOptions, visibleOptions) {
if (MountOptionsDropdown._last) {
MountOptionsDropdown._last.hide()
}
const $el = $(OCA.Files_External.Templates.mountOptionsDropDown({
mountOptionsEncodingLabel: t('files_external', 'Compatibility with Mac NFD encoding (slow)'),
mountOptionsEncryptLabel: t('files_external', 'Enable encryption'),
mountOptionsPreviewsLabel: t('files_external', 'Enable previews'),
mountOptionsSharingLabel: t('files_external', 'Enable sharing'),
mountOptionsFilesystemCheckLabel: t('files_external', 'Check for changes'),
mountOptionsFilesystemCheckOnce: t('files_external', 'Never'),
mountOptionsFilesystemCheckDA: t('files_external', 'Once every direct access'),
mountOptionsReadOnlyLabel: t('files_external', 'Read only'),
deleteLabel: t('files_external', 'Disconnect'),
}))
this.$el = $el
const storage = $container[0].parentNode.className
this.setOptions(mountOptions, visibleOptions, storage)
this.$el.appendTo($container)
MountOptionsDropdown._last = this
this.$el.trigger('show')
},
hide() {
if (this.$el) {
this.$el.trigger('hide')
this.$el.remove()
this.$el = null
MountOptionsDropdown._last = null
}
},
/**
* Returns the mount options from the dropdown controls
*
* @return {object} options mount options
*/
getOptions() {
const options = {}
this.$el.find('input, select').each(function() {
const $this = $(this)
const key = $this.attr('name')
let value = null
if ($this.attr('type') === 'checkbox') {
value = $this.prop('checked')
} else {
value = $this.val()
}
if ($this.attr('data-type') === 'int') {
value = parseInt(value, 10)
}
options[key] = value
})
return options
},
/**
* Sets the mount options to the dropdown controls
*
* @param {object} options mount options
* @param {Array} visibleOptions enabled mount options
* @param storage
*/
setOptions(options, visibleOptions, storage) {
if (storage === 'owncloud') {
const ind = visibleOptions.indexOf('encrypt')
if (ind > 0) {
visibleOptions.splice(ind, 1)
}
}
const $el = this.$el
_.each(options, function(value, key) {
const $optionEl = $el.find('input, select').filterAttr('name', key)
if ($optionEl.attr('type') === 'checkbox') {
if (_.isString(value)) {
value = (value === 'true')
}
$optionEl.prop('checked', !!value)
} else {
$optionEl.val(value)
}
})
$el.find('.optionRow').each(function(i, row) {
const $row = $(row)
const optionId = $row.find('input, select').attr('name')
if (visibleOptions.indexOf(optionId) === -1 && !$row.hasClass('persistent')) {
$row.hide()
} else {
$row.show()
}
})
},
}
/**
* @class OCA.Files_External.Settings.MountConfigListView
*
* @classdesc Mount configuration list view
*
* @param {object} $el DOM object containing the list
* @param {object} [options]
* @param {number} [options.userListLimit] page size in applicable users dropdown
*/
const MountConfigListView = function($el, options) {
this.initialize($el, options)
}
MountConfigListView.ParameterFlags = {
OPTIONAL: 1,
USER_PROVIDED: 2,
HIDDEN: 4,
}
MountConfigListView.ParameterTypes = {
TEXT: 0,
BOOLEAN: 1,
PASSWORD: 2,
}
/**
* @memberOf OCA.Files_External.Settings
*/
MountConfigListView.prototype = _.extend({
/**
* jQuery element containing the config list
*
* @type Object
*/
$el: null,
/**
* Storage config class
*
* @type Class
*/
_storageConfigClass: null,
/**
* Flag whether the list is about user storage configs (true)
* or global storage configs (false)
*
* @type bool
*/
_isPersonal: false,
/**
* Page size in applicable users dropdown
*
* @type int
*/
_userListLimit: 30,
/**
* List of supported backends
*
* @type Object.<string,Object>
*/
_allBackends: null,
/**
* List of all supported authentication mechanisms
*
* @type Object.<string,Object>
*/
_allAuthMechanisms: null,
_encryptionEnabled: false,
/**
* @param {object} $el DOM object containing the list
* @param {object} [options]
* @param {number} [options.userListLimit] page size in applicable users dropdown
*/
initialize($el, options) {
this.$el = $el
this._isPersonal = ($el.data('admin') !== true)
if (this._isPersonal) {
this._storageConfigClass = OCA.Files_External.Settings.UserStorageConfig
} else {
this._storageConfigClass = OCA.Files_External.Settings.GlobalStorageConfig
}
if (options && !_.isUndefined(options.userListLimit)) {
this._userListLimit = options.userListLimit
}
this._encryptionEnabled = options.encryptionEnabled
this._canCreateLocal = options.canCreateLocal
// read the backend config that was carefully crammed
// into the data-configurations attribute of the select
this._allBackends = this.$el.find('.selectBackend').data('configurations')
this._allAuthMechanisms = this.$el.find('#addMountPoint .authentication').data('mechanisms')
this._initEvents()
},
/**
* Custom JS event handlers
* Trigger callback for all existing configurations
* @param callback
*/
whenSelectBackend(callback) {
this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) {
const backend = $(tr).find('.backend').data('identifier')
callback($(tr), backend)
})
this.on('selectBackend', callback)
},
whenSelectAuthMechanism(callback) {
const self = this
this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) {
const authMechanism = $(tr).find('.selectAuthMechanism').val()
callback($(tr), authMechanism, self._allAuthMechanisms[authMechanism].scheme)
})
this.on('selectAuthMechanism', callback)
},
/**
* Initialize DOM event handlers
*/
_initEvents() {
const self = this
const onChangeHandler = _.bind(this._onChange, this)
// this.$el.on('input', 'td input', onChangeHandler);
this.$el.on('keyup', 'td input', onChangeHandler)
this.$el.on('paste', 'td input', onChangeHandler)
this.$el.on('change', 'td input:checkbox', onChangeHandler)
this.$el.on('change', '.applicable', onChangeHandler)
this.$el.on('click', '.status>span', function() {
self.recheckStorageConfig($(this).closest('tr'))
})
this.$el.on('click', 'td.mountOptionsToggle .icon-delete', function() {
self.deleteStorageConfig($(this).closest('tr'))
})
this.$el.on('click', 'td.save>.icon-checkmark', function() {
self.saveStorageConfig($(this).closest('tr'))
})
this.$el.on('click', 'td.mountOptionsToggle>.icon-more', function() {
$(this).attr('aria-expanded', 'true')
self._showMountOptionsDropdown($(this).closest('tr'))
})
this.$el.on('change', '.selectBackend', _.bind(this._onSelectBackend, this))
this.$el.on('change', '.selectAuthMechanism', _.bind(this._onSelectAuthMechanism, this))
this.$el.on('change', '.applicableToAllUsers', _.bind(this._onChangeApplicableToAllUsers, this))
},
_onChange(event) {
const $target = $(event.target)
if ($target.closest('.dropdown').length) {
// ignore dropdown events
return
}
highlightInput($target)
const $tr = $target.closest('tr')
this.updateStatus($tr, null)
},
_onSelectBackend(event) {
const $target = $(event.target)
let $tr = $target.closest('tr')
const storageConfig = new this._storageConfigClass()
storageConfig.mountPoint = $tr.find('.mountPoint input').val()
storageConfig.backend = $target.val()
$tr.find('.mountPoint input').val('')
$tr.find('.selectBackend').prop('selectedIndex', 0)
const onCompletion = jQuery.Deferred()
$tr = this.newStorage(storageConfig, onCompletion)
$tr.find('.applicableToAllUsers').prop('checked', false).trigger('change')
onCompletion.resolve()
$tr.find('td.configuration').children().not('[type=hidden]').first().focus()
this.saveStorageConfig($tr)
},
_onSelectAuthMechanism(event) {
const $target = $(event.target)
const $tr = $target.closest('tr')
const authMechanism = $target.val()
const onCompletion = jQuery.Deferred()
this.configureAuthMechanism($tr, authMechanism, onCompletion)
onCompletion.resolve()
this.saveStorageConfig($tr)
},
_onChangeApplicableToAllUsers(event) {
const $target = $(event.target)
const $tr = $target.closest('tr')
const checked = $target.is(':checked')
$tr.find('.applicableUsersContainer').toggleClass('hidden', checked)
if (!checked) {
$tr.find('.applicableUsers').select2('val', '', true)
}
this.saveStorageConfig($tr)
},
/**
* Configure the storage config with a new authentication mechanism
*
* @param {jQuery} $tr config row
* @param {string} authMechanism
* @param {jQuery.Deferred} onCompletion
*/
configureAuthMechanism($tr, authMechanism, onCompletion) {
const authMechanismConfiguration = this._allAuthMechanisms[authMechanism]
const $td = $tr.find('td.configuration')
$td.find('.auth-param').remove()
$.each(authMechanismConfiguration.configuration, _.partial(
this.writeParameterInput, $td, _, _, ['auth-param'],
).bind(this))
this.trigger('selectAuthMechanism',
$tr, authMechanism, authMechanismConfiguration.scheme, onCompletion,
)
},
/**
* Create a config row for a new storage
*
* @param {StorageConfig} storageConfig storage config to pull values from
* @param {jQuery.Deferred} onCompletion
* @param {boolean} deferAppend
* @return {jQuery} created row
*/
newStorage(storageConfig, onCompletion, deferAppend) {
let mountPoint = storageConfig.mountPoint
let backend = this._allBackends[storageConfig.backend]
if (!backend) {
backend = {
name: 'Unknown: ' + storageConfig.backend,
invalid: true,
}
}
// FIXME: Replace with a proper Handlebar template
const $template = this.$el.find('tr#addMountPoint')
const $tr = $template.clone()
if (!deferAppend) {
$tr.insertBefore($template)
}
$tr.data('storageConfig', storageConfig)
$tr.show()
$tr.find('td.mountOptionsToggle, td.save, td.remove').removeClass('hidden')
$tr.find('td').last().removeAttr('style')
$tr.removeAttr('id')
$tr.find('select#selectBackend')
if (!deferAppend) {
initApplicableUsersMultiselect($tr.find('.applicableUsers'), this._userListLimit)
}
if (storageConfig.id) {
$tr.data('id', storageConfig.id)
}
$tr.find('.backend').text(backend.name)
if (mountPoint === '') {
mountPoint = this._suggestMountPoint(backend.name)
}
$tr.find('.mountPoint input').val(mountPoint)
$tr.addClass(backend.identifier)
$tr.find('.backend').data('identifier', backend.identifier)
if (backend.invalid || (backend.identifier === 'local' && !this._canCreateLocal)) {
$tr.find('[name=mountPoint]').prop('disabled', true)
$tr.find('.applicable,.mountOptionsToggle').empty()
$tr.find('.save').empty()
if (backend.invalid) {
this.updateStatus($tr, false, t('files_external', 'Unknown backend: {backendName}', { backendName: backend.name }))
}
return $tr
}
const selectAuthMechanism = $('<select class="selectAuthMechanism"></select>')
const neededVisibility = (this._isPersonal) ? StorageConfig.Visibility.PERSONAL : StorageConfig.Visibility.ADMIN
$.each(this._allAuthMechanisms, function(authIdentifier, authMechanism) {
if (backend.authSchemes[authMechanism.scheme] && (authMechanism.visibility & neededVisibility)) {
selectAuthMechanism.append(
$('<option value="' + authMechanism.identifier + '" data-scheme="' + authMechanism.scheme + '">' + authMechanism.name + '</option>'),
)
}
})
if (storageConfig.authMechanism) {
selectAuthMechanism.val(storageConfig.authMechanism)
} else {
storageConfig.authMechanism = selectAuthMechanism.val()
}
$tr.find('td.authentication').append(selectAuthMechanism)
const $td = $tr.find('td.configuration')
$.each(backend.configuration, _.partial(this.writeParameterInput, $td).bind(this))
this.trigger('selectBackend', $tr, backend.identifier, onCompletion)
this.configureAuthMechanism($tr, storageConfig.authMechanism, onCompletion)
if (storageConfig.backendOptions) {
$td.find('input, select').each(function() {
const input = $(this)
const val = storageConfig.backendOptions[input.data('parameter')]
if (val !== undefined) {
if (input.is('input:checkbox')) {
input.prop('checked', val)
}
input.val(storageConfig.backendOptions[input.data('parameter')])
highlightInput(input)
}
})
}
let applicable = []
if (storageConfig.applicableUsers) {
applicable = applicable.concat(storageConfig.applicableUsers)
}
if (storageConfig.applicableGroups) {
applicable = applicable.concat(
_.map(storageConfig.applicableGroups, function(group) {
return group + '(group)'
}),
)
}
if (applicable.length) {
$tr.find('.applicableUsers').val(applicable).trigger('change')
$tr.find('.applicableUsersContainer').removeClass('hidden')
} else {
// applicable to all
$tr.find('.applicableUsersContainer').addClass('hidden')
}
$tr.find('.applicableToAllUsers').prop('checked', !applicable.length)
const priorityEl = $('<input type="hidden" class="priority" value="' + backend.priority + '" />')
$tr.append(priorityEl)
if (storageConfig.mountOptions) {
$tr.find('input.mountOptions').val(JSON.stringify(storageConfig.mountOptions))
} else {
// FIXME default backend mount options
$tr.find('input.mountOptions').val(JSON.stringify({
encrypt: true,
previews: true,
enable_sharing: false,
filesystem_check_changes: 1,
encoding_compatibility: false,
readonly: false,
}))
}
return $tr
},
/**
* Load storages into config rows
*/
loadStorages() {
const self = this
const onLoaded1 = $.Deferred()
const onLoaded2 = $.Deferred()
this.$el.find('.externalStorageLoading').removeClass('hidden')
$.when(onLoaded1, onLoaded2).always(() => {
self.$el.find('.externalStorageLoading').addClass('hidden')
})
if (this._isPersonal) {
// load userglobal storages
$.ajax({
type: 'GET',
url: OC.generateUrl('apps/files_external/userglobalstorages'),
data: { testOnly: true },
contentType: 'application/json',
success(result) {
result = Object.values(result)
const onCompletion = jQuery.Deferred()
let $rows = $()
result.forEach(function(storageParams) {
let storageConfig
const isUserGlobal = storageParams.type === 'system' && self._isPersonal
storageParams.mountPoint = storageParams.mountPoint.substr(1) // trim leading slash
if (isUserGlobal) {
storageConfig = new UserGlobalStorageConfig()
} else {
storageConfig = new self._storageConfigClass()
}
_.extend(storageConfig, storageParams)
const $tr = self.newStorage(storageConfig, onCompletion, true)
// userglobal storages must be at the top of the list
$tr.detach()
self.$el.prepend($tr)
const $authentication = $tr.find('.authentication')
$authentication.text($authentication.find('select option:selected').text())
// disable any other inputs
$tr.find('.mountOptionsToggle, .remove').empty()
$tr.find('input:not(.user_provided), select:not(.user_provided)').attr('disabled', 'disabled')
if (isUserGlobal) {
$tr.find('.configuration').find(':not(.user_provided)').remove()
} else {
// userglobal storages do not expose configuration data
$tr.find('.configuration').text(t('files_external', 'Admin defined'))
}
// don't recheck config automatically when there are a large number of storages
if (result.length < 20) {
self.recheckStorageConfig($tr)
} else {
self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status'))
}
$rows = $rows.add($tr)
})
initApplicableUsersMultiselect(self.$el.find('.applicableUsers'), this._userListLimit)
self.$el.find('tr#addMountPoint').before($rows)
const mainForm = $('#files_external')
if (result.length === 0 && mainForm.attr('data-can-create') === 'false') {
mainForm.hide()
$('a[href="#external-storage"]').parent().hide()
$('.emptycontent').show()
}
onCompletion.resolve()
onLoaded1.resolve()
},
})
} else {
onLoaded1.resolve()
}
const url = this._storageConfigClass.prototype._url
$.ajax({
type: 'GET',
url: OC.generateUrl(url),
contentType: 'application/json',
success(result) {
result = Object.values(result)
const onCompletion = jQuery.Deferred()
let $rows = $()
result.forEach(function(storageParams) {
storageParams.mountPoint = (storageParams.mountPoint === '/') ? '/' : storageParams.mountPoint.substr(1) // trim leading slash
const storageConfig = new self._storageConfigClass()
_.extend(storageConfig, storageParams)
const $tr = self.newStorage(storageConfig, onCompletion, true)
// don't recheck config automatically when there are a large number of storages
if (result.length < 20) {
self.recheckStorageConfig($tr)
} else {
self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status'))
}
$rows = $rows.add($tr)
})
initApplicableUsersMultiselect($rows.find('.applicableUsers'), this._userListLimit)
self.$el.find('tr#addMountPoint').before($rows)
onCompletion.resolve()
onLoaded2.resolve()
},
})
},
/**
* @param {jQuery} $td
* @param {string} parameter
* @param {string} placeholder
* @param {Array} classes
* @return {jQuery} newly created input
*/
writeParameterInput($td, parameter, placeholder, classes) {
const hasFlag = function(flag) {
return (placeholder.flags & flag) === flag
}
classes = $.isArray(classes) ? classes : []
classes.push('added')
if (hasFlag(MountConfigListView.ParameterFlags.OPTIONAL)) {
classes.push('optional')
}
if (hasFlag(MountConfigListView.ParameterFlags.USER_PROVIDED)) {
if (this._isPersonal) {
classes.push('user_provided')
} else {
return
}
}
let newElement
const trimmedPlaceholder = placeholder.value
if (hasFlag(MountConfigListView.ParameterFlags.HIDDEN)) {
newElement = $('<input type="hidden" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />')
} else if (placeholder.type === MountConfigListView.ParameterTypes.PASSWORD) {
newElement = $('<input type="password" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />')
} else if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) {
const checkboxId = _.uniqueId('checkbox_')
newElement = $('<div><label><input type="checkbox" id="' + checkboxId + '" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />' + trimmedPlaceholder + '</label></div>')
} else {
newElement = $('<input type="text" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />')
}
if (placeholder.defaultValue) {
if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) {
newElement.find('input').prop('checked', placeholder.defaultValue)
} else {
newElement.val(placeholder.defaultValue)
}
}
if (placeholder.tooltip) {
newElement.attr('title', placeholder.tooltip)
}
highlightInput(newElement)
$td.append(newElement)
return newElement
},
/**
* Gets the storage model from the given row
*
* @param $tr row element
* @return {OCA.Files_External.StorageConfig} storage model instance
*/
getStorageConfig($tr) {
let storageId = $tr.data('id')
if (!storageId) {
// new entry
storageId = null
}
let storage = $tr.data('storageConfig')
if (!storage) {
storage = new this._storageConfigClass(storageId)
}
storage.errors = null
storage.mountPoint = $tr.find('.mountPoint input').val()
storage.backend = $tr.find('.backend').data('identifier')
storage.authMechanism = $tr.find('.selectAuthMechanism').val()
const classOptions = {}
const configuration = $tr.find('.configuration input')
const missingOptions = []
$.each(configuration, function(index, input) {
const $input = $(input)
const parameter = $input.data('parameter')
if ($input.attr('type') === 'button') {
return
}
if (!isInputValid($input) && !$input.hasClass('optional')) {
missingOptions.push(parameter)
return
}
if ($(input).is(':checkbox')) {
if ($(input).is(':checked')) {
classOptions[parameter] = true
} else {
classOptions[parameter] = false
}
} else {
classOptions[parameter] = $(input).val()
}
})
storage.backendOptions = classOptions
if (missingOptions.length) {
storage.errors = {
backendOptions: missingOptions,
}
}
// gather selected users and groups
if (!this._isPersonal) {
const multiselect = getSelectedApplicable($tr)
const users = multiselect.users || []
const groups = multiselect.groups || []
const isApplicableToAllUsers = $tr.find('.applicableToAllUsers').is(':checked')
if (isApplicableToAllUsers) {
storage.applicableUsers = []
storage.applicableGroups = []
} else {
storage.applicableUsers = users
storage.applicableGroups = groups
if (!storage.applicableUsers.length && !storage.applicableGroups.length) {
if (!storage.errors) {
storage.errors = {}
}
storage.errors.requiredApplicable = true
}
}
storage.priority = parseInt($tr.find('input.priority').val() || '100', 10)
}
const mountOptions = $tr.find('input.mountOptions').val()
if (mountOptions) {
storage.mountOptions = JSON.parse(mountOptions)
}
return storage
},
/**
* Deletes the storage from the given tr
*
* @param $tr storage row
* @param Function callback callback to call after save
*/
deleteStorageConfig($tr) {
const self = this
const configId = $tr.data('id')
if (!_.isNumber(configId)) {
// deleting unsaved storage
$tr.remove()
return
}
const storage = new this._storageConfigClass(configId)
OC.dialogs.confirm(
t('files_external', 'Are you sure you want to disconnect this external storage?')
+ ' '
+ 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 mountOptions = dropDown.getOptions()
$('body').off('mouseup.mountOptionsDropdown')
$tr.find('input.mountOptions').val(JSON.stringify(mountOptions))
$tr.find('td.mountOptionsToggle>.icon-more').attr('aria-expanded', 'false')
self.saveStorageConfig($tr)
})
},
}, OC.Backbone.Events)
window.addEventListener('DOMContentLoaded', function() {
const enabled = $('#files_external').attr('data-encryption-enabled')
const canCreateLocal = $('#files_external').attr('data-can-create-local')
const encryptionEnabled = (enabled === 'true')
const mountConfigListView = new MountConfigListView($('#externalStorage'), {
encryptionEnabled,
canCreateLocal: (canCreateLocal === 'true'),
})
mountConfigListView.loadStorages()
// TODO: move this into its own View class
const $allowUserMounting = $('#allowUserMounting')
$allowUserMounting.bind('change', function() {
OC.msg.startSaving('#userMountingMsg')
if (this.checked) {
OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'yes')
$('input[name="allowUserMountingBackends\\[\\]"]').prop('checked', true)
$('#userMountingBackends').removeClass('hidden')
$('input[name="allowUserMountingBackends\\[\\]"]').eq(0).trigger('change')
} else {
OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'no')
$('#userMountingBackends').addClass('hidden')
}
OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } })
})
$('input[name="allowUserMountingBackends\\[\\]"]').bind('change', function() {
OC.msg.startSaving('#userMountingMsg')
let userMountingBackends = $('input[name="allowUserMountingBackends\\[\\]"]:checked').map(function() {
return $(this).val()
}).get()
const deprecatedBackends = $('input[name="allowUserMountingBackends\\[\\]"][data-deprecate-to]').map(function() {
if ($.inArray($(this).data('deprecate-to'), userMountingBackends) !== -1) {
return $(this).val()
}
return null
}).get()
userMountingBackends = userMountingBackends.concat(deprecatedBackends)
OCP.AppConfig.setValue('files_external', 'user_mounting_backends', userMountingBackends.join())
OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } })
// disable allowUserMounting
if (userMountingBackends.length === 0) {
$allowUserMounting.prop('checked', false)
$allowUserMounting.trigger('change')
}
})
$('#global_credentials').on('submit', async function(event) {
event.preventDefault()
const $form = $(this)
const $submit = $form.find('[type=submit]')
$submit.val(t('files_external', 'Saving …'))
const uid = $form.find('[name=uid]').val()
const user = $form.find('[name=username]').val()
const password = $form.find('[name=password]').val()
try {
await axios.request({
method: 'POST',
data: {
uid,
user,
password,
},
url: generateUrl('apps/files_external/globalcredentials'),
confirmPassword: PwdConfirmationMode.Strict,
})
$submit.val(t('files_external', 'Saved'))
setTimeout(function() {
$submit.val(t('files_external', 'Save'))
}, 2500)
} catch (error) {
$submit.val(t('files_external', 'Save'))
if (isAxiosError(error)) {
const message = error.response?.data?.message || t('files_external', 'Failed to save global credentials')
showError(t('files_external', 'Failed to save global credentials: {message}', { message }))
}
}
return false
})
// global instance
OCA.Files_External.Settings.mountConfig = mountConfigListView
/**
* Legacy
*
* @namespace
* @deprecated use OCA.Files_External.Settings.mountConfig instead
*/
OC.MountConfig = {
saveStorage: _.bind(mountConfigListView.saveStorageConfig, mountConfigListView),
}
})
// export
OCA.Files_External = OCA.Files_External || {}
/**
* @namespace
*/
OCA.Files_External.Settings = OCA.Files_External.Settings || {}
OCA.Files_External.Settings.GlobalStorageConfig = GlobalStorageConfig
OCA.Files_External.Settings.UserStorageConfig = UserStorageConfig
OCA.Files_External.Settings.MountConfigListView = MountConfigListView