diff options
Diffstat (limited to 'core/src')
166 files changed, 9514 insertions, 6509 deletions
diff --git a/core/src/OC/admin.js b/core/src/OC/admin.js index 008016645d9..d29e4cf676b 100644 --- a/core/src/OC/admin.js +++ b/core/src/OC/admin.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const isAdmin = !!window._oc_isadmin @@ -24,7 +8,7 @@ const isAdmin = !!window._oc_isadmin /** * Returns whether the current user is an administrator * - * @returns {bool} true if the user is an admin, false otherwise + * @return {boolean} true if the user is an admin, false otherwise * @since 9.0.0 */ export const isUserAdmin = () => isAdmin diff --git a/core/src/OC/appconfig.js b/core/src/OC/appconfig.js index 37fc3ca420d..350ffc3f21c 100644 --- a/core/src/OC/appconfig.js +++ b/core/src/OC/appconfig.js @@ -1,25 +1,11 @@ -/* eslint-disable */ /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ - import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig' +/* eslint-disable */ + import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig.js' export const appConfig = window.oc_appconfig || {} diff --git a/core/src/OC/apps.js b/core/src/OC/apps.js index c7b2c4f5e17..dec2b94bfbb 100644 --- a/core/src/OC/apps.js +++ b/core/src/OC/apps.js @@ -1,11 +1,7 @@ /** - * ownCloud - core - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. - * - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @copyright Bernhard Posselt 2014 + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -21,7 +17,7 @@ const Apps = { /** * Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings * - * @param {Object} [$el] sidebar element to show, defaults to $('#app-sidebar') + * @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar') */ Apps.showAppSidebar = function($el) { const $appSidebar = $el || $('#app-sidebar') @@ -33,7 +29,7 @@ Apps.showAppSidebar = function($el) { * Shows the #app-sidebar and removes .with-app-sidebar from subsequent * siblings * - * @param {Object} [$el] sidebar element to hide, defaults to $('#app-sidebar') + * @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar') */ Apps.hideAppSidebar = function($el) { const $appSidebar = $el || $('#app-sidebar') @@ -68,20 +64,28 @@ export const registerAppsSlideToggle = () => { const areaSelector = $(button).data('apps-slide-toggle') const area = $(areaSelector) + /** + * + */ function hideArea() { area.slideUp(OC.menuSpeed * 4, function() { area.trigger(new $.Event('hide')) }) area.removeClass('opened') $(button).removeClass('opened') + $(button).attr('aria-expanded', 'false') } + /** + * + */ function showArea() { area.slideDown(OC.menuSpeed * 4, function() { area.trigger(new $.Event('show')) }) area.addClass('opened') $(button).addClass('opened') + $(button).attr('aria-expanded', 'true') const input = $(areaSelector + ' [autofocus]') if (input.length === 1) { input.focus() diff --git a/core/src/OC/appsettings.js b/core/src/OC/appsettings.js deleted file mode 100644 index 7665d93fb7c..00000000000 --- a/core/src/OC/appsettings.js +++ /dev/null @@ -1,93 +0,0 @@ -/* eslint-disable */ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -import $ from 'jquery' -import { filePath } from './routing' - -/** - * Opens a popup with the setting for an app. - * @param {string} appid The ID of the app e.g. 'calendar', 'contacts' or 'files'. - * @param {boolean|string} loadJS If true 'js/settings.js' is loaded. If it's a string - * it will attempt to load a script by that name in the 'js' directory. - * @param {boolean} [cache] If true the javascript file won't be forced refreshed. Defaults to true. - * @param {string} [scriptName] The name of the PHP file to load. Defaults to 'settings.php' in - * the root of the app directory hierarchy. - * - * @deprecated 17.0.0 this method is unused and will be removed with Nextcloud 18 - */ -export const appSettings = args => { - console.warn('OC.appSettings is deprecated and will be removed with Nextcloud 18') - - if (typeof args === 'undefined' || typeof args.appid === 'undefined') { - throw { - name: 'MissingParameter', - message: 'The parameter appid is missing' - } - } - var props = { scriptName: 'settings.php', cache: true } - $.extend(props, args) - var settings = $('#appsettings') - if (settings.length === 0) { - throw { - name: 'MissingDOMElement', - message: 'There has be be an element with id "appsettings" for the popup to show.' - } - } - var popup = $('#appsettings_popup') - if (popup.length === 0) { - $('body').prepend('<div class="popup hidden" id="appsettings_popup"></div>') - popup = $('#appsettings_popup') - popup.addClass(settings.hasClass('topright') ? 'topright' : 'bottomleft') - } - if (popup.is(':visible')) { - popup.hide().remove() - } else { - const arrowclass = settings.hasClass('topright') ? 'up' : 'left' - $.get(filePath(props.appid, '', props.scriptName), function(data) { - popup.html(data).ready(function() { - popup.prepend('<span class="arrow ' + arrowclass + '"></span><h2>' + t('core', 'Settings') + '</h2><a class="close"></a>').show() - popup.find('.close').bind('click', function() { - popup.remove() - }) - if (typeof props.loadJS !== 'undefined') { - var scriptname - if (props.loadJS === true) { - scriptname = 'settings.js' - } else if (typeof props.loadJS === 'string') { - scriptname = props.loadJS - } else { - throw { - name: 'InvalidParameter', - message: 'The "loadJS" parameter must be either boolean or a string.' - } - } - if (props.cache) { - $.ajaxSetup({ cache: true }) - } - $.getScript(filePath(props.appid, 'js', scriptname)) - .fail(function(jqxhr, settings, e) { - throw e - }) - } - }).show() - }, 'html') - } -} diff --git a/core/src/OC/appswebroots.js b/core/src/OC/appswebroots.js index f4a82384fe6..debbd2084bf 100644 --- a/core/src/OC/appswebroots.js +++ b/core/src/OC/appswebroots.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const appswebroots = (window._oc_appswebroots !== undefined) ? window._oc_appswebroots : false diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js index 4175b386b21..318c50e8ee5 100644 --- a/core/src/OC/backbone-webdav.js +++ b/core/src/OC/backbone-webdav.js @@ -1,59 +1,9 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - /** - * Webdav transport for Backbone. - * - * This makes it possible to use Webdav endpoints when - * working with Backbone models and collections. - * - * Requires the davclient.js library. - * - * Usage example: - * - * var PersonModel = OC.Backbone.Model.extend({ - * // make it use the DAV transport - * sync: OC.Backbone.davSync, - * - * // DAV properties mapping - * davProperties: { - * 'id': '{http://example.com/ns}id', - * 'firstName': '{http://example.com/ns}first-name', - * 'lastName': '{http://example.com/ns}last-name', - * 'age': '{http://example.com/ns}age' - * }, - * - * // additional parsing, if needed - * parse: function(props) { - * // additional parsing (DAV property values are always strings) - * props.age = parseInt(props.age, 10); - * return props; - * } - * }); - * - * var PersonCollection = OC.Backbone.Collection.extend({ - * // make it use the DAV transport - * sync: OC.Backbone.davSync, - * - * // use person model - * // note that davProperties will be inherited - * model: PersonModel, - * - * // DAV collection URL - * url: function() { - * return OC.linkToRemote('dav') + '/person/'; - * }, - * }); + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import _ from 'underscore' import { dav } from 'davclient.js' diff --git a/core/src/OC/backbone.js b/core/src/OC/backbone.js index 86e98ec1b41..08520e278f6 100644 --- a/core/src/OC/backbone.js +++ b/core/src/OC/backbone.js @@ -1,26 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import VendorBackbone from 'backbone' -import { davCall, davSync } from './backbone-webdav' +import { davCall, davSync } from './backbone-webdav.js' const Backbone = VendorBackbone.noConflict() diff --git a/core/src/OC/capabilities.js b/core/src/OC/capabilities.js index 6e51abce60e..10623229625 100644 --- a/core/src/OC/capabilities.js +++ b/core/src/OC/capabilities.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities' @@ -24,11 +8,11 @@ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities' /** * Returns the capabilities * - * @returns {Array} capabilities + * @return {Array} capabilities * - * @since 14.0 + * @since 14.0.0 */ export const getCapabilities = () => { - console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities') + OC.debug && console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities') return realGetCapabilities() } diff --git a/core/src/OC/config.js b/core/src/OC/config.js index d1a3211cf62..c47df61f6e6 100644 --- a/core/src/OC/config.js +++ b/core/src/OC/config.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const config = window._oc_config || {} diff --git a/core/src/OC/constants.js b/core/src/OC/constants.js index 972848997ae..5298107e94d 100644 --- a/core/src/OC/constants.js +++ b/core/src/OC/constants.js @@ -1,22 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software = you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation either version 3 of the - * License or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const coreApps = ['', 'admin', 'log', 'core/search', 'core', '3rdparty'] diff --git a/core/src/OC/contactsmenu.js b/core/src/OC/contactsmenu.js deleted file mode 100644 index e986c46ec20..00000000000 --- a/core/src/OC/contactsmenu.js +++ /dev/null @@ -1,492 +0,0 @@ -/* eslint-disable */ - -/** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import $ from 'jquery' -import { Collection, Model, View } from 'backbone' - -import OC from './index' - -/** - * @class Contact - */ -const Contact = Model.extend({ - defaults: { - fullName: '', - lastMessage: '', - actions: [], - hasOneAction: false, - hasTwoActions: false, - hasManyActions: false - }, - - /** - * @returns {undefined} - */ - initialize: function() { - // Add needed property for easier template rendering - if (this.get('actions').length === 0) { - this.set('hasOneAction', true) - } else if (this.get('actions').length === 1) { - this.set('hasTwoActions', true) - this.set('secondAction', this.get('actions')[0]) - } else { - this.set('hasManyActions', true) - } - } -}) - -/** - * @class ContactCollection - * @private - */ -const ContactCollection = Collection.extend({ - model: Contact -}) - -/** - * @class ContactsListView - * @private - */ -const ContactsListView = View.extend({ - - /** @type {ContactCollection} */ - _collection: undefined, - - /** @type {array} */ - _subViews: [], - - /** - * @param {object} options - * @returns {undefined} - */ - initialize: function(options) { - this._collection = options.collection - }, - - /** - * @returns {self} - */ - render: function() { - var self = this - self.$el.html('') - self._subViews = [] - - self._collection.forEach(function(contact) { - var item = new ContactsListItemView({ - model: contact - }) - item.render() - self.$el.append(item.$el) - item.on('toggle:actionmenu', self._onChildActionMenuToggle, self) - self._subViews.push(item) - }) - - return self - }, - - /** - * Event callback to propagate opening (another) entry's action menu - * - * @param {type} $src - * @returns {undefined} - */ - _onChildActionMenuToggle: function($src) { - this._subViews.forEach(function(view) { - view.trigger('parent:toggle:actionmenu', $src) - }) - } -}) - -/** - * @class ContactsListItemView - * @private - */ -const ContactsListItemView = View.extend({ - - /** @type {string} */ - className: 'contact', - - /** @type {undefined|function} */ - _template: undefined, - - /** @type {Contact} */ - _model: undefined, - - /** @type {boolean} */ - _actionMenuShown: false, - - events: { - 'click .icon-more': '_onToggleActionsMenu' - }, - - contactTemplate: require('./contactsmenu/contact.handlebars'), - - /** - * @param {object} data - * @returns {undefined} - */ - template: function(data) { - return this.contactTemplate(data) - }, - - /** - * @param {object} options - * @returns {undefined} - */ - initialize: function(options) { - this._model = options.model - this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this) - }, - - /** - * @returns {self} - */ - render: function() { - this.$el.html(this.template({ - contact: this._model.toJSON() - })) - this.delegateEvents() - - // Show placeholder if no avatar is available (avatar is rendered as img, not div) - this.$('div.avatar').imageplaceholder(this._model.get('fullName')) - - // Show tooltip for top action - this.$('.top-action').tooltip({ placement: 'left' }) - // Show tooltip for second action - this.$('.second-action').tooltip({ placement: 'left' }) - - return this - }, - - /** - * Toggle the visibility of the action popover menu - * - * @private - * @returns {undefined} - */ - _onToggleActionsMenu: function() { - this._actionMenuShown = !this._actionMenuShown - if (this._actionMenuShown) { - this.$('.menu').show() - } else { - this.$('.menu').hide() - } - this.trigger('toggle:actionmenu', this.$el) - }, - - /** - * @private - * @argument {jQuery} $src - * @returns {undefined} - */ - _onOtherActionMenuOpened: function($src) { - if (this.$el.is($src)) { - // Ignore - return - } - this._actionMenuShown = false - this.$('.menu').hide() - } -}) - -/** - * @class ContactsMenuView - * @private - */ -const ContactsMenuView = View.extend({ - - /** @type {undefined|function} */ - _loadingTemplate: undefined, - - /** @type {undefined|function} */ - _errorTemplate: undefined, - - /** @type {undefined|function} */ - _contentTemplate: undefined, - - /** @type {undefined|function} */ - _contactsTemplate: undefined, - - /** @type {undefined|ContactCollection} */ - _contacts: undefined, - - /** @type {string} */ - _searchTerm: '', - - events: { - 'input #contactsmenu-search': '_onSearch' - }, - - templates: { - loading: require('./contactsmenu/loading.handlebars'), - error: require('./contactsmenu/error.handlebars'), - menu: require('./contactsmenu/menu.handlebars'), - list: require('./contactsmenu/list.handlebars') - }, - - /** - * @returns {undefined} - */ - _onSearch: _.debounce(function(e) { - var searchTerm = this.$('#contactsmenu-search').val() - // IE11 triggers an 'input' event after the view has been rendered - // resulting in an endless loading loop. To prevent this, we remember - // the last search term to savely ignore some events - // See https://github.com/nextcloud/server/issues/5281 - if (searchTerm !== this._searchTerm) { - this.trigger('search', this.$('#contactsmenu-search').val()) - this._searchTerm = searchTerm - } - }, 700), - - /** - * @param {object} data - * @returns {string} - */ - loadingTemplate: function(data) { - return this.templates.loading(data) - }, - - /** - * @param {object} data - * @returns {string} - */ - errorTemplate: function(data) { - return this.templates.error( - _.extend({ - couldNotLoadText: t('core', 'Could not load your contacts') - }, data) - ) - }, - - /** - * @param {object} data - * @returns {string} - */ - contentTemplate: function(data) { - return this.templates.menu( - _.extend({ - searchContactsText: t('core', 'Search contacts …') - }, data) - ) - }, - - /** - * @param {object} data - * @returns {string} - */ - contactsTemplate: function(data) { - return this.templates.list( - _.extend({ - noContactsFoundText: t('core', 'No contacts found'), - showAllContactsText: t('core', 'Show all contacts …'), - contactsAppMgmtText: t('core', 'Install the Contacts app') - }, data) - ) - }, - - /** - * @param {object} options - * @returns {undefined} - */ - initialize: function(options) { - this.options = options - }, - - /** - * @param {string} text - * @returns {undefined} - */ - showLoading: function(text) { - this.render() - this._contacts = undefined - this.$('.content').html(this.loadingTemplate({ - loadingText: text - })) - }, - - /** - * @returns {undefined} - */ - showError: function() { - this.render() - this._contacts = undefined - this.$('.content').html(this.errorTemplate()) - }, - - /** - * @param {object} viewData - * @param {string} searchTerm - * @returns {undefined} - */ - showContacts: function(viewData, searchTerm) { - this._contacts = viewData.contacts - this.render({ - contacts: viewData.contacts - }) - - var list = new ContactsListView({ - collection: viewData.contacts - }) - list.render() - this.$('.content').html(this.contactsTemplate({ - contacts: viewData.contacts, - searchTerm: searchTerm, - contactsAppEnabled: viewData.contactsAppEnabled, - contactsAppURL: OC.generateUrl('/apps/contacts'), - canInstallApp: OC.isUserAdmin(), - contactsAppMgmtURL: OC.generateUrl('/settings/apps/social/contacts') - })) - this.$('#contactsmenu-contacts').html(list.$el) - }, - - /** - * @param {object} data - * @returns {self} - */ - render: function(data) { - var searchVal = this.$('#contactsmenu-search').val() - this.$el.html(this.contentTemplate(data)) - - // Focus search - this.$('#contactsmenu-search').val(searchVal) - this.$('#contactsmenu-search').focus() - return this - } - -}) - -/** - * @param {Object} options - * @param {jQuery} options.el - * @param {jQuery} options.trigger - * @class ContactsMenu - * @memberOf OC - */ -const ContactsMenu = function(options) { - this.initialize(options) -} - -ContactsMenu.prototype = { - /** @type {jQuery} */ - $el: undefined, - - /** @type {jQuery} */ - _$trigger: undefined, - - /** @type {ContactsMenuView} */ - _view: undefined, - - /** @type {Promise} */ - _contactsPromise: undefined, - - /** - * @param {Object} options - * @param {jQuery} options.el - the element to render the menu in - * @param {jQuery} options.trigger - the element to click on to open the menu - * @returns {undefined} - */ - initialize: function(options) { - this.$el = options.el - this._$trigger = options.trigger - - this._view = new ContactsMenuView({ - el: this.$el - }) - this._view.on('search', function(searchTerm) { - this._loadContacts(searchTerm) - }, this) - - OC.registerMenu(this._$trigger, this.$el, function() { - this._toggleVisibility(true) - }.bind(this), true) - this.$el.on('beforeHide', function() { - this._toggleVisibility(false) - }.bind(this)) - }, - - /** - * @private - * @param {boolean} show - * @returns {Promise} - */ - _toggleVisibility: function(show) { - if (show) { - return this._loadContacts() - } else { - this.$el.html('') - return Promise.resolve() - } - }, - - /** - * @private - * @param {string|undefined} searchTerm - * @returns {Promise} - */ - _getContacts: function(searchTerm) { - var url = OC.generateUrl('/contactsmenu/contacts') - return Promise.resolve($.ajax(url, { - method: 'POST', - data: { - filter: searchTerm - } - })) - }, - - /** - * @param {string|undefined} searchTerm - * @returns {undefined} - */ - _loadContacts: function(searchTerm) { - var self = this - - if (!self._contactsPromise) { - self._contactsPromise = self._getContacts(searchTerm) - } - - if (_.isUndefined(searchTerm) || searchTerm === '') { - self._view.showLoading(t('core', 'Loading your contacts …')) - } else { - self._view.showLoading(t('core', 'Looking for {term} …', { - term: searchTerm - })) - } - return self._contactsPromise.then(function(data) { - // Convert contact entries to Backbone collection - data.contacts = new ContactCollection(data.contacts) - - self._view.showContacts(data, searchTerm) - }, function(e) { - self._view.showError() - console.error('There was an error loading your contacts', e) - }).then(function() { - // Delete promise, so that contacts are fetched again when the - // menu is opened the next time. - delete self._contactsPromise - }).catch(console.error.bind(this)) - } -} - -export default ContactsMenu diff --git a/core/src/OC/contactsmenu/contact.handlebars b/core/src/OC/contactsmenu/contact.handlebars deleted file mode 100644 index a30b11462c4..00000000000 --- a/core/src/OC/contactsmenu/contact.handlebars +++ /dev/null @@ -1,34 +0,0 @@ -{{#if contact.avatar}} -<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt=""> -{{else}} -<div class="avatar"></div> -{{/if}} -<div class="body"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> -</div> -{{#if contact.topAction}} -<a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}"> - <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}"> -</a> -{{/if}} -{{#if contact.hasTwoActions}} -<a class="second-action" href="{{contact.secondAction.hyperlink}}" title="{{contact.secondAction.title}}"> - <img src="{{contact.secondAction.icon}}" alt="{{contact.secondAction.title}}"> -</a> -{{/if}} -{{#if contact.hasManyActions}} - <span class="other-actions icon-more"></span> - <div class="menu popovermenu"> - <ul> - {{#each contact.actions}} - <li> - <a href="{{hyperlink}}"> - <img src="{{icon}}" alt=""> - <span>{{title}}</span> - </a> - </li> - {{/each}} - </ul> - </div> -{{/if}} diff --git a/core/src/OC/contactsmenu/error.handlebars b/core/src/OC/contactsmenu/error.handlebars deleted file mode 100644 index 5115595b4e1..00000000000 --- a/core/src/OC/contactsmenu/error.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<div class="emptycontent"> - <div class="icon-search"></div> - <h2>{{couldNotLoadText}}</h2> -</div> diff --git a/core/src/OC/contactsmenu/list.handlebars b/core/src/OC/contactsmenu/list.handlebars deleted file mode 100644 index 0bcff7d1a85..00000000000 --- a/core/src/OC/contactsmenu/list.handlebars +++ /dev/null @@ -1,12 +0,0 @@ -{{#unless contacts.length}} -<div class="emptycontent"> - <div class="icon-search"></div> - <h2>{{noContactsFoundText}}</h2> -</div> -{{/unless}} -<div id="contactsmenu-contacts"></div> -{{#if contactsAppEnabled}} -<div class="footer"><a href="{{contactsAppURL}}">{{showAllContactsText}}</a></div> -{{else if canInstallApp}} -<div class="footer"><a href="{{contactsAppMgmtURL}}">{{contactsAppMgmtText}}</a></div> -{{/if}} diff --git a/core/src/OC/contactsmenu/loading.handlebars b/core/src/OC/contactsmenu/loading.handlebars deleted file mode 100644 index 7fb22a6ed8e..00000000000 --- a/core/src/OC/contactsmenu/loading.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<div class="emptycontent"> - <div class="icon-loading"></div> - <h2>{{loadingText}}</h2> -</div> diff --git a/core/src/OC/contactsmenu/menu.handlebars b/core/src/OC/contactsmenu/menu.handlebars deleted file mode 100644 index 7d7697e780c..00000000000 --- a/core/src/OC/contactsmenu/menu.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<label class="hidden-visually" for="contactsmenu-search">{{searchContactsText}}</label> -<input id="contactsmenu-search" type="search" placeholder="{{searchContactsText}}" value="{{searchTerm}}"> -<div class="content"> -</div> diff --git a/core/src/OC/currentuser.js b/core/src/OC/currentuser.js index 061abba89d6..a022698eab0 100644 --- a/core/src/OC/currentuser.js +++ b/core/src/OC/currentuser.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const rawUid = document diff --git a/core/src/OC/debug.js b/core/src/OC/debug.js index 15a66c44aed..52a9ef28145 100644 --- a/core/src/OC/debug.js +++ b/core/src/OC/debug.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const base = window._oc_debug diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js index 38b7b42751b..5c6934e67a2 100644 --- a/core/src/OC/dialogs.js +++ b/core/src/OC/dialogs.js @@ -1,56 +1,51 @@ -/* eslint-disable */ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import _ from 'underscore' import $ from 'jquery' -import OC from './index' -import OCA from '../OCA/index' +import IconMove from '@mdi/svg/svg/folder-move.svg?raw' +import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw' + +import OC from './index.js' +import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { basename } from 'path' +import { defineAsyncComponent } from 'vue' /** * this class to ease the usage of jquery dialogs */ const Dialogs = { // dialog button types + /** @deprecated use `@nextcloud/dialogs` */ YES_NO_BUTTONS: 70, + /** @deprecated use `@nextcloud/dialogs` */ OK_BUTTONS: 71, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ FILEPICKER_TYPE_CHOOSE: 1, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ FILEPICKER_TYPE_MOVE: 2, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ FILEPICKER_TYPE_COPY: 3, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ FILEPICKER_TYPE_COPY_MOVE: 4, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ FILEPICKER_TYPE_CUSTOM: 5, - // used to name each dialog - dialogsCounter: 0, - /** * displays alert dialog * @param {string} text content of dialog * @param {string} title dialog title * @param {function} callback which will be triggered when user presses OK * @param {boolean} [modal] make the dialog modal + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog */ alert: function(text, title, callback, modal) { this.message( @@ -62,12 +57,15 @@ const Dialogs = { modal ) }, + /** * displays info dialog * @param {string} text content of dialog * @param {string} title dialog title * @param {function} callback which will be triggered when user presses OK * @param {boolean} [modal] make the dialog modal + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog */ info: function(text, title, callback, modal) { this.message(text, title, 'info', Dialogs.OK_BUTTON, callback, modal) @@ -80,6 +78,8 @@ const Dialogs = { * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) * @param {boolean} [modal] make the dialog modal * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog */ confirm: function(text, title, callback, modal) { return this.message( @@ -95,20 +95,38 @@ const Dialogs = { * displays confirmation dialog * @param {string} text content of dialog * @param {string} title dialog title - * @param {{type: Int, confirm: String, cancel: String, confirmClasses: String}} buttons text content of buttons + * @param {(number|{type: number, confirm: string, cancel: string, confirmClasses: string})} buttons text content of buttons * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) * @param {boolean} [modal] make the dialog modal * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog */ - confirmDestructive: function(text, title, buttons, callback, modal) { - return this.message( - text, - title, - 'none', - buttons, - callback, - modal === undefined ? true : modal - ) + confirmDestructive: function(text, title, buttons = Dialogs.OK_BUTTONS, callback = () => {}, modal) { + return (new DialogBuilder()) + .setName(title) + .setText(text) + .setButtons( + buttons === Dialogs.OK_BUTTONS + ? [ + { + label: t('core', 'Yes'), + type: 'error', + callback: () => { + callback.clicked = true + callback(true) + }, + } + ] + : Dialogs._getLegacyButtons(buttons, callback) + ) + .build() + .show() + .then(() => { + if (!callback.clicked) { + callback(false) + } + }) }, /** * displays confirmation dialog @@ -117,17 +135,35 @@ const Dialogs = { * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) * @param {boolean} [modal] make the dialog modal * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog */ confirmHtml: function(text, title, callback, modal) { - return this.message( - text, - title, - 'notice', - Dialogs.YES_NO_BUTTONS, - callback, - modal, - true - ) + return (new DialogBuilder()) + .setName(title) + .setText('') + .setButtons([ + { + label: t('core', 'No'), + callback: () => {}, + }, + { + label: t('core', 'Yes'), + type: 'primary', + callback: () => { + callback.clicked = true + callback(true) + }, + }, + ]) + .build() + .setHTML(text) + .show() + .then(() => { + if (!callback.clicked) { + callback(false) + } + }) }, /** * displays prompt dialog @@ -138,73 +174,32 @@ const Dialogs = { * @param {string} name name of the input field * @param {boolean} password whether the input should be a password input * @returns {Promise} + * + * @deprecated Use NcDialog from `@nextcloud/vue` instead */ prompt: function(text, title, callback, modal, name, password) { - return $.when(this._getMessageTemplate()).then(function($tmpl) { - var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content' - var dialogId = '#' + dialogName - var $dlg = $tmpl.octemplate({ - dialog_name: dialogName, - title: title, - message: text, - type: 'notice' - }) - var input = $('<input/>') - input.attr('type', password ? 'password' : 'text').attr('id', dialogName + '-input').attr('placeholder', name) - var label = $('<label/>').attr('for', dialogName + '-input').text(name + ': ') - $dlg.append(label) - $dlg.append(input) - if (modal === undefined) { - modal = false - } - $('body').append($dlg) - - // wrap callback in _.once(): - // only call callback once and not twice (button handler and close - // event) but call it for the close event, if ESC or the x is hit - if (callback !== undefined) { - callback = _.once(callback) - } - - var buttonlist = [{ - text: t('core', 'No'), - click: function() { - if (callback !== undefined) { - // eslint-disable-next-line standard/no-callback-literal - callback(false, input.val()) - } - $(dialogId).ocdialog('close') - } - }, { - text: t('core', 'Yes'), - click: function() { - if (callback !== undefined) { - // eslint-disable-next-line standard/no-callback-literal - callback(true, input.val()) - } - $(dialogId).ocdialog('close') + return new Promise((resolve) => { + spawnDialog( + defineAsyncComponent(() => import('../components/LegacyDialogPrompt.vue')), + { + text, + name: title, + callback, + inputName: name, + isPassword: !!password }, - defaultButton: true - }] - - $(dialogId).ocdialog({ - closeOnEscape: true, - modal: modal, - buttons: buttonlist, - close: function() { - // callback is already fired if Yes/No is clicked directly - if (callback !== undefined) { - // eslint-disable-next-line standard/no-callback-literal - callback(false, input.val()) - } - } - }) - input.focus() - Dialogs.dialogsCounter++ + (...args) => { + callback(...args) + resolve() + }, + ) }) }, + /** - * show a file picker to pick a file from + * Legacy wrapper to the new Vue based filepicker from `@nextcloud/dialogs` + * + * Prefer to use the Vue filepicker directly instead. * * In order to pick several types of mime types they need to be passed as an * array of strings. @@ -214,433 +209,196 @@ const Dialogs = { * should be used instead. * * @param {string} title dialog title - * @param {function} callback which will be triggered when user presses Choose + * @param {Function} callback which will be triggered when user presses Choose * @param {boolean} [multiselect] whether it should be possible to select multiple files - * @param {string[]} [mimetypeFilter] mimetype to filter by - directories will always be included - * @param {boolean} [modal] make the dialog modal + * @param {string[]} [mimetype] mimetype to filter by - directories will always be included + * @param {boolean} [_modal] do not use * @param {string} [type] Type of file picker : Choose, copy, move, copy and move * @param {string} [path] path to the folder that the the file can be picket from - * @param {Object} [options] additonal options that need to be set + * @param {object} [options] additonal options that need to be set + * @param {Function} [options.filter] filter function for advanced filtering + * @param {boolean} [options.allowDirectoryChooser] Allow to select directories + * @deprecated since 27.1.0 use the filepicker from `@nextcloud/dialogs` instead */ - filepicker: function(title, callback, multiselect, mimetypeFilter, modal, type, path, options) { - var self = this - - this.filepicker.sortField = 'name' - this.filepicker.sortOrder = 'asc' - // avoid opening the picker twice - if (this.filepicker.loading) { - return - } - - if (type === undefined) { - type = this.FILEPICKER_TYPE_CHOOSE - } - - var emptyText = t('core', 'No files in here') - var newText = t('files', 'New folder') - if (type === this.FILEPICKER_TYPE_COPY || type === this.FILEPICKER_TYPE_MOVE || type === this.FILEPICKER_TYPE_COPY_MOVE) { - emptyText = t('core', 'No more subfolders in here') - } - - this.filepicker.loading = true - this.filepicker.filesClient = (OCA.Sharing && OCA.Sharing.PublicApp && OCA.Sharing.PublicApp.fileList) ? OCA.Sharing.PublicApp.fileList.filesClient : OC.Files.getClient() + filepicker(title, callback, multiselect = false, mimetype = undefined, _modal = undefined, type = FilePickerType.Choose, path = undefined, options = undefined) { - this.filelist = null - path = path || '' - options = Object.assign({ - allowDirectoryChooser: false - }, options) - - $.when(this._getFilePickerTemplate()).then(function($tmpl) { - self.filepicker.loading = false - var dialogName = 'oc-dialog-filepicker-content' - if (self.$filePicker) { - self.$filePicker.ocdialog('close') - } - - if (mimetypeFilter === undefined || mimetypeFilter === null) { - mimetypeFilter = [] - } - if (typeof (mimetypeFilter) === 'string') { - mimetypeFilter = [mimetypeFilter] - } - - self.$filePicker = $tmpl.octemplate({ - dialog_name: dialogName, - title: title, - emptytext: emptyText, - newtext: newText, - nameCol: t('core', 'Name'), - sizeCol: t('core', 'Size'), - modifiedCol: t('core', 'Modified') - }).data('path', path).data('multiselect', multiselect).data('mimetype', mimetypeFilter).data('allowDirectoryChooser', options.allowDirectoryChooser) - - if (modal === undefined) { - modal = false - } - if (multiselect === undefined) { - multiselect = false + /** + * Create legacy callback wrapper to support old filepicker syntax + * @param fn The original callback + * @param type The file picker type which was used to pick the file(s) + */ + const legacyCallback = (fn, type) => { + const getPath = (node) => { + const root = node?.root || '' + let path = node?.path || '' + // TODO: Fix this in @nextcloud/files + if (path.startsWith(root)) { + path = path.slice(root.length) || '/' + } + return path } - // No grid for IE! - if (OC.Util.isIE()) { - self.$filePicker.find('#picker-view-toggle').remove() - self.$filePicker.find('#picker-filestable').removeClass('view-grid') + if (multiselect) { + return (nodes) => fn(nodes.map(getPath), type) + } else { + return (nodes) => fn(getPath(nodes[0]), type) } + } - $('body').append(self.$filePicker) - - self.$showGridView = $('input#picker-showgridview') - self.$showGridView.on('change', _.bind(self._onGridviewChange, self)) - - if (!OC.Util.isIE()) { - self._getGridSettings() - } + /** + * Coverting a Node into a legacy file info to support the OC.dialogs.filepicker filter function + * @param node The node to convert + */ + const nodeToLegacyFile = (node) => ({ + id: node.fileid || null, + path: node.path, + mimetype: node.mime || null, + mtime: node.mtime?.getTime() || null, + permissions: node.permissions, + name: node.attributes?.displayName || node.basename, + etag: node.attributes?.etag || null, + hasPreview: node.attributes?.hasPreview || null, + mountType: node.attributes?.mountType || null, + quotaAvailableBytes: node.attributes?.quotaAvailableBytes || null, + icon: null, + sharePermissions: null, + }) - var newButton = self.$filePicker.find('.actions.creatable .button-add') - if (type === self.FILEPICKER_TYPE_CHOOSE && !options.allowDirectoryChooser) { - newButton.hide() - } - newButton.on('focus', function() { - self.$filePicker.ocdialog('setEnterCallback', function() { - event.stopImmediatePropagation() - event.preventDefault() - newButton.click() - }) - }) - newButton.on('blur', function() { - self.$filePicker.ocdialog('unsetEnterCallback') - }) + const builder = getFilePickerBuilder(title) - OC.registerMenu(newButton, self.$filePicker.find('.menu'), function() { - $input.focus() - self.$filePicker.ocdialog('setEnterCallback', function() { - event.stopImmediatePropagation() - event.preventDefault() - self.$filePicker.submit() + // Setup buttons + if (type === this.FILEPICKER_TYPE_CUSTOM) { + (options.buttons || []).forEach((button) => { + builder.addButton({ + callback: legacyCallback(callback, button.type), + label: button.text, + type: button.defaultButton ? 'primary' : 'secondary', }) - var newName = $input.val() - var lastPos = newName.lastIndexOf('.') - if (lastPos === -1) { - lastPos = newName.length - } - $input.selectRange(0, lastPos) - }) - var $form = self.$filePicker.find('.filenameform') - var $input = $form.find('input[type=\'text\']') - var $submit = $form.find('input[type=\'submit\']') - $submit.on('click', function(event) { - event.stopImmediatePropagation() - event.preventDefault() - $form.submit() }) - - - /** - * Checks whether the given file name is valid. - * - * @param name file name to check - * @return true if the file name is valid. - * @throws a string exception with an error message if - * the file name is not valid - * - * NOTE: This function is duplicated in the files app: - * https://github.com/nextcloud/server/blob/b9bc2417e7a8dc81feb0abe20359bedaf864f790/apps/files/js/files.js#L127-L148 - */ - var isFileNameValid = function (name) { - var trimmedName = name.trim(); - if (trimmedName === '.' || trimmedName === '..') - { - throw t('files', '"{name}" is an invalid file name.', {name: name}) - } else if (trimmedName.length === 0) { - throw t('files', 'File name cannot be empty.') - } else if (trimmedName.indexOf('/') !== -1) { - throw t('files', '"/" is not allowed inside a file name.') - } else if (!!(trimmedName.match(OC.config.blacklist_files_regex))) { - throw t('files', '"{name}" is not an allowed filetype', {name: name}) - } - - return true - } - - var checkInput = function() { - var filename = $input.val() - try { - if (!isFileNameValid(filename)) { - // isFileNameValid(filename) throws an exception itself - } else if (self.filelist.find(function(file) { - return file.name === this - }, filename)) { - throw t('files', '{newName} already exists', { newName: filename }, undefined, { - escape: false - }) - } else { - return true - } - } catch (error) { - $input.attr('title', error) - $input.tooltip({ - placement: 'right', - trigger: 'manual', - 'container': '.newFolderMenu' + } else { + builder.setButtonFactory((nodes, path) => { + const buttons = [] + const [node] = nodes + const target = node?.displayname || node?.basename || basename(path) + + if (type === FilePickerType.Choose) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Choose), + label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'), + type: 'primary', }) - $input.tooltip('fixTitle') - $input.tooltip('show') - $input.addClass('error') } - return false - } - - $form.on('submit', function(event) { - event.stopPropagation() - event.preventDefault() - - if (checkInput()) { - var newname = $input.val() - self.filepicker.filesClient.createDirectory(self.$filePicker.data('path') + "/" + newname).always(function (status) { - self._fillFilePicker(self.$filePicker.data('path') + "/" + newname) + if (type === FilePickerType.CopyMove || type === FilePickerType.Copy) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Copy), + label: target ? t('core', 'Copy to {target}', { target }) : t('core', 'Copy'), + type: 'primary', + icon: IconCopy, }) - OC.hideMenus() - self.$filePicker.ocdialog('unsetEnterCallback') - self.$filePicker.click() - $input.val(newText) } - }) - $input.keypress(function(event) { - if (event.keyCode === 13 || event.which === 13) { - event.stopImmediatePropagation() - event.preventDefault() - $form.submit() + if (type === FilePickerType.Move || type === FilePickerType.CopyMove) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Move), + label: target ? t('core', 'Move to {target}', { target }) : t('core', 'Move'), + type: type === FilePickerType.Move ? 'primary' : 'secondary', + icon: IconMove, + }) } + return buttons }) + } - self.$filePicker.ready(function() { - self.$fileListHeader = self.$filePicker.find('.filelist thead tr') - self.$filelist = self.$filePicker.find('.filelist tbody') - self.$filelistContainer = self.$filePicker.find('.filelist-container') - self.$dirTree = self.$filePicker.find('.dirtree') - self.$dirTree.on('click', 'div:not(:last-child)', self, function(event) { - self._handleTreeListSelect(event, type) - }) - self.$filelist.on('click', 'tr', function(event) { - self._handlePickerClick(event, $(this), type) - }) - self.$fileListHeader.on('click', 'a', function(event) { - var dir = self.$filePicker.data('path') - self.filepicker.sortField = $(event.currentTarget).data('sort') - self.filepicker.sortOrder = self.filepicker.sortOrder === 'asc' ? 'desc' : 'asc' - self._fillFilePicker(dir) - }) - self._fillFilePicker(path) - }) - - // build buttons - var functionToCall = function(returnType) { - if (callback !== undefined) { - var datapath - if (multiselect === true) { - datapath = [] - self.$filelist.find('tr.filepicker_element_selected').each(function(index, element) { - datapath.push(self.$filePicker.data('path') + '/' + $(element).data('entryname')) - }) - } else { - datapath = self.$filePicker.data('path') - var selectedName = self.$filelist.find('tr.filepicker_element_selected').data('entryname') - if (selectedName) { - datapath += '/' + selectedName - } - } - callback(datapath, returnType) - self.$filePicker.ocdialog('close') - } - } - - var chooseCallback = function() { - functionToCall(Dialogs.FILEPICKER_TYPE_CHOOSE) - } - - var copyCallback = function() { - functionToCall(Dialogs.FILEPICKER_TYPE_COPY) - } - - var moveCallback = function() { - functionToCall(Dialogs.FILEPICKER_TYPE_MOVE) - } + if (mimetype) { + builder.setMimeTypeFilter(typeof mimetype === 'string' ? [mimetype] : (mimetype || [])) + } + if (typeof options?.filter === 'function') { + builder.setFilter((node) => options.filter(nodeToLegacyFile(node))) + } + builder.allowDirectories(options?.allowDirectoryChooser === true || mimetype?.includes('httpd/unix-directory') || false) + .setMultiSelect(multiselect) + .startAt(path) + .build() + .pick() + }, - var buttonlist = [] - if (type === Dialogs.FILEPICKER_TYPE_CHOOSE) { - buttonlist.push({ - text: t('core', 'Choose'), - click: chooseCallback, - defaultButton: true - }) - } else if (type === Dialogs.FILEPICKER_TYPE_CUSTOM) { - options.buttons.forEach(function(button) { - buttonlist.push({ - text: button.text, - click: function() { - functionToCall(button.type) - }, - defaultButton: button.defaultButton - }) - }) - } else { - if (type === Dialogs.FILEPICKER_TYPE_COPY || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) { - buttonlist.push({ - text: t('core', 'Copy'), - click: copyCallback, - defaultButton: false - }) - } - if (type === Dialogs.FILEPICKER_TYPE_MOVE || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) { - buttonlist.push({ - text: t('core', 'Move'), - click: moveCallback, - defaultButton: true - }) - } - } + /** + * Displays raw dialog + * You better use a wrapper instead ... + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + message: function(content, title, dialogType, buttons, callback = () => {}, modal, allowHtml) { + const builder = (new DialogBuilder()) + .setName(title) + .setText(allowHtml ? '' : content) + .setButtons(Dialogs._getLegacyButtons(buttons, callback)) + + switch (dialogType) { + case 'alert': + builder.setSeverity('warning') + break + case 'notice': + builder.setSeverity('info') + break + default: + break + } - self.$filePicker.ocdialog({ - closeOnEscape: true, - // max-width of 600 - width: 600, - height: 500, - modal: modal, - buttons: buttonlist, - style: { - buttons: 'aside' - }, - close: function() { - try { - $(this).ocdialog('destroy').remove() - } catch (e) { - } - self.$filePicker = null - } - }) + const dialog = builder.build() + + if (allowHtml) { + dialog.setHTML(content) + } - // We can access primary class only from oc-dialog. - // Hence this is one of the approach to get the choose button. - var getOcDialog = self.$filePicker.closest('.oc-dialog') - var buttonEnableDisable = getOcDialog.find('.primary') - if (self.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || self.$filePicker.data('allowDirectoryChooser')) { - buttonEnableDisable.prop('disabled', false) - } else { - buttonEnableDisable.prop('disabled', true) + return dialog.show().then(() => { + if(!callback._clicked) { + callback(false) } }) - .fail(function(status, error) { - // If the method is called while navigating away - // from the page, it is probably not needed ;) - self.filepicker.loading = false - if (status !== 0) { - alert(t('core', 'Error loading file picker template: {error}', { error: error })) - } - }) }, + /** - * Displays raw dialog - * You better use a wrapper instead ... + * Helper for legacy API + * @deprecated */ - message: function(content, title, dialogType, buttons, callback, modal, allowHtml) { - return $.when(this._getMessageTemplate()).then(function($tmpl) { - var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content' - var dialogId = '#' + dialogName - var $dlg = $tmpl.octemplate({ - dialog_name: dialogName, - title: title, - message: content, - type: dialogType - }, allowHtml ? { escapeFunction: '' } : {}) - if (modal === undefined) { - modal = false - } - $('body').append($dlg) - var buttonlist = [] - switch (buttons) { + _getLegacyButtons(buttons, callback) { + const buttonList = [] + + switch (typeof buttons === 'object' ? buttons.type : buttons) { case Dialogs.YES_NO_BUTTONS: - buttonlist = [{ - text: t('core', 'No'), - click: function() { - if (callback !== undefined) { - callback(false) - } - $(dialogId).ocdialog('close') - } - }, - { - text: t('core', 'Yes'), - click: function() { - if (callback !== undefined) { - callback(true) - } - $(dialogId).ocdialog('close') + buttonList.push({ + label: buttons?.cancel ?? t('core', 'No'), + callback: () => { + callback._clicked = true + callback(false) }, - defaultButton: true - }] + }) + buttonList.push({ + label: buttons?.confirm ?? t('core', 'Yes'), + type: 'primary', + callback: () => { + callback._clicked = true + callback(true) + }, + }) break - case Dialogs.OK_BUTTON: - var functionToCall = function() { - $(dialogId).ocdialog('close') - if (callback !== undefined) { - callback() - } - } - buttonlist[0] = { - text: t('core', 'OK'), - click: functionToCall, - defaultButton: true - } + case Dialogs.OK_BUTTONS: + buttonList.push({ + label: buttons?.confirm ?? t('core', 'OK'), + type: 'primary', + callback: () => { + callback._clicked = true + callback(true) + }, + }) break default: - if (typeof(buttons) === 'object') { - switch (buttons.type) { - case Dialogs.YES_NO_BUTTONS: - buttonlist = [{ - text: buttons.cancel || t('core', 'No'), - click: function() { - if (callback !== undefined) { - callback(false) - } - $(dialogId).ocdialog('close') - } - }, - { - text: buttons.confirm || t('core', 'Yes'), - click: function() { - if (callback !== undefined) { - callback(true) - } - $(dialogId).ocdialog('close') - }, - defaultButton: true, - classes: buttons.confirmClasses - }] - break - } - } + console.error('Invalid call to OC.dialogs') break - } - - $(dialogId).ocdialog({ - closeOnEscape: true, - closeCallback: () => { callback && callback(false) }, - modal: modal, - buttons: buttonlist - }) - Dialogs.dialogsCounter++ - }) - .fail(function(status, error) { - // If the method is called while navigating away from - // the page, we still want to deliver the message. - if (status === 0) { - alert(title + ': ' + content) - } else { - alert(t('core', 'Error loading message template: {error}', { error: error })) - } - }) + } + return buttonList }, + _fileexistsshown: false, /** * Displays file exists dialog @@ -649,6 +407,8 @@ const Dialogs = { * @param {object} replacement file with name, size and mtime * @param {object} controller with onCancel, onSkip, onReplace and onRename methods * @returns {Promise} jquery promise that resolves after the dialog template was loaded + * + * @deprecated 29.0.0 Use openConflictPicker from the @nextcloud/upload package instead */ fileexists: function(data, original, replacement, controller) { var self = this @@ -1007,68 +767,12 @@ const Dialogs = { // } return dialogDeferred.promise() }, - // get the gridview setting and set the input accordingly - _getGridSettings: function() { - var self = this - $.get(OC.generateUrl('/apps/files/api/v1/showgridview'), function(response) { - self.$showGridView.get(0).checked = response.gridview - self.$showGridView.next('#picker-view-toggle') - .removeClass('icon-toggle-filelist icon-toggle-pictures') - .addClass(response.gridview ? 'icon-toggle-filelist' : 'icon-toggle-pictures') - $('.list-container').toggleClass('view-grid', response.gridview) - }) - }, - _onGridviewChange: function() { - var show = this.$showGridView.is(':checked') - // only save state if user is logged in - if (OC.currentUser) { - $.post(OC.generateUrl('/apps/files/api/v1/showgridview'), { - show: show - }) - } - this.$showGridView.next('#picker-view-toggle') - .removeClass('icon-toggle-filelist icon-toggle-pictures') - .addClass(show ? 'icon-toggle-filelist' : 'icon-toggle-pictures') - $('.list-container').toggleClass('view-grid', show) - }, - _getFilePickerTemplate: function() { - var defer = $.Deferred() - if (!this.$filePickerTemplate) { - var self = this - $.get(OC.filePath('core', 'templates', 'filepicker.html'), function(tmpl) { - self.$filePickerTemplate = $(tmpl) - self.$listTmpl = self.$filePickerTemplate.find('.filelist tbody tr:first-child').detach() - defer.resolve(self.$filePickerTemplate) - }) - .fail(function(jqXHR, textStatus, errorThrown) { - defer.reject(jqXHR.status, errorThrown) - }) - } else { - defer.resolve(this.$filePickerTemplate) - } - return defer.promise() - }, - _getMessageTemplate: function() { - var defer = $.Deferred() - if (!this.$messageTemplate) { - var self = this - $.get(OC.filePath('core', 'templates', 'message.html'), function(tmpl) { - self.$messageTemplate = $(tmpl) - defer.resolve(self.$messageTemplate) - }) - .fail(function(jqXHR, textStatus, errorThrown) { - defer.reject(jqXHR.status, errorThrown) - }) - } else { - defer.resolve(this.$messageTemplate) - } - return defer.promise() - }, + _getFileExistsTemplate: function() { var defer = $.Deferred() if (!this.$fileexistsTemplate) { var self = this - $.get(OC.filePath('files', 'templates', 'fileexists.html'), function(tmpl) { + $.get(OC.filePath('core', 'templates/legacy', 'fileexists.html'), function(tmpl) { self.$fileexistsTemplate = $(tmpl) defer.resolve(self.$fileexistsTemplate) }) @@ -1080,246 +784,6 @@ const Dialogs = { } return defer.promise() }, - _getFileList: function(dir, mimeType) { // this is only used by the spreedme app atm - if (typeof (mimeType) === 'string') { - mimeType = [mimeType] - } - - return $.getJSON( - OC.filePath('files', 'ajax', 'list.php'), - { - dir: dir, - mimetypes: JSON.stringify(mimeType) - } - ) - }, - - /** - * fills the filepicker with files - */ - _fillFilePicker: function(dir) { - var self = this - this.$filelist.empty() - this.$filePicker.find('.emptycontent').hide() - this.$filelistContainer.addClass('icon-loading') - this.$filePicker.data('path', dir) - var filter = this.$filePicker.data('mimetype') - if (typeof (filter) === 'string') { - filter = [filter] - } - self.$fileListHeader.find('.sort-indicator').addClass('hidden').removeClass('icon-triangle-n').removeClass('icon-triangle-s') - self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').removeClass('hidden') - if (self.filepicker.sortOrder === 'asc') { - self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-n') - } else { - self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-s') - } - self.filepicker.filesClient.getFolderContents(dir).then(function(status, files) { - self.filelist = files - if (filter && filter.length > 0 && filter.indexOf('*') === -1) { - files = files.filter(function(file) { - return file.type === 'dir' || filter.indexOf(file.mimetype) !== -1 - }) - } - - var Comparators = { - name: function(fileInfo1, fileInfo2) { - if (fileInfo1.type === 'dir' && fileInfo2.type !== 'dir') { - return -1 - } - if (fileInfo1.type !== 'dir' && fileInfo2.type === 'dir') { - return 1 - } - return OC.Util.naturalSortCompare(fileInfo1.name, fileInfo2.name) - }, - size: function(fileInfo1, fileInfo2) { - return fileInfo1.size - fileInfo2.size - }, - mtime: function(fileInfo1, fileInfo2) { - return fileInfo1.mtime - fileInfo2.mtime - } - } - var comparator = Comparators[self.filepicker.sortField] || Comparators.name - files = files.sort(function(file1, file2) { - var isFavorite = function(fileInfo) { - return fileInfo.tags && fileInfo.tags.indexOf(OC.TAG_FAVORITE) >= 0 - } - - if (isFavorite(file1) && !isFavorite(file2)) { - return -1 - } else if (!isFavorite(file1) && isFavorite(file2)) { - return 1 - } - - return self.filepicker.sortOrder === 'asc' ? comparator(file1, file2) : -comparator(file1, file2) - }) - - self._fillSlug() - - if (files.length === 0) { - self.$filePicker.find('.emptycontent').show() - self.$fileListHeader.hide() - } else { - self.$filePicker.find('.emptycontent').hide() - self.$fileListHeader.show() - } - - self.$filelist.empty(); - - $.each(files, function(idx, entry) { - entry.icon = OC.MimeType.getIconUrl(entry.mimetype) - var simpleSize, sizeColor - if (typeof (entry.size) !== 'undefined' && entry.size >= 0) { - simpleSize = OC.Util.humanFileSize(parseInt(entry.size, 10), true) - sizeColor = Math.round(160 - Math.pow((entry.size / (1024 * 1024)), 2)) - } else { - simpleSize = t('files', 'Pending') - sizeColor = 80 - } - - // split the filename in half if the size is bigger than 20 char - // for ellipsis - if (entry.name.length >= 10) { - // leave maximum 10 letters - var split = Math.min(Math.floor(entry.name.length / 2), 10) - var filename1 = entry.name.substr(0, entry.name.length - split) - var filename2 = entry.name.substr(entry.name.length - split) - } else { - var filename1 = entry.name - var filename2 = '' - } - - var $row = self.$listTmpl.octemplate({ - type: entry.type, - dir: dir, - filename: entry.name, - filename1: filename1, - filename2: filename2, - date: OC.Util.relativeModifiedDate(entry.mtime), - size: simpleSize, - sizeColor: sizeColor, - icon: entry.icon - }) - if (entry.type === 'file') { - var urlSpec = { - file: dir + '/' + entry.name, - x: 100, - y: 100 - } - var img = new Image() - var previewUrl = OC.generateUrl('/core/preview.png?') + $.param(urlSpec) - img.onload = function() { - if (img.width > 5) { - $row.find('td.filename').attr('style', 'background-image:url(' + previewUrl + ')') - } - } - img.src = previewUrl - } - self.$filelist.append($row) - }) - - self.$filelistContainer.removeClass('icon-loading') - }) - }, - /** - * fills the tree list with directories - */ - _fillSlug: function() { - var addButton = this.$dirTree.find('.actions.creatable').detach() - this.$dirTree.empty() - var self = this - - self.$dirTree.append(addButton) - - var dir - var path = this.$filePicker.data('path') - var $template = $('<div data-dir="{dir}"><a>{name}</a></div>').addClass('crumb') - if (path) { - var paths = path.split('/') - $.each(paths, function(index, dir) { - dir = paths.pop() - if (dir === '') { - return false - } - self.$dirTree.prepend($template.octemplate({ - dir: paths.join('/') + '/' + dir, - name: dir - })) - }) - } - - $template.octemplate({ - dir: '', - name: '' // Ugly but works ;) - }, { escapeFunction: null }).prependTo(this.$dirTree) - - }, - /** - * handle selection made in the tree list - */ - _handleTreeListSelect: function(event, type) { - var self = event.data - var dir = $(event.target).closest('.crumb').data('dir') - self._fillFilePicker(dir) - var getOcDialog = (event.target).closest('.oc-dialog') - var buttonEnableDisable = $('.primary', getOcDialog) - this._changeButtonsText(type, dir.split(/[/]+/).pop()) - if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) { - buttonEnableDisable.prop('disabled', false) - } else { - buttonEnableDisable.prop('disabled', true) - } - }, - /** - * handle clicks made in the filepicker - */ - _handlePickerClick: function(event, $element, type) { - var getOcDialog = this.$filePicker.closest('.oc-dialog') - var buttonEnableDisable = getOcDialog.find('.primary') - if ($element.data('type') === 'file') { - if (this.$filePicker.data('multiselect') !== true || !event.ctrlKey) { - this.$filelist.find('.filepicker_element_selected').removeClass('filepicker_element_selected') - } - $element.toggleClass('filepicker_element_selected') - buttonEnableDisable.prop('disabled', false) - } else if ($element.data('type') === 'dir') { - this._fillFilePicker(this.$filePicker.data('path') + '/' + $element.data('entryname')) - this._changeButtonsText(type, $element.data('entryname')) - if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) { - buttonEnableDisable.prop('disabled', false) - } else { - buttonEnableDisable.prop('disabled', true) - } - } - }, - - /** - * Handle - * @param type of action - * @param dir on which to change buttons text - * @private - */ - _changeButtonsText: function(type, dir) { - var copyText = dir === '' ? t('core', 'Copy') : t('core', 'Copy to {folder}', { folder: dir }) - var moveText = dir === '' ? t('core', 'Move') : t('core', 'Move to {folder}', { folder: dir }) - var buttons = $('.oc-dialog-buttonrow button') - switch (type) { - case this.FILEPICKER_TYPE_CHOOSE: - break - case this.FILEPICKER_TYPE_CUSTOM: - break - case this.FILEPICKER_TYPE_COPY: - buttons.text(copyText) - break - case this.FILEPICKER_TYPE_MOVE: - buttons.text(moveText) - break - case this.FILEPICKER_TYPE_COPY_MOVE: - buttons.eq(0).text(copyText) - buttons.eq(1).text(moveText) - break - } - } } export default Dialogs diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js index cc576d8655a..090c351c057 100644 --- a/core/src/OC/eventsource.js +++ b/core/src/OC/eventsource.js @@ -1,39 +1,13 @@ -/* eslint-disable */ -/** - * ownCloud - * - * @author Robin Appelman - * @copyright 2012 Robin Appelman icewind1991@gmail.com - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library 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 library. If not, see <http://www.gnu.org/licenses/>. - * - */ - /** - * Wrapper for server side events - * (http://en.wikipedia.org/wiki/Server-sent_events) - * includes a fallback for older browsers and IE - * - * use server side events with caution, too many open requests can hang the - * server + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ -/* global EventSource */ - +/* eslint-disable */ import $ from 'jquery' -import { getToken } from './requesttoken' +import { getRequestToken } from './requesttoken.ts' /** * Create a new event source @@ -54,7 +28,7 @@ const OCEventSource = function(src, data) { dataStr += name + '=' + encodeURIComponent(data[name]) + '&' } } - dataStr += 'requesttoken=' + encodeURIComponent(getToken()) + dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken()) if (!this.useFallBack && typeof EventSource !== 'undefined') { joinChar = '&' if (src.indexOf('?') === -1) { @@ -69,7 +43,7 @@ const OCEventSource = function(src, data) { } else { var iframeId = 'oc_eventsource_iframe_' + OCEventSource.iframeCount OCEventSource.fallBackSources[OCEventSource.iframeCount] = this - this.iframe = $('<iframe/>') + this.iframe = $('<iframe></iframe>') this.iframe.attr('id', iframeId) this.iframe.hide() diff --git a/core/src/OC/get_set.js b/core/src/OC/get_set.js index b4dcd563eff..0c909ad04fd 100644 --- a/core/src/OC/get_set.js +++ b/core/src/OC/get_set.js @@ -1,30 +1,8 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -/** - * Get a variable by name - * @param {string} context context - * @returns {Function} getter - * @deprecated 19.0.0 use https://lodash.com/docs#get - */ export const get = context => name => { const namespaces = name.split('.') const tail = namespaces.pop() @@ -40,8 +18,9 @@ export const get = context => name => { /** * Set a variable by name + * * @param {string} context context - * @returns {Function} setter + * @return {Function} setter * @deprecated 19.0.0 use https://lodash.com/docs#set */ export const set = context => (name, value) => { diff --git a/core/src/OC/host.js b/core/src/OC/host.js index f90ca65b4da..75c7d63804b 100644 --- a/core/src/OC/host.js +++ b/core/src/OC/host.js @@ -1,29 +1,8 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - /** - * Protocol that is used to access this Nextcloud instance - * @returns {string} Used protocol - * @deprecated 17.0.0 use window.location.protocol directly + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + export const getProtocol = () => window.location.protocol.split(':')[0] /** @@ -35,9 +14,9 @@ export const getProtocol = () => window.location.protocol.split(':')[0] * https://example.com => example.com * http://example.com:8080 => example.com:8080 * - * @returns {string} host + * @return {string} host * - * @since 8.2 + * @since 8.2.0 * @deprecated 17.0.0 use window.location.host directly */ export const getHost = () => window.location.host @@ -46,8 +25,8 @@ export const getHost = () => window.location.host * Returns the hostname used to access this Nextcloud instance * The hostname is always stripped of the port * - * @returns {string} hostname - * @since 9.0 + * @return {string} hostname + * @since 9.0.0 * @deprecated 17.0.0 use window.location.hostname directly */ export const getHostName = () => window.location.hostname @@ -55,9 +34,9 @@ export const getHostName = () => window.location.hostname /** * Returns the port number used to access this Nextcloud instance * - * @returns {int} port number + * @return {number} port number * - * @since 8.2 + * @since 8.2.0 * @deprecated 17.0.0 use window.location.port directly */ export const getPort = () => window.location.port diff --git a/core/src/OC/index.js b/core/src/OC/index.js index 036e640be39..5afc941b396 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -1,37 +1,19 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { subscribe } from '@nextcloud/event-bus' -import { addScript, addStyle } from './legacy-loader' import { ajaxConnectionLostHandler, processAjaxError, registerXHRForErrorProcessing, -} from './xhr-error' -import Apps from './apps' -import { AppConfig, appConfig } from './appconfig' -import { appSettings } from './appsettings' -import appswebroots from './appswebroots' -import Backbone from './backbone' +} from './xhr-error.js' +import Apps from './apps.js' +import { AppConfig, appConfig } from './appconfig.js' +import appswebroots from './appswebroots.js' +import Backbone from './backbone.js' import { basename, dirname, @@ -42,8 +24,8 @@ import { import { build as buildQueryString, parse as parseQueryString, -} from './query-string' -import Config from './config' +} from './query-string.js' +import Config from './config.js' import { coreApps, menuSpeed, @@ -55,35 +37,31 @@ import { PERMISSION_SHARE, PERMISSION_UPDATE, TAG_FAVORITE, -} from './constants' -import ContactsMenu from './contactsmenu' -import { currentUser, getCurrentUser } from './currentuser' -import Dialogs from './dialogs' -import EventSource from './eventsource' -import { get, set } from './get_set' -import { getCapabilities } from './capabilities' +} from './constants.js' +import { currentUser, getCurrentUser } from './currentuser.js' +import Dialogs from './dialogs.js' +import EventSource from './eventsource.js' +import { get, set } from './get_set.js' +import { getCapabilities } from './capabilities.js' import { getHost, getHostName, getPort, getProtocol, -} from './host' -import { - getToken as getRequestToken, -} from './requesttoken' +} from './host.js' +import { getRequestToken } from './requesttoken.ts' import { hideMenus, registerMenu, showMenu, unregisterMenu, -} from './menu' -import { isUserAdmin } from './admin' -import L10N, { - getLanguage, - getLocale, -} from './l10n' +} from './menu.js' +import { isUserAdmin } from './admin.js' +import L10N from './l10n.js' import { getCanonicalLocale, + getLanguage, + getLocale, } from '@nextcloud/l10n' import { @@ -98,16 +76,16 @@ import { import { linkToRemoteBase, -} from './routing' -import msg from './msg' -import Notification from './notification' -import PasswordConfirmation from './password-confirmation' -import Plugins from './plugins' -import { theme } from './theme' -import Util from './util' -import { debug } from './debug' -import { redirect, reload } from './navigation' -import webroot from './webroot' +} from './routing.js' +import msg from './msg.js' +import Notification from './notification.js' +import PasswordConfirmation from './password-confirmation.js' +import Plugins from './plugins.js' +import { theme } from './theme.js' +import Util from './util.js' +import { debug } from './debug.js' +import { redirect, reload } from './navigation.js' +import webroot from './webroot.js' /** @namespace OC */ export default { @@ -130,26 +108,22 @@ export default { */ /** * Check if a user file is allowed to be handled. + * * @param {string} file to check - * @returns {Boolean} + * @return {boolean} * @deprecated 17.0.0 */ fileIsBlacklisted: file => !!(file.match(Config.blacklist_files_regex)), - - addScript, - addStyle, Apps, AppConfig, appConfig, - appSettings, appswebroots, Backbone, - ContactsMenu, config: Config, /** * Currently logged in user or null if none * - * @type String + * @type {string} * @deprecated use `getCurrentUser` from https://www.npmjs.com/package/@nextcloud/auth */ currentUser, @@ -168,6 +142,7 @@ export default { /** * Ajax error handlers + * * @todo remove from here and keep internally -> requires new tests */ _ajaxConnectionLostHandler: ajaxConnectionLostHandler, @@ -226,17 +201,14 @@ export default { * @deprecated 20.0.0 use `getCanonicalLocale` from https://www.npmjs.com/package/@nextcloud/l10n */ getCanonicalLocale, + /** + * @deprecated 26.0.0 use `getLocale` from https://www.npmjs.com/package/@nextcloud/l10n + */ getLocale, - getLanguage, /** - * Loads translations for the given app asynchronously. - * - * @param {String} app app name - * @param {Function} callback callback to call after loading - * @return {Promise} - * @deprecated 17.0.0 use OC.L10N.load instead + * @deprecated 26.0.0 use `getLanguage` from https://www.npmjs.com/package/@nextcloud/l10n */ - addTranslations: L10N.load, + getLanguage, /** * Query string helpers @@ -246,6 +218,9 @@ export default { msg, Notification, + /** + * @deprecated 28.0.0 use methods from '@nextcloud/password-confirmation' + */ PasswordConfirmation, Plugins, theme, @@ -283,9 +258,16 @@ export default { */ linkTo, /** + * @param {string} service service name + * @param {number} version OCS API version + * @return {string} OCS API base path * @deprecated 19.0.0 use `generateOcsUrl` from https://www.npmjs.com/package/@nextcloud/router */ - linkToOCS: generateOcsUrl, + linkToOCS: (service, version) => { + return generateOcsUrl(service, {}, { + ocsVersion: version || 1, + }) + '/' + }, /** * @deprecated 19.0.0 use `generateRemoteUrl` from https://www.npmjs.com/package/@nextcloud/router */ @@ -295,7 +277,7 @@ export default { * Relative path to Nextcloud root. * For example: "/nextcloud" * - * @type string + * @type {string} * * @deprecated 19.0.0 use `getRootUrl` from https://www.npmjs.com/package/@nextcloud/router * @see OC#getRootPath diff --git a/core/src/OC/l10n-registry.js b/core/src/OC/l10n-registry.js deleted file mode 100644 index dc353902337..00000000000 --- a/core/src/OC/l10n-registry.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -// This var is global because it's shared across webpack bundles -window._oc_l10n_registry_translations = window._oc_l10n_registry_translations || {} -window._oc_l10n_registry_plural_functions = window._oc_l10n_registry_plural_functions || {} - -/** - * @param {String} appId the app id - * @param {Object} translations the translations list - * @param {Function} pluralFunction the translations list - */ -const register = (appId, translations, pluralFunction) => { - window._oc_l10n_registry_translations[appId] = translations - window._oc_l10n_registry_plural_functions[appId] = pluralFunction -} - -/** - * @param {String} appId the app id - * @param {Object} translations the translations list - * @param {Function} pluralFunction the translations list - */ -const extend = (appId, translations, pluralFunction) => { - window._oc_l10n_registry_translations[appId] = Object.assign( - window._oc_l10n_registry_translations[appId], - translations - ) - window._oc_l10n_registry_plural_functions[appId] = pluralFunction -} - -/** - * @param {String} appId the app id - * @param {Object} translations the translations list - * @param {Function} pluralFunction the translations list - */ -export const registerAppTranslations = (appId, translations, pluralFunction) => { - if (!hasAppTranslations(appId)) { - register(appId, translations, pluralFunction) - } else { - extend(appId, translations, pluralFunction) - } -} - -/** - * @param {String} appId the app id - */ -export const unregisterAppTranslations = appId => { - delete window._oc_l10n_registry_translations[appId] - delete window._oc_l10n_registry_plural_functions[appId] -} - -/** - * @param {String} appId the app id - * @returns {Boolean} - */ -export const hasAppTranslations = appId => { - return window._oc_l10n_registry_translations[appId] !== undefined - && window._oc_l10n_registry_plural_functions[appId] !== undefined -} - -/** - * @param {String} appId the app id - * @returns {Object} - */ -export const getAppTranslations = appId => { - return { - translations: window._oc_l10n_registry_translations[appId] || {}, - pluralFunction: window._oc_l10n_registry_plural_functions[appId], - } -} diff --git a/core/src/OC/l10n.js b/core/src/OC/l10n.js index 2eeb342aa13..02f912d6a99 100644 --- a/core/src/OC/l10n.js +++ b/core/src/OC/l10n.js @@ -1,342 +1,90 @@ /** - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-FileCopyrightText: 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import _ from 'underscore' -import $ from 'jquery' -import DOMPurify from 'dompurify' import Handlebars from 'handlebars' -import identity from 'lodash/fp/identity' -import escapeHTML from 'escape-html' -import { generateFilePath } from '@nextcloud/router' - -import OC from './index' import { - getAppTranslations, - hasAppTranslations, - registerAppTranslations, - unregisterAppTranslations, -} from './l10n-registry' + loadTranslations, + translate, + translatePlural, + register, + unregister, +} from '@nextcloud/l10n' /** * L10N namespace with localization functions. * * @namespace OC.L10n + * @deprecated 26.0.0 use https://www.npmjs.com/package/@nextcloud/l10n */ const L10n = { /** * Load an app's translation bundle if not loaded already. * - * @param {String} appName name of the app + * @deprecated 26.0.0 use `loadTranslations` from https://www.npmjs.com/package/@nextcloud/l10n + * + * @param {string} appName name of the app * @param {Function} callback callback to be called when * the translations are loaded - * @returns {Promise} promise + * @return {Promise} promise */ - load(appName, callback) { - // already available ? - if (hasAppTranslations(appName) || OC.getLocale() === 'en') { - const deferred = $.Deferred() - const promise = deferred.promise() - promise.then(callback) - deferred.resolve() - return promise - } - - const self = this - const url = generateFilePath(appName, 'l10n', OC.getLocale() + '.json') - - // load JSON translation bundle per AJAX - return $.get(url) - .then( - function(result) { - if (result.translations) { - self.register(appName, result.translations, result.pluralForm) - } - }) - .then(callback) - }, + load: loadTranslations, /** * Register an app's translation bundle. * - * @param {String} appName name of the app - * @param {Object<String,String>} bundle bundle + * @deprecated 26.0.0 use `register` from https://www.npmjs.com/package/@nextcloud/l10 + * + * @param {string} appName name of the app + * @param {Record<string, string>} bundle bundle */ - register(appName, bundle) { - registerAppTranslations(appName, bundle, this._getPlural) - }, + register, /** - * @private do not use this + * @private + * @deprecated 26.0.0 use `unregister` from https://www.npmjs.com/package/@nextcloud/l10n */ - _unregister: unregisterAppTranslations, + _unregister: unregister, /** * Translate a string + * + * @deprecated 26.0.0 use `translate` from https://www.npmjs.com/package/@nextcloud/l10n + * * @param {string} app the id of the app for which to translate the string * @param {string} text the string to translate - * @param {Object} [vars] map of placeholder key to value + * @param {object} [vars] map of placeholder key to value * @param {number} [count] number to replace %n with - * @param {array} [options] options array - * @param {bool} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) - * @param {bool} [options.sanitize=true] enable/disable sanitization (by default enabled) - * @returns {string} + * @param {Array} [options] options array + * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) + * @param {boolean} [options.sanitize=true] enable/disable sanitization (by default enabled) + * @return {string} */ - translate(app, text, vars, count, options) { - const defaultOptions = { - escape: true, - sanitize: true, - } - const allOptions = options || {} - _.defaults(allOptions, defaultOptions) - - const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity - const optEscape = allOptions.escape ? escapeHTML : identity - - // TODO: cache this function to avoid inline recreation - // of the same function over and over again in case - // translate() is used in a loop - const _build = function(text, vars, count) { - return text.replace(/%n/g, count).replace(/{([^{}]*)}/g, - function(a, b) { - const r = vars[b] - if (typeof r === 'string' || typeof r === 'number') { - return optSanitize(optEscape(r)) - } else { - return optSanitize(a) - } - } - ) - } - let translation = text - const bundle = getAppTranslations(app) - const value = bundle.translations[text] - if (typeof (value) !== 'undefined') { - translation = value - } - - if (typeof vars === 'object' || count !== undefined) { - return optSanitize(_build(translation, vars, count)) - } else { - return optSanitize(translation) - } - }, + translate, /** * Translate a plural string + * + * @deprecated 26.0.0 use `translatePlural` from https://www.npmjs.com/package/@nextcloud/l10n + * * @param {string} app the id of the app for which to translate the string * @param {string} textSingular the string to translate for exactly one object * @param {string} textPlural the string to translate for n objects * @param {number} count number to determine whether to use singular or plural - * @param {Object} [vars] map of placeholder key to value - * @param {array} [options] options array - * @param {bool} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) - * @returns {string} Translated string - */ - translatePlural(app, textSingular, textPlural, count, vars, options) { - const identifier = '_' + textSingular + '_::_' + textPlural + '_' - const bundle = getAppTranslations(app) - const value = bundle.translations[identifier] - if (typeof (value) !== 'undefined') { - const translation = value - if ($.isArray(translation)) { - const plural = bundle.pluralFunction(count) - return this.translate(app, translation[plural], vars, count, options) - } - } - - if (count === 1) { - return this.translate(app, textSingular, vars, count, options) - } else { - return this.translate(app, textPlural, vars, count, options) - } - }, - - /** - * The plural function taken from symfony - * - * @param {number} number the number of elements - * @returns {number} - * @private + * @param {object} [vars] map of placeholder key to value + * @param {Array} [options] options array + * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) + * @return {string} Translated string */ - _getPlural(number) { - let language = OC.getLanguage() - if (language === 'pt-BR') { - // temporary set a locale for brazilian - language = 'xbr' - } - - if (typeof language === 'undefined' || language === '') { - return (number === 1) ? 0 : 1 - } - - if (language.length > 3) { - language = language.substring(0, language.lastIndexOf('-')) - } - - /* - * The plural rules are derived from code of the Zend Framework (2010-09-25), - * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd). - * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) - */ - switch (language) { - case 'az': - case 'bo': - case 'dz': - case 'id': - case 'ja': - case 'jv': - case 'ka': - case 'km': - case 'kn': - case 'ko': - case 'ms': - case 'th': - case 'tr': - case 'vi': - case 'zh': - return 0 - - case 'af': - case 'bn': - case 'bg': - case 'ca': - case 'da': - case 'de': - case 'el': - case 'en': - case 'eo': - case 'es': - case 'et': - case 'eu': - case 'fa': - case 'fi': - case 'fo': - case 'fur': - case 'fy': - case 'gl': - case 'gu': - case 'ha': - case 'he': - case 'hu': - case 'is': - case 'it': - case 'ku': - case 'lb': - case 'ml': - case 'mn': - case 'mr': - case 'nah': - case 'nb': - case 'ne': - case 'nl': - case 'nn': - case 'no': - case 'oc': - case 'om': - case 'or': - case 'pa': - case 'pap': - case 'ps': - case 'pt': - case 'so': - case 'sq': - case 'sv': - case 'sw': - case 'ta': - case 'te': - case 'tk': - case 'ur': - case 'zu': - return (number === 1) ? 0 : 1 - - case 'am': - case 'bh': - case 'fil': - case 'fr': - case 'gun': - case 'hi': - case 'hy': - case 'ln': - case 'mg': - case 'nso': - case 'xbr': - case 'ti': - case 'wa': - return ((number === 0) || (number === 1)) ? 0 : 1 - - case 'be': - case 'bs': - case 'hr': - case 'ru': - case 'sh': - case 'sr': - case 'uk': - return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2) - - case 'cs': - case 'sk': - return (number === 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2) - - case 'ga': - return (number === 1) ? 0 : ((number === 2) ? 1 : 2) - - case 'lt': - return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2) - - case 'sl': - return (number % 100 === 1) ? 0 : ((number % 100 === 2) ? 1 : (((number % 100 === 3) || (number % 100 === 4)) ? 2 : 3)) - - case 'mk': - return (number % 10 === 1) ? 0 : 1 - - case 'mt': - return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3)) - - case 'lv': - return (number === 0) ? 0 : (((number % 10 === 1) && (number % 100 !== 11)) ? 1 : 2) - - case 'pl': - return (number === 1) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2) - - case 'cy': - return (number === 1) ? 0 : ((number === 2) ? 1 : (((number === 8) || (number === 11)) ? 2 : 3)) - - case 'ro': - return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2) - - case 'ar': - return (number === 0) ? 0 : ((number === 1) ? 1 : ((number === 2) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5)))) - - default: - return 0 - } - }, + translatePlural, } export default L10n -/** - * Returns the user's locale - * - * @returns {String} locale string - */ -export const getLocale = () => $('html').data('locale') ?? 'en' - -/** - * Returns the user's language - * - * @returns {String} language string - */ -export const getLanguage = () => $('html').prop('lang') - Handlebars.registerHelper('t', function(app, text) { - return L10n.translate(app, text) + return translate(app, text) }) diff --git a/core/src/OC/legacy-loader.js b/core/src/OC/legacy-loader.js deleted file mode 100644 index 273ba3e359f..00000000000 --- a/core/src/OC/legacy-loader.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import $ from 'jquery' -import { generateFilePath } from '@nextcloud/router' - -const loadedScripts = {} -const loadedStyles = [] - -/** - * Load a script for the server and load it. If the script is already loaded, - * the event handler will be called directly - * @param {string} app the app id to which the script belongs - * @param {string} script the filename of the script - * @param {Function} ready event handler to be called when the script is loaded - * @returns {jQuery.Deferred} - * @deprecated 16.0.0 Use OCP.Loader.loadScript - */ -export const addScript = (app, script, ready) => { - console.warn('OC.addScript is deprecated, use OCP.Loader.loadScript instead') - - let deferred - const path = generateFilePath(app, 'js', script + '.js') - if (!loadedScripts[path]) { - deferred = $.Deferred() - $.getScript(path, () => deferred.resolve()) - loadedScripts[path] = deferred - } else { - if (ready) { - ready() - } - } - return loadedScripts[path] -} - -/** - * Loads a CSS file - * @param {string} app the app id to which the css style belongs - * @param {string} style the filename of the css file - * @deprecated 16.0.0 Use OCP.Loader.loadStylesheet - */ -export const addStyle = (app, style) => { - console.warn('OC.addStyle is deprecated, use OCP.Loader.loadStylesheet instead') - - const path = generateFilePath(app, 'css', style + '.css') - if (loadedStyles.indexOf(path) === -1) { - loadedStyles.push(path) - if (document.createStyleSheet) { - document.createStyleSheet(path) - } else { - style = $('<link rel="stylesheet" type="text/css" href="' + path + '"/>') - $('head').append(style) - } - } -} diff --git a/core/src/OC/menu.js b/core/src/OC/menu.js index 82cde9e862d..4b4eb658592 100644 --- a/core/src/OC/menu.js +++ b/core/src/OC/menu.js @@ -1,28 +1,13 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' +/** @typedef {import('jquery')} jQuery */ import $ from 'jquery' -import { menuSpeed } from './constants' +import { menuSpeed } from './constants.js' export let currentMenu = null export let currentMenuToggle = null @@ -32,9 +17,9 @@ export let currentMenuToggle = null * * @param {jQuery} $toggle the toggle element * @param {jQuery} $menuEl the menu container element - * @param {function|undefined} toggle callback invoked everytime the menu is opened + * @param {Function | undefined} toggle callback invoked everytime the menu is opened * @param {boolean} headerMenu is this a top right header menu? - * @returns {undefined} + * @return {void} */ export const registerMenu = function($toggle, $menuEl, toggle, headerMenu) { $menuEl.addClass('menu') @@ -107,6 +92,9 @@ export const hideMenus = function(complete) { // Set menu to closed $('.menutoggle').attr('aria-expanded', false) + if (currentMenuToggle) { + currentMenuToggle.attr('aria-expanded', false) + } $('.openedMenu').removeClass('openedMenu') currentMenu = null @@ -116,8 +104,8 @@ export const hideMenus = function(complete) { /** * Shows a given element as menu * - * @param {Object} [$toggle=null] menu toggle - * @param {Object} $menuEl menu element + * @param {object} [$toggle] menu toggle + * @param {object} $menuEl menu element * @param {Function} complete callback when the showing animation is done */ export const showMenu = ($toggle, $menuEl, complete) => { diff --git a/core/src/OC/msg.js b/core/src/OC/msg.js index 9be2b7fd322..655631a03ff 100644 --- a/core/src/OC/msg.js +++ b/core/src/OC/msg.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -32,7 +16,7 @@ export default { /** * Displayes a "Saving..." message in the given message placeholder * - * @param {Object} selector Placeholder to display the message in + * @param {object} selector Placeholder to display the message in */ startSaving(selector) { this.startAction(selector, t('core', 'Saving …')) @@ -41,7 +25,7 @@ export default { /** * Displayes a custom message in the given message placeholder * - * @param {Object} selector Placeholder to display the message in + * @param {object} selector Placeholder to display the message in * @param {string} message Plain text message to display (no HTML allowed) */ startAction(selector, message) { @@ -55,9 +39,9 @@ export default { /** * Displayes an success/error message in the given selector * - * @param {Object} selector Placeholder to display the message in - * @param {Object} response Response of the server - * @param {Object} response.data Data of the servers response + * @param {object} selector Placeholder to display the message in + * @param {object} response Response of the server + * @param {object} response.data Data of the servers response * @param {string} response.data.message Plain text message to display (no HTML allowed) * @param {string} response.status is being used to decide whether the message * is displayed as an error/success @@ -69,9 +53,9 @@ export default { /** * Displayes an success/error message in the given selector * - * @param {Object} selector Placeholder to display the message in - * @param {Object} response Response of the server - * @param {Object} response.data Data of the servers response + * @param {object} selector Placeholder to display the message in + * @param {object} response Response of the server + * @param {object} response.data Data of the servers response * @param {string} response.data.message Plain text message to display (no HTML allowed) * @param {string} response.status is being used to decide whether the message * is displayed as an error/success @@ -87,7 +71,7 @@ export default { /** * Displayes an success message in the given selector * - * @param {Object} selector Placeholder to display the message in + * @param {object} selector Placeholder to display the message in * @param {string} message Plain text success message to display (no HTML allowed) */ finishedSuccess(selector, message) { @@ -103,7 +87,7 @@ export default { /** * Displayes an error message in the given selector * - * @param {Object} selector Placeholder to display the message in + * @param {object} selector Placeholder to display the message in * @param {string} message Plain text error message to display (no HTML allowed) */ finishedError(selector, message) { diff --git a/core/src/OC/navigation.js b/core/src/OC/navigation.js index f9e6789950a..b279b9a60f3 100644 --- a/core/src/OC/navigation.js +++ b/core/src/OC/navigation.js @@ -1,33 +1,13 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -/** - * Redirect to the target URL, can also be used for downloads. - * @param {string} targetURL URL to redirect to - * @deprecated 17.0.0 use window.location directly - */ export const redirect = targetURL => { window.location = targetURL } /** * Reloads the current page + * * @deprecated 17.0.0 use window.location.reload directly */ export const reload = () => { window.location.reload() } diff --git a/core/src/OC/notification.js b/core/src/OC/notification.js index 8b7e43373a6..b658f4163bb 100644 --- a/core/src/OC/notification.js +++ b/core/src/OC/notification.js @@ -1,25 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' +/** @typedef {import('jquery')} jQuery */ import $ from 'jquery' import { showMessage, TOAST_DEFAULT_TIMEOUT, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' @@ -89,10 +74,10 @@ export default { * Consider using show() instead of showHTML() * * @param {string} html Message to display - * @param {Object} [options] options + * @param {object} [options] options * @param {string} [options.type] notification type - * @param {int} [options.timeout=0] timeout value, defaults to 0 (permanent) - * @returns {jQuery} jQuery element for notification row + * @param {number} [options.timeout] timeout value, defaults to 0 (permanent) + * @return {jQuery} jQuery element for notification row * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ showHtml(html, options) { @@ -108,10 +93,10 @@ export default { * Shows a sanitized notification * * @param {string} text Message to display - * @param {Object} [options] options + * @param {object} [options] options * @param {string} [options.type] notification type - * @param {int} [options.timeout=0] timeout value, defaults to 0 (permanent) - * @returns {jQuery} jQuery element for notification row + * @param {number} [options.timeout] timeout value, defaults to 0 (permanent) + * @return {jQuery} jQuery element for notification row * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ show(text, options) { @@ -135,7 +120,7 @@ export default { * Updates (replaces) a sanitized notification. * * @param {string} text Message to display - * @returns {jQuery} JQuery element for notificaiton row + * @return {jQuery} JQuery element for notification row * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ showUpdate(text) { @@ -152,11 +137,11 @@ export default { * 7 seconds * * @param {string} text Message to show - * @param {array} [options] options array - * @param {int} [options.timeout=7] timeout in seconds, if this is 0 it will show the message permanently - * @param {boolean} [options.isHTML=false] an indicator for HTML notifications (true) or text (false) + * @param {Array} [options] options array + * @param {number} [options.timeout] timeout in seconds, if this is 0 it will show the message permanently + * @param {boolean} [options.isHTML] an indicator for HTML notifications (true) or text (false) * @param {string} [options.type] notification type - * @returns {JQuery<any>} the toast element + * @return {jQuery} the toast element * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ showTemporary(text, options) { @@ -169,7 +154,8 @@ export default { /** * Returns whether a notification is hidden. - * @returns {boolean} + * + * @return {boolean} * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ isHidden() { diff --git a/core/src/OC/password-confirmation.js b/core/src/OC/password-confirmation.js index 77977f1efbd..621f7a0695f 100644 --- a/core/src/OC/password-confirmation.js +++ b/core/src/OC/password-confirmation.js @@ -1,127 +1,26 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import _ from 'underscore' -import $ from 'jquery' -import moment from 'moment' -import { generateUrl } from '@nextcloud/router' - -import OC from './index' +import { confirmPassword, isPasswordConfirmationRequired } from '@nextcloud/password-confirmation' +import '@nextcloud/password-confirmation/dist/style.css' /** * @namespace OC.PasswordConfirmation */ export default { - callback: null, - - pageLoadTime: null, - - init() { - $('.password-confirm-required').on('click', _.bind(this.requirePasswordConfirmation, this)) - this.pageLoadTime = moment.now() - }, requiresPasswordConfirmation() { - const serverTimeDiff = this.pageLoadTime - (window.nc_pageLoad * 1000) - const timeSinceLogin = moment.now() - (serverTimeDiff + (window.nc_lastLogin * 1000)) - - // if timeSinceLogin > 30 minutes and user backend allows password confirmation - return (window.backendAllowsPasswordConfirmation && timeSinceLogin > 30 * 60 * 1000) + return isPasswordConfirmationRequired() }, /** * @param {Function} callback success callback function - * @param {Object} options options + * @param {object} options options currently not used by confirmPassword * @param {Function} rejectCallback error callback function */ requirePasswordConfirmation(callback, options, rejectCallback) { - options = typeof options !== 'undefined' ? options : {} - const defaults = { - title: t('core', 'Authentication required'), - text: t( - 'core', - 'This action requires you to confirm your password' - ), - confirm: t('core', 'Confirm'), - label: t('core', 'Password'), - error: '', - } - - const config = _.extend(defaults, options) - - const self = this - - if (this.requiresPasswordConfirmation()) { - OC.dialogs.prompt( - config.text, - config.title, - function(result, password) { - if (result && password !== '') { - self._confirmPassword(password, config) - } else if (_.isFunction(rejectCallback)) { - rejectCallback() - } - }, - true, - config.label, - true - ).then(function() { - const $dialog = $('.oc-dialog:visible') - $dialog.find('.ui-icon').remove() - $dialog.addClass('password-confirmation') - if (config.error !== '') { - const $error = $('<p></p>').addClass('msg warning').text(config.error) - $dialog.find('.oc-dialog-content').append($error) - } - $dialog.find('.oc-dialog-buttonrow').addClass('aside') - - const $buttons = $dialog.find('button') - $buttons.eq(0).hide() - $buttons.eq(1).text(config.confirm) - }) - } - - this.callback = callback - }, - - _confirmPassword(password, config) { - const self = this - - $.ajax({ - url: generateUrl('/login/confirm'), - data: { - password, - }, - type: 'POST', - success(response) { - window.nc_lastLogin = response.lastLogin - - if (_.isFunction(self.callback)) { - self.callback() - } - }, - error() { - config.error = t('core', 'Failed to authenticate, try again') - OC.PasswordConfirmation.requirePasswordConfirmation(self.callback, config) - }, - }) + confirmPassword().then(callback, rejectCallback) }, } diff --git a/core/src/OC/plugins.js b/core/src/OC/plugins.js index 27c5e6acb2c..8212fc0b4ee 100644 --- a/core/src/OC/plugins.js +++ b/core/src/OC/plugins.js @@ -1,38 +1,19 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - /** - * @namespace OC.Plugins + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + export default { /** - * @type Array.<OC.Plugin> + * @type {Array.<OC.Plugin>} */ _plugins: {}, /** * Register plugin * - * @param {String} targetName app name / class name to hook into + * @param {string} targetName app name / class name to hook into * @param {OC.Plugin} plugin plugin */ register(targetName, plugin) { @@ -47,8 +28,8 @@ export default { * Returns all plugin registered to the given target * name / app name / class name. * - * @param {String} targetName app name / class name to hook into - * @returns {Array.<OC.Plugin>} array of plugins + * @param {string} targetName app name / class name to hook into + * @return {Array.<OC.Plugin>} array of plugins */ getPlugins(targetName) { return this._plugins[targetName] || [] @@ -57,9 +38,9 @@ export default { /** * Call attach() on all plugins registered to the given target name. * - * @param {String} targetName app name / class name - * @param {Object} targetObject to be extended - * @param {Object} [options] options + * @param {string} targetName app name / class name + * @param {object} targetObject to be extended + * @param {object} [options] options */ attach(targetName, targetObject, options) { const plugins = this.getPlugins(targetName) @@ -73,9 +54,9 @@ export default { /** * Call detach() on all plugins registered to the given target name. * - * @param {String} targetName app name / class name - * @param {Object} targetObject to be extended - * @param {Object} [options] options + * @param {string} targetName app name / class name + * @param {object} targetObject to be extended + * @param {object} [options] options */ detach(targetName, targetObject, options) { const plugins = this.getPlugins(targetName) diff --git a/core/src/OC/query-string.js b/core/src/OC/query-string.js index 33a51505ae8..df0f366133a 100644 --- a/core/src/OC/query-string.js +++ b/core/src/OC/query-string.js @@ -1,30 +1,15 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' /** * Parses a URL query string into a JS map + * * @param {string} queryString query string in the format param1=1234¶m2=abcde¶m3=xyz - * @returns {Object.<string, string>} map containing key/values matching the URL parameters + * @return {Record<string, string>} map containing key/values matching the URL parameters */ export const parse = queryString => { let pos @@ -72,8 +57,9 @@ export const parse = queryString => { /** * Builds a URL query from a JS map. - * @param {Object.<string, string>} params map containing key/values matching the URL parameters - * @returns {string} String containing a URL query (without question) mark + * + * @param {Record<string, string>} params map containing key/values matching the URL parameters + * @return {string} String containing a URL query (without question) mark */ export const build = params => { if (!params) { diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js deleted file mode 100644 index 6c011879b8b..00000000000 --- a/core/src/OC/requesttoken.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import { emit } from '@nextcloud/event-bus' - -/** - * @private - * @param {Document} global the document to read the initial value from - * @param {Function} emit the function to invoke for every new token - * @returns {Object} - */ -export const manageToken = (global, emit) => { - let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken') - - return { - getToken: () => token, - setToken: newToken => { - token = newToken - - emit('csrf-token-update', { - token, - }) - }, - } -} - -const manageFromDocument = manageToken(document, emit) - -/** - * @returns {string} - */ -export const getToken = manageFromDocument.getToken - -/** - * @param {String} newToken new token - */ -export const setToken = manageFromDocument.setToken diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts new file mode 100644 index 00000000000..8ecf0b3de7e --- /dev/null +++ b/core/src/OC/requesttoken.ts @@ -0,0 +1,49 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' + +/** + * Get the current CSRF token. + */ +export function getRequestToken(): string { + return document.head.dataset.requesttoken! +} + +/** + * Set a new CSRF token (e.g. because of session refresh). + * This also emits an event bus event for the updated token. + * + * @param token - The new token + * @fires Error - If the passed token is not a potential valid token + */ +export function setRequestToken(token: string): void { + if (!token || typeof token !== 'string') { + throw new Error('Invalid CSRF token given', { cause: { token } }) + } + + document.head.dataset.requesttoken = token + emit('csrf-token-update', { token }) +} + +/** + * Fetch the request token from the API. + * This does also set it on the current context, see `setRequestToken`. + * + * @fires Error - If the request failed + */ +export async function fetchRequestToken(): Promise<string> { + const url = generateUrl('/csrftoken') + + const response = await fetch(url) + if (!response.ok) { + throw new Error('Could not fetch CSRF token from API', { cause: response }) + } + + const { token } = await response.json() + setRequestToken(token) + return token +} diff --git a/core/src/OC/routing.js b/core/src/OC/routing.js index 7895b958c81..4b81714d6f0 100644 --- a/core/src/OC/routing.js +++ b/core/src/OC/routing.js @@ -1,22 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { @@ -25,8 +9,9 @@ import { /** * Creates a relative url for remote use + * * @param {string} service id - * @returns {string} the url + * @return {string} the url */ export const linkToRemoteBase = service => { return realGetRootUrl() + '/remote.php/' + service diff --git a/core/src/OC/theme.js b/core/src/OC/theme.js index 8e49499f485..af45c37de7e 100644 --- a/core/src/OC/theme.js +++ b/core/src/OC/theme.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const theme = window._theme || {} diff --git a/core/src/OC/util-history.js b/core/src/OC/util-history.js index 54019b804c2..7ecd0e098c6 100644 --- a/core/src/OC/util-history.js +++ b/core/src/OC/util-history.js @@ -1,26 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' -import OC from './index' +import OC from './index.js' /** * Utility class for the history API, @@ -39,11 +23,11 @@ export default { * Note: this includes a workaround for IE8/IE9 that uses * the hash part instead of the search part. * - * @param {Object|string} params to append to the URL, can be either a string + * @param {object | string} params to append to the URL, can be either a string * or a map * @param {string} [url] URL to be used, otherwise the current URL will be used, * using the params as query string - * @param {boolean} [replace=false] whether to replace instead of pushing + * @param {boolean} [replace] whether to replace instead of pushing */ _pushState(params, url, replace) { let strParams @@ -90,7 +74,7 @@ export default { * Note: this includes a workaround for IE8/IE9 that uses * the hash part instead of the search part. * - * @param {Object|string} params to append to the URL, can be either a string or a map + * @param {object | string} params to append to the URL, can be either a string or a map * @param {string} [url] URL to be used, otherwise the current URL will be used, using the params as query string */ pushState(params, url) { @@ -103,7 +87,7 @@ export default { * Note: this includes a workaround for IE8/IE9 that uses * the hash part instead of the search part. * - * @param {Object|string} params to append to the URL, can be either a string + * @param {object | string} params to append to the URL, can be either a string * or a map * @param {string} [url] URL to be used, otherwise the current URL will be used, * using the params as query string @@ -124,7 +108,8 @@ export default { /** * Parse a query string from the hash part of the URL. * (workaround for IE8 / IE9) - * @returns {string} + * + * @return {string} */ _parseHashQuery() { const hash = window.location.hash @@ -147,7 +132,7 @@ export default { * Parse the query/search part of the URL. * Also try and parse it from the URL hash (for IE8) * - * @returns {Object} map of parameters + * @return {object} map of parameters */ parseUrlQuery() { const query = this._parseHashQuery() diff --git a/core/src/OC/util.js b/core/src/OC/util.js index 611c42d317f..c46d9a141b1 100644 --- a/core/src/OC/util.js +++ b/core/src/OC/util.js @@ -1,31 +1,17 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import $ from 'jquery' import moment from 'moment' -import History from './util-history' -import OC from './index' +import History from './util-history.js' +import OC from './index.js' import { formatFileSize as humanFileSize } from '@nextcloud/files' +/** + * @param {any} t - + */ function chunkify(t) { // Adapted from http://my.opera.com/GreyWyvern/blog/show.dml/1671288 const tz = [] @@ -52,6 +38,7 @@ function chunkify(t) { /** * Utility functions + * * @namespace OC.Util */ export default { @@ -59,7 +46,7 @@ export default { History, /** - * @deprecated use https://nextcloud.github.io/nextcloud-files/modules/_humanfilesize_.html#formatfilesize + * @deprecated use https://nextcloud.github.io/nextcloud-files/functions/formatFileSize.html */ humanFileSize, @@ -67,8 +54,9 @@ export default { * Returns a file size in bytes from a humanly readable string * Makes 2kB to 2048. * Inspired by computerFileSize in helper.php - * @param {string} string file size in human readable format - * @returns {number} or null if string could not be parsed + * + * @param {string} string file size in human-readable format + * @return {number} or null if string could not be parsed * * */ @@ -114,11 +102,11 @@ export default { /** * @param {string|number} timestamp timestamp * @param {string} format date format, see momentjs docs - * @returns {string} timestamp formatted as requested + * @return {string} timestamp formatted as requested */ formatDate(timestamp, format) { if (window.TESTING === undefined) { - console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') + OC.debug && console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') } format = format || 'LLL' return moment(timestamp).format(format) @@ -126,11 +114,11 @@ export default { /** * @param {string|number} timestamp timestamp - * @returns {string} human readable difference from now + * @return {string} human readable difference from now */ relativeModifiedDate(timestamp) { if (window.TESTING === undefined) { - console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') + OC.debug && console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') } const diff = moment().diff(moment(timestamp)) if (diff >= 0 && diff < 45000) { @@ -140,18 +128,9 @@ export default { }, /** - * Returns whether this is IE - * - * @returns {bool} true if this is IE, false otherwise - */ - isIE() { - return $('html').hasClass('ie') - }, - - /** * Returns the width of a generic browser scrollbar * - * @returns {int} width of scrollbar + * @return {number} width of scrollbar */ getScrollBarWidth() { if (this._scrollBarWidth) { @@ -191,7 +170,7 @@ export default { * Remove the time component from a given date * * @param {Date} date date - * @returns {Date} date with stripped time + * @return {Date} date with stripped time */ stripTime(date) { // FIXME: likely to break when crossing DST @@ -201,9 +180,10 @@ export default { /** * Compare two strings to provide a natural sort + * * @param {string} a first string to compare * @param {string} b second string to compare - * @returns {number} -1 if b comes before a, 1 if a comes before b + * @return {number} -1 if b comes before a, 1 if a comes before b * or 0 if the strings are identical */ naturalSortCompare(a, b) { @@ -230,8 +210,9 @@ export default { /** * Calls the callback in a given interval until it returns true - * @param {function} callback function to call on success - * @param {integer} interval in milliseconds + * + * @param {Function} callback function to call on success + * @param {number} interval in milliseconds */ waitFor(callback, interval) { const internalCallback = function() { @@ -245,9 +226,10 @@ export default { /** * Checks if a cookie with the given name is present and is set to the provided value. + * * @param {string} name name of the cookie * @param {string} value value of the cookie - * @returns {boolean} true if the cookie with the given name has the given value + * @return {boolean} true if the cookie with the given name has the given value */ isCookieSetToValue(name, value) { const cookies = document.cookie.split(';') diff --git a/core/src/OC/webroot.js b/core/src/OC/webroot.js index 89c04a6bb07..cbe5a6190e1 100644 --- a/core/src/OC/webroot.js +++ b/core/src/OC/webroot.js @@ -1,22 +1,6 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ let webroot = window._oc_webroot diff --git a/core/src/OC/xhr-error.js b/core/src/OC/xhr-error.js index 8d2ad115336..233aaf60350 100644 --- a/core/src/OC/xhr-error.js +++ b/core/src/OC/xhr-error.js @@ -1,55 +1,42 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' import $ from 'jquery' -import OC from './index' -import Notification from './notification' +import OC from './index.js' +import Notification from './notification.js' +import { getCurrentUser } from '@nextcloud/auth' +import { showWarning } from '@nextcloud/dialogs' /** * Warn users that the connection to the server was lost temporarily * - * This function is throttled to prevent stacked notfications. + * This function is throttled to prevent stacked notifications. * After 7sec the first notification is gone, then we can show another one * if necessary. */ export const ajaxConnectionLostHandler = _.throttle(() => { - Notification.showTemporary(t('core', 'Connection to server lost')) + showWarning(t('core', 'Connection to server lost')) }, 7 * 1000, { trailing: false }) /** * Process ajax error, redirects to main page * if an error/auth error status was returned. + * * @param {XMLHttpRequest} xhr xhr request */ export const processAjaxError = xhr => { // purposefully aborted request ? - // OC._userIsNavigatingAway needed to distinguish ajax calls cancelled by navigating away - // from calls cancelled by failed cross-domain ajax due to SSO redirect + // OC._userIsNavigatingAway needed to distinguish Ajax calls cancelled by navigating away + // from calls cancelled by failed cross-domain Ajax due to SSO redirect if (xhr.status === 0 && (xhr.statusText === 'abort' || xhr.statusText === 'timeout' || OC._reloadCalled)) { return } - if (_.contains([302, 303, 307, 401], xhr.status) && OC.currentUser) { + if ([302, 303, 307, 401].includes(xhr.status) && getCurrentUser()) { // sometimes "beforeunload" happens later, so need to defer the reload a bit setTimeout(function() { if (!OC._userIsNavigatingAway && !OC._reloadCalled) { @@ -62,7 +49,7 @@ export const processAjaxError = xhr => { OC.reload() } timer++ - }, 1000 // 1 second interval + }, 1000, // 1 second interval ) // only call reload once diff --git a/core/src/OCA/index.js b/core/src/OCA/index.js index 8cf5e6efeb4..cf5c29ce60a 100644 --- a/core/src/OCA/index.js +++ b/core/src/OCA/index.js @@ -1,33 +1,11 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import Search from './search' - /** * Namespace for apps + * * @namespace OCA */ -export default { - /** - * @deprecated 20.0.0, will be removed in Nextcloud 22 - */ - Search, -} +export default { } diff --git a/core/src/OCA/search.js b/core/src/OCA/search.js deleted file mode 100644 index f3eba1247f9..00000000000 --- a/core/src/OCA/search.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -/** - * @deprecated 20.0.0, will be removed in Nextcloud 22 - */ -export default class Search { - - /** - * @deprecated 20.0.0, will be removed in Nextcloud 22 - */ - constructor() { - console.warn('OCA.Search is deprecated. Please use the unified search API instead') - } - -} diff --git a/core/src/OCP/accessibility.js b/core/src/OCP/accessibility.js new file mode 100644 index 00000000000..4a1399f3f96 --- /dev/null +++ b/core/src/OCP/accessibility.js @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' + +/** + * Set the page heading + * + * @param {string} heading page title from the history api + * @since 27.0.0 + */ +export function setPageHeading(heading) { + const headingEl = document.getElementById('page-heading-level-1') + if (headingEl) { + headingEl.textContent = heading + } +} +export default { + /** + * @return {boolean} Whether the user opted-out of shortcuts so that they should not be registered + */ + disableKeyboardShortcuts() { + return loadState('theming', 'shortcutsDisabled', false) + }, + setPageHeading, +} diff --git a/core/src/OCP/appconfig.js b/core/src/OCP/appconfig.js index 78ee1643878..78f94922d53 100644 --- a/core/src/OCP/appconfig.js +++ b/core/src/OCP/appconfig.js @@ -1,35 +1,21 @@ /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' +import { generateOcsUrl } from '@nextcloud/router' -import OC from '../OC/index' +import OC from '../OC/index.js' /** * @param {string} method 'post' or 'delete' * @param {string} endpoint endpoint - * @param {Object} [options] destructuring object - * @param {Object} [options.data] option data - * @param {function} [options.success] success callback - * @param {function} [options.error] error callback - * @internal + * @param {object} [options] destructuring object + * @param {object} [options.data] option data + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback */ function call(method, endpoint, options) { if ((method === 'post' || method === 'delete') && OC.PasswordConfirmation.requiresPasswordConfirmation()) { @@ -40,7 +26,7 @@ function call(method, endpoint, options) { options = options || {} $.ajax({ type: method.toUpperCase(), - url: OC.linkToOCS('apps/provisioning_api/api/v1', 2) + 'config/apps' + endpoint, + url: generateOcsUrl('apps/provisioning_api/api/v1/config/apps') + endpoint, data: options.data || {}, success: options.success, error: options.error, @@ -48,8 +34,8 @@ function call(method, endpoint, options) { } /** - * @param {Object} [options] destructuring object - * @param {function} [options.success] success callback + * @param {object} [options] destructuring object + * @param {Function} [options.success] success callback * @since 11.0.0 */ export function getApps(options) { @@ -58,9 +44,9 @@ export function getApps(options) { /** * @param {string} app app id - * @param {Object} [options] destructuring object - * @param {function} [options.success] success callback - * @param {function} [options.error] error callback + * @param {object} [options] destructuring object + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback * @since 11.0.0 */ export function getKeys(app, options) { @@ -70,10 +56,10 @@ export function getKeys(app, options) { /** * @param {string} app app id * @param {string} key key - * @param {string|function} defaultValue default value - * @param {Object} [options] destructuring object - * @param {function} [options.success] success callback - * @param {function} [options.error] error callback + * @param {string | Function} defaultValue default value + * @param {object} [options] destructuring object + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback * @since 11.0.0 */ export function getValue(app, key, defaultValue, options) { @@ -89,9 +75,9 @@ export function getValue(app, key, defaultValue, options) { * @param {string} app app id * @param {string} key key * @param {string} value value - * @param {Object} [options] destructuring object - * @param {function} [options.success] success callback - * @param {function} [options.error] error callback + * @param {object} [options] destructuring object + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback * @since 11.0.0 */ export function setValue(app, key, value, options) { @@ -106,9 +92,9 @@ export function setValue(app, key, value, options) { /** * @param {string} app app id * @param {string} key key - * @param {Object} [options] destructuring object - * @param {function} [options.success] success callback - * @param {function} [options.error] error callback + * @param {object} [options] destructuring object + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback * @since 11.0.0 */ export function deleteKey(app, key, options) { diff --git a/core/src/OCP/collaboration.js b/core/src/OCP/collaboration.js index 73573b3f1f7..82ff34392cf 100644 --- a/core/src/OCP/collaboration.js +++ b/core/src/OCP/collaboration.js @@ -1,43 +1,27 @@ /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import escapeHTML from 'escape-html' /** * @typedef TypeDefinition - * @method {callback} action This action is executed to let the user select a resource + * @function action This action is executed to let the user select a resource * @param {string} icon Contains the icon css class for the type - * @constructor + * @function Object() { [native code] } */ /** * @type {TypeDefinition[]} - **/ + */ const types = {} /** * Those translations will be used by the vue component but they should be shipped with the server * FIXME: Those translations should be added to the library - * @returns {Array} + * + * @return {Array} */ export const l10nProjects = () => { return [ diff --git a/core/src/OCP/comments.js b/core/src/OCP/comments.js index 2e12accddce..34699a477d1 100644 --- a/core/src/OCP/comments.js +++ b/core/src/OCP/comments.js @@ -1,10 +1,6 @@ /** - * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -12,22 +8,33 @@ import $ from 'jquery' /* * Detects links: * Either the http(s) protocol is given or two strings, basically limited to ascii with the last - * word being at least one digit long, + * word being at least one digit long, * followed by at least another character * * The downside: anything not ascii is excluded. Not sure how common it is in areas using different * alphabets… the upside: fake domains with similar looking characters won't be formatted as links + * + * This is a copy of the backend regex in IURLGenerator, make sure to adjust both when changing */ -const urlRegex = /(\s|^)(https?:\/\/)?((?:[-A-Z0-9+_]+\.)+[-A-Z]+(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig +const urlRegex = /(\s|^)(https?:\/\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig +/** + * @param {any} content - + */ export function plainToRich(content) { return this.formatLinksRich(content) } +/** + * @param {any} content - + */ export function richToPlain(content) { return this.formatLinksPlain(content) } +/** + * @param {any} content - + */ export function formatLinksRich(content) { return content.replace(urlRegex, function(_, leadingSpace, protocol, url, trailingSpace) { let linkText = url @@ -41,6 +48,9 @@ export function formatLinksRich(content) { }) } +/** + * @param {any} content - + */ export function formatLinksPlain(content) { const $content = $('<div></div>').html(content) $content.find('a').each(function() { diff --git a/core/src/OCP/index.js b/core/src/OCP/index.js index 4f2c47f1fa4..94f4e8e5eb3 100644 --- a/core/src/OCP/index.js +++ b/core/src/OCP/index.js @@ -1,13 +1,22 @@ -import * as AppConfig from './appconfig' -import * as Comments from './comments' -import Loader from './loader' +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + import { loadState } from '@nextcloud/initial-state' -import Collaboration from './collaboration' -import * as WhatsNew from './whatsnew' -import Toast from './toast' + +import * as AppConfig from './appconfig.js' +import * as Comments from './comments.js' +import * as WhatsNew from './whatsnew.js' + +import Accessibility from './accessibility.js' +import Collaboration from './collaboration.js' +import Loader from './loader.js' +import Toast from './toast.js' /** @namespace OCP */ export default { + Accessibility, AppConfig, Collaboration, Comments, diff --git a/core/src/OCP/loader.js b/core/src/OCP/loader.js index 26beaffca33..d307eb27996 100644 --- a/core/src/OCP/loader.js +++ b/core/src/OCP/loader.js @@ -1,25 +1,10 @@ /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { generateFilePath } from '@nextcloud/router' + const loadedScripts = {} const loadedStylesheets = {} /** @@ -33,7 +18,7 @@ export default { * * @param {string} app the app name * @param {string} file the script file name - * @returns {Promise} + * @return {Promise} */ loadScript(app, file) { const key = app + file @@ -42,7 +27,7 @@ export default { } loadedScripts[key] = true return new Promise(function(resolve, reject) { - const scriptPath = OC.filePath(app, 'js', file) + const scriptPath = generateFilePath(app, 'js', file) const script = document.createElement('script') script.src = scriptPath script.setAttribute('nonce', btoa(OC.requestToken)) @@ -57,7 +42,7 @@ export default { * * @param {string} app the app name * @param {string} file the script file name - * @returns {Promise} + * @return {Promise} */ loadStylesheet(app, file) { const key = app + file @@ -66,7 +51,7 @@ export default { } loadedStylesheets[key] = true return new Promise(function(resolve, reject) { - const stylePath = OC.filePath(app, 'css', file) + const stylePath = generateFilePath(app, 'css', file) const link = document.createElement('link') link.href = stylePath link.type = 'text/css' diff --git a/core/src/OCP/toast.js b/core/src/OCP/toast.js index e5331377dc9..f93344bbc8e 100644 --- a/core/src/OCP/toast.js +++ b/core/src/OCP/toast.js @@ -1,23 +1,6 @@ -/* - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { @@ -27,13 +10,15 @@ import { showWarning, } from '@nextcloud/dialogs' +/** @typedef {import('toastify-js')} Toast */ + export default { /** * @deprecated 19.0.0 use `showSuccess` from the `@nextcloud/dialogs` package instead * * @param {string} text the toast text * @param {object} options options - * @returns {Toast} + * @return {Toast} */ success(text, options) { return showSuccess(text, options) @@ -43,7 +28,7 @@ export default { * * @param {string} text the toast text * @param {object} options options - * @returns {Toast} + * @return {Toast} */ warning(text, options) { return showWarning(text, options) @@ -53,7 +38,7 @@ export default { * * @param {string} text the toast text * @param {object} options options - * @returns {Toast} + * @return {Toast} */ error(text, options) { return showError(text, options) @@ -63,7 +48,7 @@ export default { * * @param {string} text the toast text * @param {object} options options - * @returns {Toast} + * @return {Toast} */ info(text, options) { return showInfo(text, options) @@ -73,7 +58,7 @@ export default { * * @param {string} text the toast text * @param {object} options options - * @returns {Toast} + * @return {Toast} */ message(text, options) { return showMessage(text, options) diff --git a/core/src/OCP/whatsnew.js b/core/src/OCP/whatsnew.js index 17c9eeabce2..acada6a8383 100644 --- a/core/src/OCP/whatsnew.js +++ b/core/src/OCP/whatsnew.js @@ -1,23 +1,21 @@ /** - * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' import $ from 'jquery' +import { generateOcsUrl } from '@nextcloud/router' -import OC from '../OC/index' - +/** + * @param {any} options - + */ export function query(options) { options = options || {} const dismissOptions = options.dismiss || {} $.ajax({ type: 'GET', - url: options.url || OC.linkToOCS('core', 2) + 'whatsnew?format=json', + url: options.url || generateOcsUrl('core/whatsnew?format=json'), success: options.success || function(data, statusText, xhr) { onQuerySuccess(data, statusText, xhr, dismissOptions) }, @@ -25,11 +23,15 @@ export function query(options) { }) } +/** + * @param {any} version - + * @param {any} options - + */ export function dismiss(version, options) { options = options || {} $.ajax({ type: 'POST', - url: options.url || OC.linkToOCS('core', 2) + 'whatsnew', + url: options.url || generateOcsUrl('core/whatsnew'), data: { version: encodeURIComponent(version) }, success: options.success || onDismissSuccess, error: options.error || onDismissError, @@ -38,6 +40,12 @@ export function dismiss(version, options) { $('.whatsNewPopover').remove() } +/** + * @param {any} data - + * @param {any} statusText - + * @param {any} xhr - + * @param {any} dismissOptions - + */ function onQuerySuccess(data, statusText, xhr, dismissOptions) { console.debug('querying Whats New data was successful: ' + statusText) console.debug(data) @@ -118,15 +126,26 @@ function onQuerySuccess(data, statusText, xhr, dismissOptions) { document.body.appendChild(div) } +/** + * @param {any} x - + * @param {any} t - + * @param {any} e - + */ function onQueryError(x, t, e) { console.debug('querying Whats New Data resulted in an error: ' + t + e) console.debug(x) } +/** + * @param {any} data - + */ function onDismissSuccess(data) { // noop } +/** + * @param {any} data - + */ function onDismissError(data) { console.debug('dismissing Whats New data resulted in an error: ' + data) } diff --git a/core/src/Polyfill/closest.js b/core/src/Polyfill/closest.js deleted file mode 100644 index 6af05d526a7..00000000000 --- a/core/src/Polyfill/closest.js +++ /dev/null @@ -1,19 +0,0 @@ -// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill - -if (!Element.prototype.matches) { - Element.prototype.matches - = Element.prototype.msMatchesSelector - || Element.prototype.webkitMatchesSelector -} - -if (!Element.prototype.closest) { - Element.prototype.closest = function(s) { - let el = this - - do { - if (el.matches(s)) return el - el = el.parentElement || el.parentNode - } while (el !== null && el.nodeType === 1) - return null - } -} diff --git a/core/src/Polyfill/console.js b/core/src/Polyfill/console.js deleted file mode 100644 index 0d60fe6f20d..00000000000 --- a/core/src/Polyfill/console.js +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable no-console */ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -if (typeof console === 'undefined' || typeof console.log === 'undefined') { - if (!window.console) { - window.console = {} - } - const noOp = () => {} - const methods = ['log', 'debug', 'warn', 'info', 'error', 'assert', 'time', 'timeEnd'] - for (let i = 0; i < methods.length; i++) { - console[methods[i]] = noOp - } -} diff --git a/core/src/Polyfill/index.js b/core/src/Polyfill/index.js deleted file mode 100644 index 306c72a0777..00000000000 --- a/core/src/Polyfill/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import './console' -import './closest' -import './windows-phone' diff --git a/core/src/Polyfill/tooltip.js b/core/src/Polyfill/tooltip.js deleted file mode 100644 index 2dd7592edb4..00000000000 --- a/core/src/Polyfill/tooltip.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * @copyright 2019 Julius Härtl <jus@bitgrid.net> - * - * @author 2019 Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -$.prototype.tooltip = (function(tooltip) { - return function(config) { - try { - return tooltip.call(this, config) - } catch (ex) { - if (ex instanceof TypeError && config === 'destroy') { - if (window.TESTING === undefined) { - console.error('Deprecated call $.tooltip(\'destroy\') has been deprecated and should be removed') - } - return tooltip.call(this, 'dispose') - } - if (ex instanceof TypeError && config === 'fixTitle') { - if (window.TESTING === undefined) { - console.error('Deprecated call $.tooltip(\'fixTitle\') has been deprecated and should be removed') - } - return tooltip.call(this, '_fixTitle') - } - } - } -})($.prototype.tooltip) diff --git a/core/src/Polyfill/windows-phone.js b/core/src/Polyfill/windows-phone.js deleted file mode 100644 index 983e412e453..00000000000 --- a/core/src/Polyfill/windows-phone.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -// fix device width on windows phone -if ('-ms-user-select' in document.documentElement.style && navigator.userAgent.match(/IEMobile\/10\.0/)) { - const msViewportStyle = document.createElement('style') - msViewportStyle.appendChild( - document.createTextNode('@-ms-viewport{width:auto!important}') - ) - document.getElementsByTagName('head')[0].appendChild(msViewportStyle) -} diff --git a/core/src/Util/a11y.js b/core/src/Util/a11y.js new file mode 100644 index 00000000000..2eb753b3faf --- /dev/null +++ b/core/src/Util/a11y.js @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Return whether the DOM event is an accessible mouse or keyboard element activation + * + * @param {Event} event DOM event + * + * @return {boolean} + */ +export const isA11yActivation = (event) => { + if (event.type === 'click') { + return true + } + if (event.type === 'keydown' && event.key === 'Enter') { + return true + } + return false +} diff --git a/core/src/Util/get-url-parameter.js b/core/src/Util/get-url-parameter.js index 6f809994f10..6df264f009f 100644 --- a/core/src/Util/get-url-parameter.js +++ b/core/src/Util/get-url-parameter.js @@ -1,33 +1,14 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /** - * Get the value of a URL parameter - * @link http://stackoverflow.com/questions/1403888/get-url-parameter-with-jquery - * @param {string} name URL parameter - * @returns {string} + * @param {any} name - */ export default function getURLParameter(name) { return decodeURIComponent( // eslint-disable-next-line no-sparse-arrays - (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20') + (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20'), ) || '' } diff --git a/core/src/ajax-cron.ts b/core/src/ajax-cron.ts new file mode 100644 index 00000000000..d903a3596ea --- /dev/null +++ b/core/src/ajax-cron.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getRootUrl } from '@nextcloud/router' +import logger from './logger' + +window.addEventListener('DOMContentLoaded', async () => { + // When the page is loaded send GET to the cron endpoint to trigger background jobs + try { + logger.debug('Running web cron') + await window.fetch(`${getRootUrl()}/cron.php`) + logger.debug('Web cron successfull') + } catch (e) { + logger.debug('Running web cron failed', { error: e }) + } +}) diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue new file mode 100644 index 00000000000..d983226d273 --- /dev/null +++ b/core/src/components/AccountMenu/AccountMenuEntry.vue @@ -0,0 +1,117 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcListItem :id="href ? undefined : id" + :anchor-id="id" + :active="active" + class="account-menu-entry" + compact + :href="href" + :name="name" + target="_self" + @click="onClick"> + <template #icon> + <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" /> + <slot v-else-if="$scopedSlots.icon" name="icon" /> + <img v-else + class="account-menu-entry__icon" + :class="{ 'account-menu-entry__icon--active': active }" + :src="iconSource" + alt=""> + </template> + </NcListItem> +</template> + +<script lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { defineComponent } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +const versionHash = loadState('core', 'versionHash', '') + +export default defineComponent({ + name: 'AccountMenuEntry', + + components: { + NcListItem, + NcLoadingIcon, + }, + + props: { + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + active: { + type: Boolean, + default: false, + }, + icon: { + type: String, + default: '', + }, + }, + + data() { + return { + loading: false, + } + }, + + computed: { + iconSource() { + return `${this.icon}?v=${versionHash}` + }, + }, + + methods: { + onClick(e: MouseEvent) { + this.$emit('click', e) + + // Allow to not show the loading indicator + // in case the click event was already handled + if (!e.defaultPrevented) { + this.loading = true + } + }, + }, +}) +</script> + +<style lang="scss" scoped> +.account-menu-entry { + &__icon { + height: 16px; + width: 16px; + margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size + filter: var(--background-invert-if-dark); + + &--active { + filter: var(--primary-invert-if-dark); + } + } + + &__loading { + height: 20px; + width: 20px; + margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size + } + + :deep(.list-item-content__main) { + width: fit-content; + } +} +</style> diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue new file mode 100644 index 00000000000..8b895b8ca31 --- /dev/null +++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue @@ -0,0 +1,100 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcListItem :id="profileEnabled ? undefined : id" + :anchor-id="id" + :active="active" + compact + :href="profileEnabled ? href : undefined" + :name="displayName" + target="_self"> + <template v-if="profileEnabled" #subname> + {{ name }} + </template> + <template v-if="loading" #indicator> + <NcLoadingIcon /> + </template> + </NcListItem> +</template> + +<script lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { getCurrentUser } from '@nextcloud/auth' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { defineComponent } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false }) + +export default defineComponent({ + name: 'AccountMenuProfileEntry', + + components: { + NcListItem, + NcLoadingIcon, + }, + + props: { + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + + setup() { + return { + profileEnabled, + displayName: getCurrentUser()!.displayName, + } + }, + + data() { + return { + loading: false, + } + }, + + mounted() { + subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + subscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + methods: { + handleClick() { + if (this.profileEnabled) { + this.loading = true + } + }, + + handleProfileEnabledUpdate(profileEnabled: boolean) { + this.profileEnabled = profileEnabled + }, + + handleDisplayNameUpdate(displayName: string) { + this.displayName = displayName + }, + }, +}) +</script> diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue new file mode 100644 index 00000000000..88f626ff569 --- /dev/null +++ b/core/src/components/AppMenu.vue @@ -0,0 +1,161 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <nav ref="appMenu" + class="app-menu" + :aria-label="t('core', 'Applications menu')"> + <ul :aria-label="t('core', 'Apps')" + class="app-menu__list"> + <AppMenuEntry v-for="app in mainAppList" + :key="app.id" + :app="app" /> + </ul> + <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')"> + <NcActionLink v-for="app in popoverAppList" + :key="app.id" + :aria-current="app.active ? 'page' : false" + :href="app.href" + :icon="app.icon" + class="app-menu__overflow-entry"> + {{ app.name }} + </NcActionLink> + </NcActions> + </nav> +</template> + +<script lang="ts"> +import type { INavigationEntry } from '../types/navigation' + +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { n, t } from '@nextcloud/l10n' +import { useElementSize } from '@vueuse/core' +import { defineComponent, ref } from 'vue' + +import AppMenuEntry from './AppMenuEntry.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import logger from '../logger' + +export default defineComponent({ + name: 'AppMenu', + + components: { + AppMenuEntry, + NcActions, + NcActionLink, + }, + + setup() { + const appMenu = ref() + const { width: appMenuWidth } = useElementSize(appMenu) + return { + t, + n, + appMenu, + appMenuWidth, + } + }, + + data() { + const appList = loadState<INavigationEntry[]>('core', 'apps', []) + return { + appList, + } + }, + + computed: { + appLimit() { + const maxApps = Math.floor(this.appMenuWidth / 50) + if (maxApps < this.appList.length) { + // Ensure there is space for the overflow menu + return Math.max(maxApps - 1, 0) + } + return maxApps + }, + + mainAppList() { + return this.appList.slice(0, this.appLimit) + }, + + popoverAppList() { + return this.appList.slice(this.appLimit) + }, + }, + + mounted() { + subscribe('nextcloud:app-menu.refresh', this.setApps) + }, + + beforeDestroy() { + unsubscribe('nextcloud:app-menu.refresh', this.setApps) + }, + + methods: { + setNavigationCounter(id: string, counter: number) { + const app = this.appList.find(({ app }) => app === id) + if (app) { + this.$set(app, 'unread', counter) + } else { + logger.warn(`Could not find app "${id}" for setting navigation count`) + } + }, + + setApps({ apps }: { apps: INavigationEntry[]}) { + this.appList = apps + }, + }, +}) +</script> + +<style scoped lang="scss"> +.app-menu { + // The size the currently focussed entry will grow to show the full name + --app-menu-entry-growth: calc(var(--default-grid-baseline) * 4); + display: flex; + flex: 1 1; + width: 0; + + &__list { + display: flex; + flex-wrap: nowrap; + margin-inline: calc(var(--app-menu-entry-growth) / 2); + } + + &__overflow { + margin-block: auto; + + // Adjust the overflow NcActions styles as they are directly rendered on the background + :deep(.button-vue--vue-tertiary) { + opacity: .7; + margin: 3px; + filter: var(--background-image-invert-if-bright); + + /* Remove all background and align text color if not expanded */ + &:not([aria-expanded="true"]) { + color: var(--color-background-plain-text); + + &:hover { + opacity: 1; + background-color: transparent !important; + } + } + + &:focus-visible { + opacity: 1; + outline: none !important; + } + } + } + + &__overflow-entry { + :deep(.action-link__icon) { + // Icons are bright so invert them if bright color theme == bright background is used + filter: var(--background-invert-if-bright) !important; + } + } +} +</style> diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue new file mode 100644 index 00000000000..4c5acb7e9c8 --- /dev/null +++ b/core/src/components/AppMenuEntry.vue @@ -0,0 +1,189 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <li ref="containerElement" + class="app-menu-entry" + :class="{ + 'app-menu-entry--active': app.active, + 'app-menu-entry--truncated': needsSpace, + }"> + <a class="app-menu-entry__link" + :href="app.href" + :title="app.name" + :aria-current="app.active ? 'page' : false" + :target="app.target ? '_blank' : undefined" + :rel="app.target ? 'noopener noreferrer' : undefined"> + <AppMenuIcon class="app-menu-entry__icon" :app="app" /> + <span ref="labelElement" class="app-menu-entry__label"> + {{ app.name }} + </span> + </a> + </li> +</template> + +<script setup lang="ts"> +import type { INavigationEntry } from '../types/navigation' +import { onMounted, ref, watch } from 'vue' +import AppMenuIcon from './AppMenuIcon.vue' + +const props = defineProps<{ + app: INavigationEntry +}>() + +const containerElement = ref<HTMLLIElement>() +const labelElement = ref<HTMLSpanElement>() +const needsSpace = ref(false) + +/** Update the space requirements of the app label */ +function calculateSize() { + const maxWidth = containerElement.value!.clientWidth + // Also keep the 0.5px letter spacing in mind + needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth) +} +// Update size on mounted and when the app name changes +onMounted(calculateSize) +watch(() => props.app.name, calculateSize) +</script> + +<style scoped lang="scss"> +.app-menu-entry { + --app-menu-entry-font-size: 12px; + width: var(--header-height); + height: var(--header-height); + position: relative; + + &__link { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + // Set color as this is shown directly on the background + color: var(--color-background-plain-text); + // Make space for focus-visible outline + width: calc(100% - 4px); + height: calc(100% - 4px); + margin: 2px; + } + + &__label { + opacity: 0; + position: absolute; + font-size: var(--app-menu-entry-font-size); + // this is shown directly on the background + color: var(--color-background-plain-text); + text-align: center; + bottom: 0; + inset-inline-start: 50%; + top: 50%; + display: block; + transform: translateX(-50%); + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; + letter-spacing: -0.5px; + } + body[dir=rtl] &__label { + transform: translateX(50%) !important; + } + + &__icon { + font-size: var(--app-menu-entry-font-size); + } + + &--active { + // When hover or focus, show the label and make it bolder than the other entries + .app-menu-entry__label { + font-weight: bolder; + } + + // When active show a line below the entry as an "active" indicator + &::before { + content: " "; + position: absolute; + pointer-events: none; + border-bottom-color: var(--color-main-background); + transform: translateX(-50%); + width: 10px; + height: 5px; + border-radius: 3px; + background-color: var(--color-background-plain-text); + inset-inline-start: 50%; + bottom: 8px; + display: block; + transition: all var(--animation-quick) ease-in-out; + opacity: 1; + } + body[dir=rtl] &::before { + transform: translateX(50%) !important; + } + } + + &__icon, + &__label { + transition: all var(--animation-quick) ease-in-out; + } + + // Make the hovered entry bold to see that it is hovered + &:hover .app-menu-entry__label, + &:focus-within .app-menu-entry__label { + font-weight: bold; + } + + // Adjust the width when an entry is focussed + // The focussed / hovered entry should grow, while both neighbors need to shrink + &--truncated:hover, + &--truncated:focus-within { + .app-menu-entry__label { + max-width: calc(var(--header-height) + var(--app-menu-entry-growth)); + } + + // The next entry needs to shrink half the growth + + .app-menu-entry { + .app-menu-entry__label { + font-weight: normal; + max-width: calc(var(--header-height) - var(--app-menu-entry-growth)); + } + } + } + + // The previous entry needs to shrink half the growth + &:has(+ .app-menu-entry--truncated:hover), + &:has(+ .app-menu-entry--truncated:focus-within) { + .app-menu-entry__label { + font-weight: normal; + max-width: calc(var(--header-height) - var(--app-menu-entry-growth)); + } + } +} +</style> + +<style lang="scss"> +// Showing the label +.app-menu-entry:hover, +.app-menu-entry:focus-within, +.app-menu__list:hover, +.app-menu__list:focus-within { + // Move icon up so that the name does not overflow the icon + .app-menu-entry__icon { + margin-block-end: 1lh; + } + + // Make the label visible + .app-menu-entry__label { + opacity: 1; + } + + // Hide indicator when the text is shown + .app-menu-entry--active::before { + opacity: 0; + } + + .app-menu-icon__unread { + opacity: 0; + } +} +</style> diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue new file mode 100644 index 00000000000..1b0d48daf8c --- /dev/null +++ b/core/src/components/AppMenuIcon.vue @@ -0,0 +1,67 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <span class="app-menu-icon" + role="img" + :aria-hidden="ariaHidden" + :aria-label="ariaLabel"> + <img class="app-menu-icon__icon" :src="app.icon" alt=""> + <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" /> + </span> +</template> + +<script setup lang="ts"> +import type { INavigationEntry } from '../types/navigation.ts' + +import { n } from '@nextcloud/l10n' +import { computed } from 'vue' +import IconDot from 'vue-material-design-icons/CircleOutline.vue' + +const props = defineProps<{ + app: INavigationEntry +}>() + +// only hide if there are no unread notifications +const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined) + +const ariaLabel = computed(() => { + if (!props.app.unread) { + return undefined + } + + return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` +}) +</script> + +<style scoped lang="scss"> +$icon-size: 20px; +$unread-indicator-size: 10px; + +.app-menu-icon { + box-sizing: border-box; + position: relative; + + height: $icon-size; + width: $icon-size; + + &__icon { + transition: margin 0.1s ease-in-out; + height: $icon-size; + width: $icon-size; + filter: var(--background-image-invert-if-bright); + mask: var(--header-menu-icon-mask); + } + + &__unread { + color: var(--color-error); + position: absolute; + // Align the dot to the top right corner of the icon + inset-block-end: calc($icon-size + ($unread-indicator-size / -2)); + inset-inline-end: calc($unread-indicator-size / -2); + transition: all 0.1s ease-in-out; + } +} +</style> diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js index 0791fe83b0c..e07a699ab9f 100644 --- a/core/src/components/ContactsMenu.js +++ b/core/src/components/ContactsMenu.js @@ -1,34 +1,23 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import $ from 'jquery' -import OC from '../OC' +import Vue from 'vue' + +import ContactsMenu from '../views/ContactsMenu.vue' /** * @todo move to contacts menu code https://github.com/orgs/nextcloud/projects/31#card-21213129 */ export const setUp = () => { - // eslint-disable-next-line no-new - new OC.ContactsMenu({ - el: $('#contactsmenu .menu'), - trigger: $('#contactsmenu .menutoggle'), - }) + const mountPoint = document.getElementById('contactsmenu') + if (mountPoint) { + // eslint-disable-next-line no-new + new Vue({ + name: 'ContactsMenuRoot', + el: mountPoint, + render: h => h(ContactsMenu), + }) + } } diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue new file mode 100644 index 00000000000..322f53647b1 --- /dev/null +++ b/core/src/components/ContactsMenu/Contact.vue @@ -0,0 +1,193 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <li class="contact"> + <NcAvatar class="contact__avatar" + :size="44" + :user="contact.isUser ? contact.uid : undefined" + :is-no-user="!contact.isUser" + :disable-menu="true" + :display-name="contact.avatarLabel" + :preloaded-user-status="preloadedUserStatus" /> + <a class="contact__body" + :href="contact.profileUrl || contact.topAction?.hyperlink"> + <div class="contact__body__full-name">{{ contact.fullName }}</div> + <div v-if="contact.lastMessage" class="contact__body__last-message">{{ contact.lastMessage }}</div> + <div v-if="contact.statusMessage" class="contact__body__status-message">{{ contact.statusMessage }}</div> + <div v-else class="contact__body__email-address">{{ contact.emailAddresses[0] }}</div> + </a> + <NcActions v-if="actions.length" + :inline="contact.topAction ? 1 : 0"> + <template v-for="(action, idx) in actions"> + <NcActionLink v-if="action.hyperlink !== '#'" + :key="`${idx}-link`" + :href="action.hyperlink" + class="other-actions"> + <template #icon> + <img aria-hidden="true" class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionLink> + <NcActionText v-else :key="`${idx}-text`" class="other-actions"> + <template #icon> + <img aria-hidden="true" class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionText> + </template> + <NcActionButton v-for="action in jsActions" + :key="action.id" + :close-after-click="true" + class="other-actions" + @click="action.callback(contact)"> + <template #icon> + <NcIconSvgWrapper class="contact__action__icon-svg" + :svg="action.iconSvg(contact)" /> + </template> + {{ action.displayName(contact) }} + </NcActionButton> + </NcActions> + </li> +</template> + +<script> +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { getEnabledContactsMenuActions } from '@nextcloud/vue/functions/contactsMenu' + +export default { + name: 'Contact', + components: { + NcActionLink, + NcActionText, + NcActionButton, + NcActions, + NcAvatar, + NcIconSvgWrapper, + }, + props: { + contact: { + required: true, + type: Object, + }, + }, + computed: { + actions() { + if (this.contact.topAction) { + return [this.contact.topAction, ...this.contact.actions] + } + return this.contact.actions + }, + jsActions() { + return getEnabledContactsMenuActions(this.contact) + }, + preloadedUserStatus() { + if (this.contact.status) { + return { + status: this.contact.status, + message: this.contact.statusMessage, + icon: this.contact.statusIcon, + } + } + return undefined + }, + }, +} +</script> + +<style scoped lang="scss"> +.contact { + display: flex; + position: relative; + align-items: center; + padding: 3px; + padding-inline-start: 10px; + + &__action { + &__icon { + width: 20px; + height: 20px; + padding: 12px; + filter: var(--background-invert-if-dark); + } + + &__icon-svg { + padding: 5px; + } + } + + &__avatar { + display: inherit; + } + + &__body { + flex-grow: 1; + padding-inline-start: 10px; + margin-inline-start: 10px; + min-width: 0; + + div { + position: relative; + width: 100%; + overflow-x: hidden; + text-overflow: ellipsis; + margin: -1px 0; + } + div:first-of-type { + margin-top: 0; + } + div:last-of-type { + margin-bottom: 0; + } + + &__last-message, &__status-message, &__email-address { + color: var(--color-text-maxcontrast); + } + + &:focus-visible { + box-shadow: 0 0 0 4px var(--color-main-background) !important; + outline: 2px solid var(--color-main-text) !important; + } + } + + .other-actions { + width: 16px; + height: 16px; + cursor: pointer; + + img { + filter: var(--background-invert-if-dark); + } + } + + button.other-actions { + width: 44px; + + &:focus { + border-color: transparent; + box-shadow: 0 0 0 2px var(--color-main-text); + } + + &:focus-visible { + border-radius: var(--border-radius-pill); + } + } + + /* actions menu */ + .menu { + top: 47px; + margin-inline-end: 13px; + } + + .popovermenu::after { + inset-inline-end: 2px; + } +} +</style> diff --git a/core/src/components/HeaderMenu.vue b/core/src/components/HeaderMenu.vue deleted file mode 100644 index 12afa16e091..00000000000 --- a/core/src/components/HeaderMenu.vue +++ /dev/null @@ -1,215 +0,0 @@ - <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> -<template> - <div - :id="id" - v-click-outside="clickOutsideConfig" - :class="{ 'header-menu--opened': opened }" - class="header-menu"> - <a class="header-menu__trigger" - href="#" - :aria-controls="`header-menu-${id}`" - :aria-expanded="opened" - aria-haspopup="true" - @click.prevent="toggleMenu"> - <slot name="trigger" /> - </a> - <div v-if="opened" - :id="`header-menu-${id}`" - class="header-menu__wrapper" - role="menu"> - <div class="header-menu__carret" /> - <div class="header-menu__content"> - <slot /> - </div> - </div> - </div> -</template> - -<script> -import { directive as ClickOutside } from 'v-click-outside' -import excludeClickOutsideClasses from '@nextcloud/vue/dist/Mixins/excludeClickOutsideClasses' - -export default { - name: 'HeaderMenu', - - directives: { - ClickOutside, - }, - - mixins: [ - excludeClickOutsideClasses, - ], - - props: { - id: { - type: String, - required: true, - }, - open: { - type: Boolean, - default: false, - }, - }, - - data() { - return { - opened: this.open, - clickOutsideConfig: { - handler: this.closeMenu, - middleware: this.clickOutsideMiddleware, - }, - } - }, - - watch: { - open(newVal) { - this.opened = newVal - this.$nextTick(() => { - if (this.opened) { - this.openMenu() - } else { - this.closeMenu() - } - }) - }, - }, - - mounted() { - document.addEventListener('keydown', this.onKeyDown) - }, - beforeDestroy() { - document.removeEventListener('keydown', this.onKeyDown) - }, - - methods: { - /** - * Toggle the current menu open state - */ - toggleMenu() { - // Toggling current state - if (!this.opened) { - this.openMenu() - } else { - this.closeMenu() - } - }, - - /** - * Close the current menu - */ - closeMenu() { - if (!this.opened) { - return - } - - this.opened = false - this.$emit('close') - this.$emit('update:open', false) - }, - - /** - * Open the current menu - */ - openMenu() { - if (this.opened) { - return - } - - this.opened = true - this.$emit('open') - this.$emit('update:open', true) - }, - - onKeyDown(event) { - // If opened and escape pressed, close - if (event.key === 'Escape' && this.opened) { - event.preventDefault() - - /** user cancelled the menu by pressing escape */ - this.$emit('cancel') - - /** we do NOT fire a close event to differentiate cancel and close */ - this.opened = false - this.$emit('update:open', false) - } - }, - }, -} -</script> - -<style lang="scss" scoped> -.header-menu { - &__trigger { - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 100%; - margin: 0; - padding: 0; - cursor: pointer; - opacity: .6; - } - - &--opened &__trigger, - &__trigger:hover, - &__trigger:focus, - &__trigger:active { - opacity: 1; - } - - &__wrapper { - position: absolute; - z-index: 2000; - top: 50px; - right: 5px; - box-sizing: border-box; - margin: 0; - border-radius: 0 0 var(--border-radius) var(--border-radius); - background-color: var(--color-main-background); - - filter: drop-shadow(0 1px 5px var(--color-box-shadow)); - } - - &__carret { - position: absolute; - right: 10px; - bottom: 100%; - width: 0; - height: 0; - content: ' '; - pointer-events: none; - border: 10px solid transparent; - border-bottom-color: var(--color-main-background); - } - - &__content { - overflow: auto; - width: 350px; - max-width: 350px; - min-height: calc(44px * 1.5); - max-height: calc(100vh - 50px * 2); - } -} - -</style> diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue new file mode 100644 index 00000000000..f2ee4be9151 --- /dev/null +++ b/core/src/components/LegacyDialogPrompt.vue @@ -0,0 +1,111 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog dialog-classes="legacy-prompt__dialog" + :buttons="buttons" + :name="name" + @update:open="$emit('close', false, inputValue)"> + <p class="legacy-prompt__text" v-text="text" /> + <NcPasswordField v-if="isPassword" + ref="input" + autocomplete="new-password" + class="legacy-prompt__input" + :label="name" + :name="inputName" + :value.sync="inputValue" /> + <NcTextField v-else + ref="input" + class="legacy-prompt__input" + :label="name" + :name="inputName" + :value.sync="inputValue" /> + </NcDialog> +</template> + +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' + +export default defineComponent({ + name: 'LegacyDialogPrompt', + + components: { + NcDialog, + NcTextField, + NcPasswordField, + }, + + props: { + name: { + type: String, + required: true, + }, + + text: { + type: String, + required: true, + }, + + isPassword: { + type: Boolean, + required: true, + }, + + inputName: { + type: String, + default: 'prompt-input', + }, + }, + + emits: ['close'], + + data() { + return { + inputValue: '', + } + }, + + computed: { + buttons() { + return [ + { + label: t('core', 'No'), + callback: () => this.$emit('close', false, this.inputValue), + }, + { + label: t('core', 'Yes'), + type: 'primary', + callback: () => this.$emit('close', true, this.inputValue), + }, + ] + }, + }, + + mounted() { + this.$nextTick(() => this.$refs.input?.focus?.()) + }, +}) +</script> + +<style scoped lang="scss"> +.legacy-prompt { + &__text { + margin-block: 0 .75em; + } + + &__input { + margin-block: 0 1em; + } +} + +:deep(.legacy-prompt__dialog .dialog__actions) { + min-width: calc(100% - 12px); + justify-content: space-between; +} +</style> diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js index d57711010ea..21a0b6a772f 100644 --- a/core/src/components/MainMenu.js +++ b/core/src/components/MainMenu.js @@ -1,97 +1,34 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import $ from 'jquery' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import Vue from 'vue' -import OC from '../OC' +import AppMenu from './AppMenu.vue' -/** - * Set up the main menu toggle to react to media query changes. - * If the screen is small enough, the main menu becomes a toggle. - * If the screen is bigger, the main menu is not a toggle any more. - */ export const setUp = () => { - // init the more-apps menu - OC.registerMenu($('#more-apps > a'), $('#navigation')) - - // toggle the navigation - const $toggle = $('#header .header-appname-container') - const $navigation = $('#navigation') - const $appmenu = $('#appmenu') - // init the menu - OC.registerMenu($toggle, $navigation) - $toggle.data('oldhref', $toggle.attr('href')) - $toggle.attr('href', '#') - $navigation.hide() - - // show loading feedback on more apps list - $navigation.delegate('a', 'click', event => { - let $app = $(event.target) - if (!$app.is('a')) { - $app = $app.closest('a') - } - if (event.which === 1 && !event.ctrlKey && !event.metaKey) { - $app.find('svg').remove() - $app.find('div').remove() // prevent odd double-clicks - // no need for theming, loader is already inverted on dark mode - // but we need it over the primary colour - $app.prepend($('<div/>').addClass('icon-loading-small')) - } else { - // Close navigation when opening app in - // a new tab - OC.hideMenus(() => false) - } + Vue.mixin({ + methods: { + t, + n, + }, }) - $navigation.delegate('a', 'mouseup', event => { - if (event.which === 2) { - // Close navigation when opening app in - // a new tab via middle click - OC.hideMenus(() => false) - } + const container = document.getElementById('header-start__appmenu') + if (!container) { + // no container, possibly we're on a public page + return + } + const AppMenuApp = Vue.extend(AppMenu) + const appMenu = new AppMenuApp({}).$mount(container) + + Object.assign(OC, { + setNavigationCounter(id, counter) { + appMenu.setNavigationCounter(id, counter) + }, }) - // show loading feedback on visible apps list - $appmenu.delegate('li:not(#more-apps) > a', 'click', event => { - let $app = $(event.target) - if (!$app.is('a')) { - $app = $app.closest('a') - } - - if (event.which === 1 && !event.ctrlKey && !event.metaKey && $app.parent('#more-apps').length === 0) { - $app.find('svg').remove() - $app.find('div').remove() // prevent odd double-clicks - $app.prepend($('<div/>').addClass( - OCA.Theming && OCA.Theming.inverted - ? 'icon-loading-small' - : 'icon-loading-small-dark' - )) - // trigger redirect - // needed for ie, but also works for every browser - window.location = $app.attr('href') - } else { - // Close navigation when opening app in - // a new tab - OC.hideMenus(() => false) - } - }) } diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue new file mode 100644 index 00000000000..dbc446b3d90 --- /dev/null +++ b/core/src/components/Profile/PrimaryActionButton.vue @@ -0,0 +1,64 @@ +<!-- + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton type="primary" + :href="href" + alignment="center" + :target="target" + :disabled="disabled"> + <template #icon> + <img class="icon" + aria-hidden="true" + :src="icon" + alt=""> + </template> + <slot /> + </NcButton> +</template> + +<script> +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' + +export default defineComponent({ + name: 'PrimaryActionButton', + + components: { + NcButton, + }, + + props: { + disabled: { + type: Boolean, + default: false, + }, + href: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + target: { + type: String, + required: true, + validator: (value) => ['_self', '_blank', '_parent', '_top'].includes(value), + }, + }, + + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> + .icon { + filter: var(--primary-invert-if-dark); + } +</style> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue new file mode 100644 index 00000000000..f3c57a12042 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue @@ -0,0 +1,36 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <!-- eslint-disable-next-line vue/no-v-html --> + <li ref="listItem" :role="itemRole" v-html="html" /> +</template> + +<script setup lang="ts"> +import { onMounted, ref } from 'vue' + +defineProps<{ + id: string + html: string +}>() + +const listItem = ref<HTMLLIElement>() +const itemRole = ref('presentation') + +onMounted(() => { + // check for proper roles + const menuitem = listItem.value?.querySelector('[role="menuitem"]') + if (menuitem) { + return + } + // check if a button is available + const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a') + if (button) { + button.role = 'menuitem' + } else { + // if nothing is available set role on `<li>` + itemRole.value = 'menuitem' + } +}) +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue new file mode 100644 index 00000000000..413806c7089 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue @@ -0,0 +1,51 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcListItem :anchor-id="`${id}--link`" + compact + :details="details" + :href="href" + :name="label" + role="presentation" + @click="$emit('click')"> + <template #icon> + <slot v-if="$scopedSlots.icon" name="icon" /> + <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" /> + </template> + </NcListItem> +</template> + +<script setup lang="ts"> +import { onMounted } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' + +const props = defineProps<{ + /** Only emit click event but do not open href */ + clickOnly?: boolean + // menu entry props + id: string + label: string + icon?: string + href: string + details?: string +}>() + +onMounted(() => { + const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement + // Make the `<a>` a menuitem + anchor.role = 'menuitem' + // Prevent native click handling if required + if (props.clickOnly) { + anchor.onclick = (event) => event.preventDefault() + } +}) +</script> + +<style scoped> +.public-page-menu-entry__icon { + padding-inline-start: var(--default-grid-baseline); +} +</style> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue new file mode 100644 index 00000000000..0f02bdf7524 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue @@ -0,0 +1,90 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcDialog is-form + :name="label" + :open.sync="open" + @submit="createFederatedShare"> + <NcTextField ref="input" + :label="t('core', 'Federated user')" + :placeholder="t('core', 'user@your-nextcloud.org')" + required + :value.sync="remoteUrl" /> + <template #actions> + <NcButton :disabled="loading" type="primary" native-type="submit"> + <template v-if="loading" #icon> + <NcLoadingIcon /> + </template> + {{ t('core', 'Create share') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script setup lang="ts"> +import type Vue from 'vue' + +import { t } from '@nextcloud/l10n' +import { showError } from '@nextcloud/dialogs' +import { generateUrl } from '@nextcloud/router' +import { getSharingToken } from '@nextcloud/sharing/public' +import { nextTick, onMounted, ref, watch } from 'vue' +import axios from '@nextcloud/axios' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import logger from '../../logger' + +defineProps<{ + label: string +}>() + +const loading = ref(false) +const remoteUrl = ref('') +// Todo: @nextcloud/vue should expose the types correctly +const input = ref<Vue & { focus: () => void }>() +const open = ref(true) + +// Focus when mounted +onMounted(() => nextTick(() => input.value!.focus())) + +// Check validity +watch(remoteUrl, () => { + let validity = '' + if (!remoteUrl.value.includes('@')) { + validity = t('core', 'The remote URL must include the user.') + } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) { + validity = t('core', 'Invalid remote URL.') + } + input.value!.$el.querySelector('input')!.setCustomValidity(validity) + input.value!.$el.querySelector('input')!.reportValidity() +}) + +/** + * Create a federated share for the current share + */ +async function createFederatedShare() { + loading.value = true + + try { + const url = generateUrl('/apps/federatedfilesharing/createFederatedShare') + const { data } = await axios.post<{ remoteUrl: string }>(url, { + shareWith: remoteUrl.value, + token: getSharingToken(), + }) + if (data.remoteUrl.includes('://')) { + window.location.href = data.remoteUrl + } else { + window.location.href = `${window.location.protocol}//${data.remoteUrl}` + } + } catch (error) { + logger.error('Failed to create federated share', { error }) + showError(t('files_sharing', 'Failed to add the public link to your Nextcloud')) + } finally { + loading.value = false + } +} +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue new file mode 100644 index 00000000000..a4451a38bbe --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue @@ -0,0 +1,36 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <PublicPageMenuEntry :id="id" + :icon="icon" + href="#" + :label="label" + @click="openDialog" /> +</template> + +<script setup lang="ts"> +import { spawnDialog } from '@nextcloud/dialogs' +import PublicPageMenuEntry from './PublicPageMenuEntry.vue' +import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue' + +const props = defineProps<{ + id: string + label: string + icon: string + href: string +}>() + +const emit = defineEmits<{ + (e: 'click'): void +}>() + +/** + * Open the "create federated share" dialog + */ +function openDialog() { + spawnDialog(PublicPageMenuExternalDialog, { label: props.label }) + emit('click') +} +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue new file mode 100644 index 00000000000..5f3a4883d6d --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue @@ -0,0 +1,51 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <PublicPageMenuEntry :id="id" + click-only + :icon="icon" + :href="href" + :label="label" + @click="onClick" /> +</template> + +<script setup lang="ts"> +import { showSuccess } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import PublicPageMenuEntry from './PublicPageMenuEntry.vue' + +const props = defineProps<{ + id: string + label: string + icon: string + href: string +}>() + +const emit = defineEmits<{ + (e: 'click'): void +}>() + +/** + * Copy the href to the clipboard + */ +async function copyLink() { + try { + await window.navigator.clipboard.writeText(props.href) + showSuccess(t('core', 'Direct link copied')) + } catch { + // No secure context -> fallback to dialog + window.prompt(t('core', 'Please copy the link manually:'), props.href) + } +} + +/** + * onclick handler to trigger the "copy link" action + * and emit the event so the menu can be closed + */ +function onClick() { + copyLink() + emit('click') +} +</script> diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue new file mode 100644 index 00000000000..d86192d156e --- /dev/null +++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcModal v-if="isModalOpen" + id="unified-search" + :name="t('core', 'Custom date range')" + :show.sync="isModalOpen" + :size="'small'" + :clear-view-delay="0" + :title="t('core', 'Custom date range')" + @close="closeModal"> + <!-- Custom date range --> + <div class="unified-search-custom-date-modal"> + <h1>{{ t('core', 'Custom date range') }}</h1> + <div class="unified-search-custom-date-modal__pickers"> + <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'" + v-model="dateFilter.startFrom" + :label="t('core', 'Pick start date')" + type="date" /> + <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'" + v-model="dateFilter.endAt" + :label="t('core', 'Pick end date')" + type="date" /> + </div> + <div class="unified-search-custom-date-modal__footer"> + <NcButton @click="applyCustomRange"> + {{ t('core', 'Search in date range') }} + <template #icon> + <CalendarRangeIcon :size="20" /> + </template> + </NcButton> + </div> + </div> + </NcModal> +</template> + +<script> +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcModal from '@nextcloud/vue/components/NcModal' +import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' + +export default { + name: 'CustomDateRangeModal', + components: { + NcButton, + NcModal, + CalendarRangeIcon, + NcDateTimePicker, + }, + props: { + isOpen: { + type: Boolean, + required: true, + }, + }, + data() { + return { + dateFilter: { startFrom: null, endAt: null }, + } + }, + computed: { + isModalOpen: { + get() { + return this.isOpen + }, + set(value) { + this.$emit('update:is-open', value) + }, + }, + }, + methods: { + closeModal() { + this.isModalOpen = false + }, + applyCustomRange() { + this.$emit('set:custom-date-range', this.dateFilter) + this.closeModal() + }, + }, +} +</script> + +<style lang="scss" scoped> +.unified-search-custom-date-modal { + padding: 10px 20px 10px 20px; + + h1 { + font-size: 16px; + font-weight: bolder; + line-height: 2em; + } + + &__pickers { + display: flex; + flex-direction: column; + } + + &__footer { + display: flex; + justify-content: end; + } + +} +</style> diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue new file mode 100644 index 00000000000..4592adf08c9 --- /dev/null +++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue @@ -0,0 +1,242 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <a :href="resourceUrl || '#'" + class="unified-search__result" + :class="{ + 'unified-search__result--focused': focused, + }" + @click="reEmitEvent" + @focus="reEmitEvent"> + + <!-- Icon describing the result --> + <div class="unified-search__result-icon" + :class="{ + 'unified-search__result-icon--rounded': rounded, + 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, + 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, + [icon]: !loaded && !isIconUrl, + }" + :style="{ + backgroundImage: isIconUrl ? `url(${icon})` : '', + }"> + + <img v-if="hasValidThumbnail" + v-show="loaded" + :src="thumbnailUrl" + alt="" + @error="onError" + @load="onLoad"> + </div> + + <!-- Title and sub-title --> + <span class="unified-search__result-content"> + <span class="unified-search__result-line-one" :title="title"> + <NcHighlight :text="title" :search="query" /> + </span> + <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span> + </span> + </a> +</template> + +<script> +import NcHighlight from '@nextcloud/vue/components/NcHighlight' + +export default { + name: 'LegacySearchResult', + + components: { + NcHighlight, + }, + + props: { + thumbnailUrl: { + type: String, + default: null, + }, + title: { + type: String, + required: true, + }, + subline: { + type: String, + default: null, + }, + resourceUrl: { + type: String, + default: null, + }, + icon: { + type: String, + default: '', + }, + rounded: { + type: Boolean, + default: false, + }, + query: { + type: String, + default: '', + }, + + /** + * Only used for the first result as a visual feedback + * so we can keep the search input focused but pressing + * enter still opens the first result + */ + focused: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', + loaded: false, + } + }, + + computed: { + isIconUrl() { + // If we're facing an absolute url + if (this.icon.startsWith('/')) { + return true + } + + // Otherwise, let's check if this is a valid url + try { + // eslint-disable-next-line no-new + new URL(this.icon) + } catch { + return false + } + return true + }, + }, + + watch: { + // Make sure to reset state on change even when vue recycle the component + thumbnailUrl() { + this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' + this.loaded = false + }, + }, + + methods: { + reEmitEvent(e) { + this.$emit(e.type, e) + }, + + /** + * If the image fails to load, fallback to iconClass + */ + onError() { + this.hasValidThumbnail = false + }, + + onLoad() { + this.loaded = true + }, + }, +} +</script> + +<style lang="scss" scoped> +@use "sass:math"; + +$clickable-area: 44px; +$margin: 10px; + +.unified-search__result { + display: flex; + align-items: center; + height: $clickable-area; + padding: $margin; + border: 2px solid transparent; + border-radius: var(--border-radius-large) !important; + + &--focused { + background-color: var(--color-background-hover); + } + + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + border: 2px solid var(--color-border-maxcontrast); + } + + * { + cursor: pointer; + } + + &-icon { + overflow: hidden; + width: $clickable-area; + height: $clickable-area; + border-radius: var(--border-radius); + background-repeat: no-repeat; + background-position: center center; + background-size: 32px; + &--rounded { + border-radius: math.div($clickable-area, 2); + } + &--no-preview { + background-size: 32px; + } + &--with-thumbnail { + background-size: cover; + } + &--with-thumbnail:not(&--rounded) { + // compensate for border + max-width: $clickable-area - 2px; + max-height: $clickable-area - 2px; + border: 1px solid var(--color-border); + } + + img { + // Make sure to keep ratio + width: 100%; + height: 100%; + + object-fit: cover; + object-position: center; + } + } + + &-icon, + &-actions { + flex: 0 0 $clickable-area; + } + + &-content { + display: flex; + align-items: center; + flex: 1 1 100%; + flex-wrap: wrap; + // Set to minimum and gro from it + min-width: 0; + padding-inline-start: $margin; + } + + &-line-one, + &-line-two { + overflow: hidden; + flex: 1 1 100%; + margin: 1px 0; + white-space: nowrap; + text-overflow: ellipsis; + // Use the same color as the `a` + color: inherit; + font-size: inherit; + } + &-line-two { + opacity: .7; + font-size: var(--default-font-size); + } +} + +</style> diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue new file mode 100644 index 00000000000..e08ddd58a4b --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue @@ -0,0 +1,79 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="chip"> + <span class="icon"> + <slot name="icon" /> + <span v-if="pretext.length"> {{ pretext }} : </span> + </span> + <span class="text">{{ text }}</span> + <span class="close-icon" @click="deleteChip"> + <CloseIcon :size="18" /> + </span> + </div> +</template> + +<script> +import CloseIcon from 'vue-material-design-icons/Close.vue' + +export default { + name: 'SearchFilterChip', + components: { + CloseIcon, + }, + props: { + text: { + type: String, + required: true, + }, + pretext: { + type: String, + required: true, + }, + }, + methods: { + deleteChip() { + this.$emit('delete', this.filter) + }, + }, +} +</script> + +<style lang="scss" scoped> +.chip { + display: flex; + align-items: center; + padding: 2px 4px; + border: 1px solid var(--color-primary-element-light); + border-radius: 20px; + background-color: var(--color-primary-element-light); + margin: 2px; + + .icon { + display: flex; + align-items: center; + padding-inline-end: 5px; + + img { + width: 20px; + padding: 2px; + border-radius: 20px; + filter: var(--background-invert-if-bright); + } + } + + .text { + margin: 0 2px; + } + + .close-icon { + cursor: pointer ; + + :hover { + filter: invert(20%); + } + } +} +</style> diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue index e50cc413d03..4f33fbd54cc 100644 --- a/core/src/components/UnifiedSearch/SearchResult.vue +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -1,74 +1,44 @@ - <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <a :href="resourceUrl || '#'" - class="unified-search__result" - :class="{ - 'unified-search__result--focused': focused, - }" - @click="reEmitEvent" - @focus="reEmitEvent"> - - <!-- Icon describing the result --> - <div class="unified-search__result-icon" - :class="{ - 'unified-search__result-icon--rounded': rounded, - 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, - 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, - [icon]: !loaded && !isIconUrl, - }" - :style="{ - backgroundImage: isIconUrl ? `url(${icon})` : '', - }" - role="img"> - - <img v-if="hasValidThumbnail" - v-show="loaded" - :src="thumbnailUrl" - alt="" - @error="onError" - @load="onLoad"> - </div> - - <!-- Title and sub-title --> - <span class="unified-search__result-content"> - <h3 class="unified-search__result-line-one" :title="title"> - <Highlight :text="title" :search="query" /> - </h3> - <h4 v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</h4> - </span> - </a> + <NcListItem class="result-item" + :name="title" + :bold="false" + :href="resourceUrl" + target="_self"> + <template #icon> + <div aria-hidden="true" + class="result-item__icon" + :class="{ + 'result-item__icon--rounded': rounded, + 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), + [icon]: !isValidIconOrPreviewUrl(icon), + }" + :style="{ + backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '', + }"> + <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError" + :src="thumbnailUrl" + @error="thumbnailErrorHandler"> + </div> + </template> + <template #subname> + {{ subline }} + </template> + </NcListItem> </template> <script> -import Highlight from '@nextcloud/vue/dist/Components/Highlight' +import NcListItem from '@nextcloud/vue/components/NcListItem' export default { name: 'SearchResult', - components: { - Highlight, + NcListItem, }, - props: { thumbnailUrl: { type: String, @@ -109,107 +79,71 @@ export default { default: false, }, }, - data() { return { - hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', - loaded: false, + thumbnailHasError: false, } }, - - computed: { - isIconUrl() { - // If we're facing an absolute url - if (this.icon.startsWith('/')) { - return true - } - - // Otherwise, let's check if this is a valid url - try { - // eslint-disable-next-line no-new - new URL(this.icon) - } catch { - return false - } - return true - }, - }, - watch: { - // Make sure to reset state on change even when vue recycle the component thumbnailUrl() { - this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' - this.loaded = false + this.thumbnailHasError = false }, }, - methods: { - reEmitEvent(e) { - this.$emit(e.type, e) - }, - - /** - * If the image fails to load, fallback to iconClass - */ - onError() { - this.hasValidThumbnail = false + isValidIconOrPreviewUrl(url) { + return /^https?:\/\//.test(url) || url.startsWith('/') }, - - onLoad() { - this.loaded = true + thumbnailErrorHandler() { + this.thumbnailHasError = true }, }, } </script> <style lang="scss" scoped> -$clickable-area: 44px; -$margin: 10px; - -.unified-search__result { - display: flex; - height: $clickable-area; - padding: $margin; - border-bottom: 1px solid var(--color-border); - - // Load more entry, - &:last-child { - border-bottom: none; - } - - &--focused, - &:active, - &:hover, - &:focus { - background-color: var(--color-background-hover); - } +.result-item { + :deep(a) { + border: 2px solid transparent; + border-radius: var(--border-radius-large) !important; + + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + border: 2px solid var(--color-border-maxcontrast); + } - * { - cursor: pointer; + * { + cursor: pointer; + } } - &-icon { + &__icon { overflow: hidden; - width: $clickable-area; - height: $clickable-area; + width: var(--default-clickable-area); + height: var(--default-clickable-area); border-radius: var(--border-radius); background-repeat: no-repeat; background-position: center center; background-size: 32px; + &--rounded { - border-radius: $clickable-area / 2; + border-radius: calc(var(--default-clickable-area) / 2); } + &--no-preview { background-size: 32px; } + &--with-thumbnail { background-size: cover; } - &--with-thumbnail:not(&--rounded) { - // compensate for border - max-width: $clickable-area - 2px; - max-height: $clickable-area - 2px; + + &--with-thumbnail:not(#{&}--rounded) { border: 1px solid var(--color-border); + // compensate for border + max-height: calc(var(--default-clickable-area) - 2px); + max-width: calc(var(--default-clickable-area) - 2px); } img { @@ -221,37 +155,5 @@ $margin: 10px; object-position: center; } } - - &-icon, - &-actions { - flex: 0 0 $clickable-area; - } - - &-content { - display: flex; - align-items: center; - flex: 1 1 100%; - flex-wrap: wrap; - // Set to minimum and gro from it - min-width: 0; - padding-left: $margin; - } - - &-line-one, - &-line-two { - overflow: hidden; - flex: 1 1 100%; - margin: 1px 0; - white-space: nowrap; - text-overflow: ellipsis; - // Use the same color as the `a` - color: inherit; - font-size: inherit; - } - &-line-two { - opacity: .7; - font-size: var(--default-font-size); - } } - </style> diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue index 31f85f413d3..aec2791d8e4 100644 --- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue +++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <ul> <!-- Placeholder animation --> @@ -22,8 +26,7 @@ <!-- Placeholders --> <li v-for="placeholder in [1, 2, 3]" :key="placeholder"> - <svg - class="unified-search__result-placeholder" + <svg class="unified-search__result-placeholder" xmlns="http://www.w3.org/2000/svg" fill="url(#unified-search__result-placeholder-gradient)"> <rect class="unified-search__result-placeholder-icon" /> diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue new file mode 100644 index 00000000000..d7abb6ffdbb --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchableList.vue @@ -0,0 +1,157 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcPopover :shown="opened" + @show="opened = true" + @hide="opened = false"> + <template #trigger> + <slot ref="popoverTrigger" name="trigger" /> + </template> + <div class="searchable-list__wrapper"> + <NcTextField :value.sync="searchTerm" + :label="labelText" + trailing-button-icon="close" + :show-trailing-button="searchTerm !== ''" + @update:value="searchTermChanged" + @trailing-button-click="clearSearch"> + <IconMagnify :size="20" /> + </NcTextField> + <ul v-if="filteredList.length > 0" class="searchable-list__list"> + <li v-for="element in filteredList" + :key="element.id" + :title="element.displayName" + role="button"> + <NcButton alignment="start" + type="tertiary" + :wide="true" + @click="itemSelected(element)"> + <template #icon> + <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" /> + <NcAvatar v-else + :is-no-user="true" + :display-name="element.displayName" + :show-user-status="false" /> + </template> + {{ element.displayName }} + </NcButton> + </li> + </ul> + <div v-else class="searchable-list__empty-content"> + <NcEmptyContent :name="emptyContentText"> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + </div> + </div> + </NcPopover> +</template> + +<script> +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' + +export default { + name: 'SearchableList', + + components: { + IconMagnify, + IconAlertCircleOutline, + NcAvatar, + NcButton, + NcEmptyContent, + NcPopover, + NcTextField, + }, + + props: { + labelText: { + type: String, + default: 'this is a label', + }, + + searchList: { + type: Array, + required: true, + }, + + emptyContentText: { + type: String, + required: true, + }, + }, + + data() { + return { + opened: false, + error: false, + searchTerm: '', + } + }, + + computed: { + filteredList() { + return this.searchList.filter((element) => { + if (!this.searchTerm.toLowerCase().length) { + return true + } + return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase())) + }) + }, + }, + + methods: { + clearSearch() { + this.searchTerm = '' + }, + itemSelected(element) { + this.$emit('item-selected', element) + this.clearSearch() + this.opened = false + }, + searchTermChanged(term) { + this.$emit('search-term-change', term) + }, + }, +} +</script> + +<style lang="scss" scoped> +.searchable-list { + &__wrapper { + padding: calc(var(--default-grid-baseline) * 3); + display: flex; + flex-direction: column; + align-items: center; + width: 250px; + } + + &__list { + width: 100%; + max-height: 284px; + overflow-y: auto; + margin-top: var(--default-grid-baseline); + padding: var(--default-grid-baseline); + + :deep(.button-vue) { + border-radius: var(--border-radius-large) !important; + span { + font-weight: initial; + } + } + } + + &__empty-content { + margin-top: calc(var(--default-grid-baseline) * 3); + } +} +</style> diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue new file mode 100644 index 00000000000..171eada8a06 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue @@ -0,0 +1,166 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <Transition> + <div v-if="open" + class="local-unified-search animated-width" + :class="{ 'local-unified-search--open': open }"> + <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder --> + <NcInputField ref="searchInput" + class="local-unified-search__input animated-width" + :aria-label="t('core', 'Search in current app')" + :placeholder="t('core', 'Search in current app')" + show-trailing-button + :trailing-button-label="t('core', 'Clear search')" + :value="query" + @update:value="$emit('update:query', $event)" + @trailing-button-click="clearAndCloseSearch"> + <template #trailing-button-icon> + <NcIconSvgWrapper :path="mdiClose" /> + </template> + </NcInputField> + + <NcButton ref="searchGlobalButton" + class="local-unified-search__global-search" + :aria-label="t('core', 'Search everywhere')" + :title="t('core', 'Search everywhere')" + type="tertiary-no-background" + @click="$emit('global-search')"> + <template v-if="!isMobile" #default> + {{ t('core', 'Search everywhere') }} + </template> + <template #icon> + <NcIconSvgWrapper :path="mdiCloudSearchOutline" /> + </template> + </NcButton> + </div> + </Transition> +</template> + +<script lang="ts" setup> +import type { ComponentPublicInstance } from 'vue' +import { mdiCloudSearchOutline, mdiClose } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' +import { useElementSize } from '@vueuse/core' +import { computed, ref, watchEffect } from 'vue' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' + +const props = defineProps<{ + query: string, + open: boolean +}>() + +const emit = defineEmits<{ + (e: 'update:open', open: boolean): void + (e: 'update:query', query: string): void + (e: 'global-search'): void +}>() + +// Hacky type until the library provides real Types +type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }> +/** The input field component */ +const searchInput = ref<FocusableComponent>() +/** When the search bar is opened we focus the input */ +watchEffect(() => { + if (props.open && searchInput.value) { + searchInput.value.focus() + } +}) + +/** Current window size is below the "mobile" breakpoint (currently 1024px) */ +const isMobile = useIsMobile() + +const searchGlobalButton = ref<ComponentPublicInstance>() +/** Width of the search global button, used to resize the input field */ +const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton) +const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)') + +/** + * Clear the search query and close the search bar + */ +function clearAndCloseSearch() { + emit('update:query', '') + emit('update:open', false) +} +</script> + +<style scoped lang="scss"> +.local-unified-search { + --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw); + box-sizing: border-box; + position: relative; + height: var(--header-height); + width: var(--local-search-width); + display: flex; + align-items: center; + // Ensure it overlays the other entries + z-index: 10; + // add some padding for the focus visible outline + padding-inline: var(--border-width-input-focused); + // hide the overflow - needed for the transition + overflow: hidden; + // Ensure the position is fixed also during "position: absolut" (transition) + inset-inline-end: 0; + + #{&} &__global-search { + position: absolute; + inset-inline-end: var(--default-clickable-area); + } + + #{&} &__input { + box-sizing: border-box; + // override some nextcloud-vue styles + margin: 0; + width: var(--local-search-width); + + // Fixup the spacing so we can fit in the "search globally" button + // this can break at any time the component library changes + :deep(input) { + // search global width + close button width + padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area)); + } + } +} + +.animated-width { + transition: width var(--animation-quick) linear; +} + +// Make the position absolute during the transition +// this is needed to "hide" the button behind it +.v-leave-active { + position: absolute !important; +} + +.v-enter, +.v-leave-to { + &.local-unified-search { + // Start with only the overlay button + --local-search-width: var(--clickable-area-large); + } +} + +@media screen and (max-width: 500px) { + .local-unified-search.local-unified-search--open { + // 100% but still show the menu toggle on the very right + --local-search-width: 100vw; + padding-inline: var(--default-grid-baseline); + } + + // when open we need to position it absolute to allow overlay the full bar + :global(.unified-search-menu:has(.local-unified-search--open)) { + position: absolute !important; + inset-inline: 0; + } + // Hide all other entries, especially the user menu as it might leak pixels + :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) { + display: none; + } +} +</style> diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue new file mode 100644 index 00000000000..e59058bc0f0 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -0,0 +1,838 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog id="unified-search" + ref="unifiedSearchModal" + content-classes="unified-search-modal__content" + dialog-classes="unified-search-modal" + :name="t('core', 'Unified search')" + :open="open" + size="normal" + @update:open="onUpdateOpen"> + <!-- Modal for picking custom time range --> + <CustomDateRangeModal :is-open="showDateRangeModal" + class="unified-search__date-range" + @set:custom-date-range="setCustomDateRange" + @update:is-open="showDateRangeModal = $event" /> + + <!-- Unified search form --> + <div class="unified-search-modal__header"> + <NcInputField ref="searchInput" + data-cy-unified-search-input + :value.sync="searchQuery" + type="text" + :label="t('core', 'Search apps, files, tags, messages') + '...'" + @update:value="debouncedFind" /> + <div class="unified-search-modal__filters" data-cy-unified-search-filters> + <NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places"> + <template #icon> + <IconListBox :size="20" /> + </template> + <!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults. + provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. --> + <NcActionButton v-for="provider in providers" + :key="`${provider.id}-${provider.name.replace(/\s/g, '')}`" + :disabled="provider.disabled" + @click="addProviderFilter(provider)"> + <template #icon> + <img :src="provider.icon" class="filter-button__icon" alt=""> + </template> + {{ provider.name }} + </NcActionButton> + </NcActions> + <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date"> + <template #icon> + <IconCalendarRange :size="20" /> + </template> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')"> + {{ t('core', 'Today') }} + </NcActionButton> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')"> + {{ t('core', 'Last 7 days') }} + </NcActionButton> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')"> + {{ t('core', 'Last 30 days') }} + </NcActionButton> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')"> + {{ t('core', 'This year') }} + </NcActionButton> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')"> + {{ t('core', 'Last year') }} + </NcActionButton> + <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')"> + {{ t('core', 'Custom date range') }} + </NcActionButton> + </NcActions> + <SearchableList :label-text="t('core', 'Search people')" + :search-list="userContacts" + :empty-content-text="t('core', 'Not found')" + data-cy-unified-search-filter="people" + @search-term-change="debouncedFilterContacts" + @item-selected="applyPersonFilter"> + <template #trigger> + <NcButton> + <template #icon> + <IconAccountGroup :size="20" /> + </template> + {{ t('core', 'People') }} + </NcButton> + </template> + </SearchableList> + <NcButton v-if="localSearch" data-cy-unified-search-filter="current-view" @click="searchLocally"> + {{ t('core', 'Filter in current view') }} + <template #icon> + <IconFilter :size="20" /> + </template> + </NcButton> + <NcCheckboxRadioSwitch v-if="hasExternalResources" + v-model="searchExternalResources" + type="switch" + class="unified-search-modal__search-external-resources" + :class="{'unified-search-modal__search-external-resources--aligned': localSearch}"> + {{ t('core', 'Search connected services') }} + </NcCheckboxRadioSwitch> + </div> + <div class="unified-search-modal__filters-applied"> + <FilterChip v-for="filter in filters" + :key="filter.id" + :text="filter.name ?? filter.text" + :pretext="''" + @delete="removeFilter(filter)"> + <template #icon> + <NcAvatar v-if="filter.type === 'person'" + :user="filter.user" + :size="24" + :disable-menu="true" + :show-user-status="false" + :hide-favorite="false" /> + <IconCalendarRange v-else-if="filter.type === 'date'" /> + <img v-else :src="filter.icon" alt=""> + </template> + </FilterChip> + </div> + </div> + + <div v-if="showEmptyContentInfo" class="unified-search-modal__no-content"> + <NcEmptyContent :name="emptyContentMessage"> + <template #icon> + <IconMagnify :size="64" /> + </template> + </NcEmptyContent> + </div> + + <div v-else class="unified-search-modal__results"> + <h3 class="hidden-visually"> + {{ t('core', 'Results') }} + </h3> + <div v-for="providerResult in results" :key="providerResult.id" class="result"> + <h4 :id="`unified-search-result-${providerResult.id}`" class="result-title"> + {{ providerResult.name }} + </h4> + <ul class="result-items" :aria-labelledby="`unified-search-result-${providerResult.id}`"> + <SearchResult v-for="(result, index) in providerResult.results" + :key="index" + v-bind="result" /> + </ul> + <div class="result-footer"> + <NcButton v-if="providerResult.results.length === providerResult.limit" type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)"> + {{ t('core', 'Load more results') }} + <template #icon> + <IconDotsHorizontal :size="20" /> + </template> + </NcButton> + <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> + {{ t('core', 'Search in') }} {{ providerResult.name }} + <template #icon> + <IconArrowRight :size="20" /> + </template> + </NcButton> + </div> + </div> + </div> + </NcDialog> +</template> + +<script lang="ts"> +import { subscribe } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import { defineComponent } from 'vue' +import { getProviders, search as unifiedSearch, getContacts } from '../../services/UnifiedSearchService.js' +import { useSearchStore } from '../../store/unified-search-external-filters.js' + +import debounce from 'debounce' +import { unifiedSearchLogger } from '../../logger' + +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' +import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue' +import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue' +import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' +import IconFilter from 'vue-material-design-icons/Filter.vue' +import IconListBox from 'vue-material-design-icons/ListBox.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import CustomDateRangeModal from './CustomDateRangeModal.vue' +import FilterChip from './SearchFilterChip.vue' +import SearchableList from './SearchableList.vue' +import SearchResult from './SearchResult.vue' + +export default defineComponent({ + name: 'UnifiedSearchModal', + components: { + IconArrowRight, + IconAccountGroup, + IconCalendarRange, + IconDotsHorizontal, + IconFilter, + IconListBox, + IconMagnify, + + CustomDateRangeModal, + FilterChip, + NcActions, + NcActionButton, + NcAvatar, + NcButton, + NcEmptyContent, + NcDialog, + NcInputField, + NcCheckboxRadioSwitch, + SearchableList, + SearchResult, + }, + + props: { + /** + * Open state of the modal + */ + open: { + type: Boolean, + required: true, + }, + + /** + * The current query string + */ + query: { + type: String, + default: '', + }, + + /** + * If the current page / app supports local search + */ + localSearch: { + type: Boolean, + default: false, + }, + }, + + emits: ['update:open', 'update:query'], + + setup() { + /** + * Reactive version of window.location + */ + const currentLocation = useBrowserLocation() + const searchStore = useSearchStore() + return { + t, + + currentLocation, + externalFilters: searchStore.externalFilters, + } + }, + + data() { + return { + providers: [], + providerActionMenuIsOpen: false, + dateActionMenuIsOpen: false, + providerResultLimit: 5, + dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null }, + personFilter: { id: 'person', type: 'person', name: '' }, + filteredProviders: [], + searching: false, + searchQuery: '', + lastSearchQuery: '', + placessearchTerm: '', + dateTimeFilter: null, + filters: [], + results: [], + contacts: [], + showDateRangeModal: false, + internalIsVisible: this.open, + initialized: false, + searchExternalResources: false, + } + }, + + computed: { + isEmptySearch() { + return this.searchQuery.length === 0 + }, + + hasNoResults() { + return !this.isEmptySearch && this.results.length === 0 + }, + + showEmptyContentInfo() { + return this.isEmptySearch || this.hasNoResults + }, + + emptyContentMessage() { + if (this.searching && this.hasNoResults) { + return t('core', 'Searching …') + } + if (this.isEmptySearch) { + return t('core', 'Start typing to search') + } + return t('core', 'No matching results') + }, + + userContacts() { + return this.contacts + }, + + debouncedFind() { + return debounce(this.find, 300) + }, + + debouncedFilterContacts() { + return debounce(this.filterContacts, 300) + }, + + hasExternalResources() { + return this.providers.some(provider => provider.isExternalProvider) + }, + }, + + watch: { + open() { + // Load results when opened with already filled query + if (this.open) { + this.focusInput() + if (!this.initialized) { + Promise.all([getProviders(), getContacts({ searchTerm: '' })]) + .then(([providers, contacts]) => { + this.providers = this.groupProvidersByApp([...providers, ...this.externalFilters]) + this.contacts = this.mapContacts(contacts) + unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts }) + this.initialized = true + }) + .catch((error) => { + unifiedSearchLogger.error(error) + }) + } + if (this.searchQuery) { + this.find(this.searchQuery) + } + } + }, + + query: { + immediate: true, + handler() { + this.searchQuery = this.query + }, + }, + + searchQuery: { + handler() { + this.$emit('update:query', this.searchQuery) + }, + }, + + searchExternalResources() { + if (this.searchQuery) { + this.find(this.searchQuery) + } + }, + }, + + mounted() { + subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter) + }, + methods: { + /** + * On close the modal is closed and the query is reset + * @param open The new open state + */ + onUpdateOpen(open: boolean) { + if (!open) { + this.$emit('update:open', false) + this.$emit('update:query', '') + } + }, + + /** + * Only close the modal but keep the query for in-app search + */ + searchLocally() { + this.$emit('update:query', this.searchQuery) + this.$emit('update:open', false) + }, + focusInput() { + this.$nextTick(() => { + this.$refs.searchInput?.focus() + }) + }, + find(query: string, providersToSearchOverride = null) { + if (query.length === 0) { + this.results = [] + this.searching = false + return + } + + // Reset the provider result limit when performing a new search + if (query !== this.lastSearchQuery) { + this.providerResultLimit = 5 + } + this.lastSearchQuery = query + + this.searching = true + const newResults = [] + const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers) + const searchProvider = (provider) => { + const params = { + type: provider.searchFrom ?? provider.id, + query, + cursor: null, + extraQueries: provider.extraParams, + } + + // This block of filter checks should be dynamic somehow and should be handled in + // nextcloud/search lib + const activeFilters = this.filters.filter(filter => { + return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type]) + }) + + activeFilters.forEach(filter => { + switch (filter.type) { + case 'date': + if (provider.filters?.since && provider.filters?.until) { + params.since = this.dateFilter.startFrom + params.until = this.dateFilter.endAt + } + break + case 'person': + if (provider.filters?.person) { + params.person = this.personFilter.user + } + break + } + }) + + if (this.providerResultLimit > 5) { + params.limit = this.providerResultLimit + unifiedSearchLogger.debug('Limiting search to', params.limit) + } + + const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider + const wasManuallySelected = this.filteredProviders.some(filteredProvider => filteredProvider.id === provider.id) + // if the provider is an external resource and the user has not manually selected it, skip the search + if (shouldSkipSearch && !wasManuallySelected) { + this.searching = false + return + } + + const request = unifiedSearch(params).request + + request().then((response) => { + newResults.push({ + ...provider, + results: response.data.ocs.data.entries, + limit: params.limit ?? 5, + }) + + unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults }) + + this.updateResults(newResults) + this.searching = false + }) + } + + providersToSearch.forEach(searchProvider) + }, + updateResults(newResults) { + let updatedResults = [...this.results] + // If filters are applied, remove any previous results for providers that are not in current filters + if (this.filters.length > 0) { + updatedResults = updatedResults.filter(result => { + return this.filters.some(filter => filter.id === result.id) + }) + } + // Process the new results + newResults.forEach(newResult => { + const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id) + if (existingResultIndex !== -1) { + if (newResult.results.length === 0) { + // If the new results data has no matches for and existing result, remove the existing result + updatedResults.splice(existingResultIndex, 1) + } else { + // If input triggered a change in existing results, update existing result + updatedResults.splice(existingResultIndex, 1, newResult) + } + } else if (newResult.results.length > 0) { + // Push the new result to the array only if its results array is not empty + updatedResults.push(newResult) + } + }) + const sortedResults = updatedResults.slice(0) + // Order results according to provider preference + sortedResults.sort((a, b) => { + const aProvider = this.providers.find(provider => provider.id === a.id) + const bProvider = this.providers.find(provider => provider.id === b.id) + const aOrder = aProvider ? aProvider.order : 0 + const bOrder = bProvider ? bProvider.order : 0 + return aOrder - bOrder + }) + this.results = sortedResults + }, + mapContacts(contacts) { + return contacts.map(contact => { + return { + // id: contact.id, + // name: '', + displayName: contact.fullName, + isNoUser: false, + subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '', + icon: '', + user: contact.id, + isUser: contact.isUser, + } + }) + }, + filterContacts(query) { + getContacts({ searchTerm: query }).then((contacts) => { + this.contacts = this.mapContacts(contacts) + unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts }) + }) + }, + applyPersonFilter(person) { + + const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id) + if (existingPersonFilter === -1) { + this.personFilter.id = person.id + this.personFilter.user = person.user + this.personFilter.name = person.displayName + this.filters.push(this.personFilter) + } else { + this.filters[existingPersonFilter].id = person.id + this.filters[existingPersonFilter].user = person.user + this.filters[existingPersonFilter].name = person.displayName + } + + this.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['person'])) + }) + + this.debouncedFind(this.searchQuery) + unifiedSearchLogger.debug('Person filter applied', { person }) + }, + async loadMoreResultsForProvider(provider) { + this.providerResultLimit += 5 + this.find(this.searchQuery, [provider]) + }, + addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { + unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider }) + if (!providerFilter.id) return + if (providerFilter.isPluginFilter) { + // There is no way to know what should go into the callback currently + // Here we are passing isProviderFilterApplied (boolean) which is a flag sent to the plugin + // This is sent to the plugin so that depending on whether the filter is applied or not, the plugin can decide what to do + // TODO : In nextcloud/search, this should be a proper interface that the plugin can implement + const isProviderFilterApplied = this.filteredProviders.some(provider => provider.id === providerFilter.id) + providerFilter.callback(!isProviderFilterApplied) + } + this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5 + this.providerActionMenuIsOpen = false + // With the possibility for other apps to add new filters + // Resulting in a possible id/provider collision + // If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one. + const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id) + if (existingFilterIndex > -1) { + this.filteredProviders.splice(existingFilterIndex, 1) + this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) + } + this.filteredProviders.push({ + ...providerFilter, + type: providerFilter.type || 'provider', + isPluginFilter: providerFilter.isPluginFilter || false, + }) + this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) + unifiedSearchLogger.debug('Search filters (newly added)', { filters: this.filters }) + this.debouncedFind(this.searchQuery) + }, + removeFilter(filter) { + if (filter.type === 'provider') { + for (let i = 0; i < this.filteredProviders.length; i++) { + if (this.filteredProviders[i].id === filter.id) { + this.filteredProviders.splice(i, 1) + break + } + } + this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) + unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters }) + + } else { + // Remove non provider filters such as date and person filters + for (let i = 0; i < this.filters.length; i++) { + if (this.filters[i].id === filter.id) { + this.filters.splice(i, 1) + this.enableAllProviders() + break + } + } + } + this.debouncedFind(this.searchQuery) + }, + syncProviderFilters(firstArray, secondArray) { + // Create a copy of the first array to avoid modifying it directly. + const synchronizedArray = firstArray.slice() + // Remove items from the synchronizedArray that are not in the secondArray. + synchronizedArray.forEach((item, index) => { + const itemId = item.id + if (item.type === 'provider') { + if (!secondArray.some(secondItem => secondItem.id === itemId)) { + synchronizedArray.splice(index, 1) + } + } + }) + // Add items to the synchronizedArray that are in the secondArray but not in the firstArray. + secondArray.forEach(secondItem => { + const itemId = secondItem.id + if (secondItem.type === 'provider') { + if (!synchronizedArray.some(item => item.id === itemId)) { + synchronizedArray.push(secondItem) + } + } + }) + + return synchronizedArray + }, + updateDateFilter() { + const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date') + if (currFilterIndex !== -1) { + this.filters[currFilterIndex] = this.dateFilter + } else { + this.filters.push(this.dateFilter) + } + + this.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until'])) + }) + this.debouncedFind(this.searchQuery) + }, + applyQuickDateRange(range) { + this.dateActionMenuIsOpen = false + const today = new Date() + let startDate + let endDate + + switch (range) { + case 'today': + // For 'Today', both start and end are set to today + startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0) + endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999) + this.dateFilter.text = t('core', 'Today') + break + case '7days': + // For 'Last 7 days', start date is 7 days ago, end is today + startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0) + this.dateFilter.text = t('core', 'Last 7 days') + break + case '30days': + // For 'Last 30 days', start date is 30 days ago, end is today + startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0) + this.dateFilter.text = t('core', 'Last 30 days') + break + case 'thisyear': + // For 'This year', start date is the first day of the year, end is the last day of the year + startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0) + endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999) + this.dateFilter.text = t('core', 'This year') + break + case 'lastyear': + // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year + startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0) + endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999) + this.dateFilter.text = t('core', 'Last year') + break + case 'custom': + this.showDateRangeModal = true + return + default: + return + } + this.dateFilter.startFrom = startDate + this.dateFilter.endAt = endDate + this.updateDateFilter() + + }, + setCustomDateRange(event) { + unifiedSearchLogger.debug('Custom date range', { range: event }) + this.dateFilter.startFrom = event.startFrom + this.dateFilter.endAt = event.endAt + this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`) + this.updateDateFilter() + }, + handlePluginFilter(addFilterEvent) { + unifiedSearchLogger.debug('Handling plugin filter', { addFilterEvent }) + for (let i = 0; i < this.filteredProviders.length; i++) { + const provider = this.filteredProviders[i] + if (provider.id === addFilterEvent.id) { + provider.name = addFilterEvent.filterUpdateText + // Filters attached may only make sense with certain providers, + // So, find the provider attached, add apply the extra parameters to those providers only + const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id) + if (compatibleProviderIndex > -1) { + provider.extraParams = addFilterEvent.filterParams + this.filteredProviders[i] = provider + } + break + } + } + this.debouncedFind(this.searchQuery) + }, + groupProvidersByApp(filters) { + const groupedByProviderApp = {} + + filters.forEach(filter => { + const provider = filter.appId ? filter.appId : 'general' + if (!groupedByProviderApp[provider]) { + groupedByProviderApp[provider] = [] + } + groupedByProviderApp[provider].push(filter) + }) + + const flattenedArray = [] + Object.values(groupedByProviderApp).forEach(group => { + flattenedArray.push(...group) + }) + + return flattenedArray + }, + async providerIsCompatibleWithFilters(provider, filterIds) { + return filterIds.every(filterId => provider.filters?.[filterId] !== undefined) + }, + async enableAllProviders() { + this.providers.forEach(async (_, index) => { + this.providers[index].disabled = false + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +:deep(.unified-search-modal .unified-search-modal__content) { + --dialog-height: min(80vh, 800px); + box-sizing: border-box; + height: var(--dialog-height); + max-height: var(--dialog-height); + min-height: var(--dialog-height); + + display: flex; + flex-direction: column; + // No padding to prevent scrollbar misplacement + padding-inline: 0; +} + +.unified-search-modal { + &__header { + // Add background to prevent leaking scrolled content (because of sticky position) + background-color: var(--color-main-background); + // Fix padding to have the input centered + padding-inline-end: 12px; + // Some padding to make elements scrolled under sticky position look nicer + padding-block-end: 12px; + // Make it sticky with the input margin for the label + position: sticky; + top: 6px; + } + + &__filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: start; + padding-top: 4px; + } + + &__search-external-resources { + :deep(span.checkbox-content) { + padding-top: 0; + padding-bottom: 0; + } + + :deep(.checkbox-content__icon) { + margin: auto !important; + } + + &--aligned { + margin-inline-start: auto; + } + } + + &__filters-applied { + padding-top: 4px; + display: flex; + flex-wrap: wrap; + } + + &__no-content { + display: flex; + align-items: center; + margin-top: 0.5em; + height: 70%; + } + + &__results { + overflow: hidden scroll; + // Adjust padding to match container but keep the scrollbar on the very end + padding-inline: 0 12px; + padding-block: 0 12px; + + .result { + &-title { + color: var(--color-primary-element); + font-size: 16px; + margin-block: 8px 4px; + } + + &-footer { + justify-content: space-between; + align-items: center; + display: flex; + } + } + + } +} + +.filter-button__icon { + height: 20px; + width: 20px; + object-fit: contain; + filter: var(--background-invert-if-bright); + padding: 11px; // align with text to fit at least 44px +} + +// Ensure modal is accessible on small devices +@media only screen and (max-height: 400px) { + .unified-search-modal__results { + overflow: unset; + } +} +</style> diff --git a/core/src/components/UserMenu.js b/core/src/components/UserMenu.js index a9e7d8725bb..5c488f2341e 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -1,53 +1,20 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import OC from '../OC' +import Vue from 'vue' -import $ from 'jquery' +import AccountMenu from '../views/AccountMenu.vue' export const setUp = () => { - const $menu = $('#header #settings') - - // show loading feedback - $menu.delegate('a', 'click', event => { - let $page = $(event.target) - if (!$page.is('a')) { - $page = $page.closest('a') - } - if (event.which === 1 && !event.ctrlKey && !event.metaKey) { - $page.find('img').remove() - $page.find('div').remove() // prevent odd double-clicks - $page.prepend($('<div/>').addClass('icon-loading-small')) - } else { - // Close navigation when opening menu entry in - // a new tab - OC.hideMenus(() => false) - } - }) - - $menu.delegate('a', 'mouseup', event => { - if (event.which === 2) { - // Close navigation when opening app in - // a new tab via middle click - OC.hideMenus(() => false) - } - }) + const mountPoint = document.getElementById('user-menu') + if (mountPoint) { + // eslint-disable-next-line no-new + new Vue({ + name: 'AccountMenuRoot', + el: mountPoint, + render: h => h(AccountMenu), + }) + } } diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue index f7d426e6c63..da387df0ff6 100644 --- a/core/src/components/login/LoginButton.vue +++ b/core/src/components/login/LoginButton.vue @@ -1,44 +1,43 @@ <!-- - - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div id="submit-wrapper" @click="$emit('click')"> - <input id="submit-form" - type="submit" - class="login primary" - title="" - :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> + <NcButton type="primary" + native-type="submit" + :wide="true" + :disabled="loading" + @click="$emit('click')"> + {{ !loading ? value : valueLoading }} + <template #icon> + <div v-if="loading" class="submit-wrapper__icon icon-loading-small-dark" /> + <ArrowRight v-else class="submit-wrapper__icon" /> + </template> + </NcButton> </template> <script> +import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' +import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' + export default { name: 'LoginButton', + components: { + ArrowRight, + NcButton, + }, props: { + value: { + type: String, + default: t('core', 'Log in'), + }, + valueLoading: { + type: String, + default: t('core', 'Logging in …'), + }, loading: { type: Boolean, required: true, @@ -51,6 +50,8 @@ export default { } </script> -<style scoped> - +<style lang="scss" scoped> +.button-vue { + margin-top: .5rem; +} </style> diff --git a/core/src/components/login/LoginForm.cy.ts b/core/src/components/login/LoginForm.cy.ts new file mode 100644 index 00000000000..1b1aeda6306 --- /dev/null +++ b/core/src/components/login/LoginForm.cy.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import LoginForm from './LoginForm.vue' + +describe('core: LoginForm', { testIsolation: true }, () => { + beforeEach(() => { + // Mock the required global state + cy.window().then(($window) => { + $window.OC = { + theme: { + name: 'J\'s cloud', + }, + requestToken: 'request-token', + } + }) + }) + + /** + * Ensure that characters like ' are not double HTML escaped. + * This was a bug in https://github.com/nextcloud/server/issues/34990 + */ + it('does not double escape special characters in product name', () => { + cy.mount(LoginForm, { + propsData: { + username: 'test-user', + }, + }) + + cy.get('h2').contains('J\'s cloud') + }) + + it('fills username from props into form', () => { + cy.mount(LoginForm, { + propsData: { + username: 'test-user', + }, + }) + + cy.get('input[name="user"]') + .should('exist') + .and('have.attr', 'id', 'user') + + cy.get('input[name="user"]') + .should('have.value', 'test-user') + }) + + it('clears password after timeout', () => { + // mock timeout of 5 seconds + cy.window().then(($window) => { + const state = $window.document.createElement('input') + state.type = 'hidden' + state.id = 'initial-state-core-loginTimeout' + state.value = btoa(JSON.stringify(5)) + $window.document.body.appendChild(state) + }) + + // mount forms + cy.mount(LoginForm) + + cy.get('input[name="password"]') + .should('exist') + .type('MyPassword') + + cy.get('input[name="password"]') + .should('have.value', 'MyPassword') + + // Wait for timeout + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(5100) + + cy.get('input[name="password"]') + .should('have.value', '') + }) +}) diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index 9f0d4f9ba1c..8cbe55f1f68 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -1,48 +1,37 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <form ref="loginForm" + class="login-form" method="post" name="login" :action="loginActionUrl" @submit="submit"> - <fieldset> - <div v-if="apacheAuthFailed" - class="warning"> - {{ t('core', 'Server side authentication failed!') }}<br> - <small>{{ t('core', 'Please contact your administrator.') }} - </small> - </div> - <div v-for="(message, index) in messages" - :key="index" - class="warning"> - {{ message }}<br> - </div> - <div v-if="internalException" - class="warning"> - {{ t('core', 'An internal error occurred.') }}<br> - <small>{{ t('core', 'Please try again or contact your administrator.') }} - </small> - </div> + <fieldset class="login-form__fieldset" data-login-form> + <NcNoteCard v-if="apacheAuthFailed" + :title="t('core', 'Server side authentication failed!')" + type="warning"> + {{ t('core', 'Please contact your administrator.') }} + </NcNoteCard> + <NcNoteCard v-if="csrfCheckFailed" + :heading="t('core', 'Session error')" + type="error"> + {{ t('core', 'It appears your session token has expired, please refresh the page and try again.') }} + </NcNoteCard> + <NcNoteCard v-if="messages.length > 0"> + <div v-for="(message, index) in messages" + :key="index"> + {{ message }}<br> + </div> + </NcNoteCard> + <NcNoteCard v-if="internalException" + :class="t('core', 'An internal error occurred.')" + type="warning"> + {{ t('core', 'Please try again or contact your administrator.') }} + </NcNoteCard> <div id="message" class="hidden"> <img class="float-spinner" @@ -52,55 +41,40 @@ <!-- the following div ensures that the spinner is always inside the #message div --> <div style="clear: both;" /> </div> - <p class="grouptop" - :class="{shake: invalidPassword}"> - <input id="user" - ref="user" - v-model="user" - type="text" - name="user" - autocapitalize="off" - :autocomplete="autoCompleteAllowed ? 'on' : 'off'" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="updateUsername"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - </p> - - <p class="groupbottom" - :class="{shake: invalidPassword}"> - <input id="password" - ref="password" - :type="passwordInputType" - class="password-with-toggle" - name="password" - :autocomplete="autoCompleteAllowed ? 'on' : 'off'" - :placeholder="t('core', 'Password')" - :aria-label="t('core', 'Password')" - required> - <label for="password" - class="infield">{{ t('Password') }}</label> - <a href="#" class="toggle-password" @click.stop.prevent="togglePassword"> - <img :src="toggleIcon"> - </a> - </p> - - <LoginButton :loading="loading" :inverted-colors="invertedColors" /> - - <p v-if="invalidPassword" - class="warning wrongPasswordMsg"> - {{ t('core', 'Wrong username or password.') }} - </p> - <p v-else-if="userDisabled" - class="warning userDisabledMsg"> - {{ t('core', 'User disabled') }} - </p> - - <p v-if="throttleDelay && throttleDelay > 5000" - class="warning throttledMsg"> - {{ t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.') }} - </p> + <h2 class="login-form__headline" data-login-form-headline> + {{ headlineText }} + </h2> + <NcTextField id="user" + ref="user" + :label="loginText" + name="user" + :maxlength="255" + :value.sync="user" + :class="{shake: invalidPassword}" + autocapitalize="none" + :spellchecking="false" + :autocomplete="autoCompleteAllowed ? 'username' : 'off'" + required + :error="userNameInputLengthIs255" + :helper-text="userInputHelperText" + data-login-form-input-user + @change="updateUsername" /> + + <NcPasswordField id="password" + ref="password" + name="password" + :class="{shake: invalidPassword}" + :value.sync="password" + :spellchecking="false" + autocapitalize="none" + :autocomplete="autoCompleteAllowed ? 'current-password' : 'off'" + :label="t('core', 'Password')" + :helper-text="errorLabel" + :error="isError" + data-login-form-input-password + required /> + + <LoginButton data-login-form-submit :loading="loading" /> <input v-if="redirectUrl" type="hidden" @@ -114,7 +88,7 @@ :value="timezoneOffset"> <input type="hidden" name="requesttoken" - :value="OC.requestToken"> + :value="requestToken"> <input v-if="directLogin" type="hidden" name="direct" @@ -124,23 +98,38 @@ </template> <script> -import jstz from 'jstimezonedetect' -import LoginButton from './LoginButton' -import { - generateUrl, - imagePath, -} from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl, imagePath } from '@nextcloud/router' +import debounce from 'debounce' + +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +import AuthMixin from '../../mixins/auth.js' +import LoginButton from './LoginButton.vue' export default { name: 'LoginForm', - components: { LoginButton }, + + components: { + LoginButton, + NcPasswordField, + NcTextField, + NcNoteCard, + }, + + mixins: [AuthMixin], + props: { username: { type: String, default: '', }, redirectUrl: { - type: String, + type: [String, Boolean], + default: false, }, errors: { type: Array, @@ -152,10 +141,7 @@ export default { }, throttleDelay: { type: Number, - }, - invertedColors: { - type: Boolean, - default: false, + default: 0, }, autoCompleteAllowed: { type: Boolean, @@ -165,21 +151,73 @@ export default { type: Boolean, default: false, }, + emailStates: { + type: Array, + default() { + return [] + }, + }, }, + + setup() { + // non reactive props + return { + t, + + // Disable escape and sanitize to prevent special characters to be html escaped + // For example "J's cloud" would be escaped to "J' cloud". But we do not need escaping as Vue does this in `v-text` automatically + headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }), + + loginTimeout: loadState('core', 'loginTimeout', 300), + requestToken: window.OC.requestToken, + timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone, + timezoneOffset: (-new Date().getTimezoneOffset() / 60), + } + }, + data() { return { loading: false, - timezone: jstz.determine().name(), - timezoneOffset: (-new Date().getTimezoneOffset() / 60), - user: this.username, + user: '', password: '', - passwordInputType: 'password', } }, + computed: { + /** + * Reset the login form after a long idle time (debounced) + */ + resetFormTimeout() { + // Infinite timeout, do nothing + if (this.loginTimeout <= 0) { + return () => {} + } + // Debounce for given timeout (in seconds so convert to milli seconds) + return debounce(this.handleResetForm, this.loginTimeout * 1000) + }, + + isError() { + return this.invalidPassword || this.userDisabled + || this.throttleDelay > 5000 + }, + errorLabel() { + if (this.invalidPassword) { + return t('core', 'Wrong login or password.') + } + if (this.userDisabled) { + return t('core', 'This account is disabled') + } + if (this.throttleDelay > 5000) { + return t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.') + } + return undefined + }, apacheAuthFailed() { return this.errors.indexOf('apacheAuthFailed') !== -1 }, + csrfCheckFailed() { + return this.errors.indexOf('csrfCheckFailed') !== -1 + }, internalException() { return this.errors.indexOf('internalexception') !== -1 }, @@ -189,35 +227,60 @@ export default { userDisabled() { return this.errors.indexOf('userdisabled') !== -1 }, - toggleIcon() { - return imagePath('core', 'actions/toggle.svg') - }, loadingIcon() { return imagePath('core', 'loading-dark.gif') }, loginActionUrl() { return generateUrl('login') }, + emailEnabled() { + return this.emailStates ? this.emailStates.every((state) => state === '1') : 1 + }, + loginText() { + if (this.emailEnabled) { + return t('core', 'Account name or email') + } + return t('core', 'Account name') + }, + }, + + watch: { + /** + * Reset form reset after the password was changed + */ + password() { + this.resetFormTimeout() + }, }, + mounted() { if (this.username === '') { - this.$refs.user.focus() + this.$refs.user.$refs.inputField.$refs.input.focus() } else { - this.$refs.password.focus() + this.user = this.username + this.$refs.password.$refs.inputField.$refs.input.focus() } }, + methods: { - togglePassword() { - if (this.passwordInputType === 'password') { - this.passwordInputType = 'text' - } else { - this.passwordInputType = 'password' - } + /** + * Handle reset of the login form after a long IDLE time + * This is recommended security behavior to prevent password leak on public devices + */ + handleResetForm() { + this.password = '' }, + updateUsername() { this.$emit('update:username', this.user) }, - submit() { + submit(event) { + if (this.loading) { + // Prevent the form from being submitted twice + event.preventDefault() + return + } + this.loading = true this.$emit('submit') }, @@ -225,6 +288,27 @@ export default { } </script> -<style scoped> +<style lang="scss" scoped> +.login-form { + text-align: start; + font-size: 1rem; + margin: 0; + &__fieldset { + width: 100%; + display: flex; + flex-direction: column; + gap: .5rem; + } + + &__headline { + text-align: center; + overflow-wrap: anywhere; + } + + // Only show the error state if the user interacted with the login box + :deep(input:invalid:not(:user-invalid)) { + border-color: var(--color-border-maxcontrast) !important; + } +} </style> diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue index df774599f92..bc4d25bf70f 100644 --- a/core/src/components/login/PasswordLessLoginForm.vue +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -1,57 +1,75 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <form v-if="isHttps && hasPublicKeyCredential" + <form v-if="(isHttps || isLocalhost) && supportsWebauthn" ref="loginForm" + aria-labelledby="password-less-login-form-title" + class="password-less-login-form" method="post" name="login" @submit.prevent="submit"> - <fieldset> - <p class="grouptop groupbottom"> - <input id="user" - ref="user" - v-model="user" - type="text" - name="user" - :autocomplete="autoCompleteAllowed ? 'on' : 'off'" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="$emit('update:username', user)"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - </p> - - <div v-if="!validCredentials"> - {{ t('core', 'Your account is not setup for passwordless login.') }} - </div> - - <LoginButton v-if="validCredentials" - :loading="loading" - :inverted-colors="invertedColors" - @click="authenticate" /> - </fieldset> + <h2 id="password-less-login-form-title"> + {{ t('core', 'Log in with a device') }} + </h2> + + <NcTextField required + :value="user" + :autocomplete="autoCompleteAllowed ? 'on' : 'off'" + :error="!validCredentials" + :label="t('core', 'Login or email')" + :placeholder="t('core', 'Login or email')" + :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''" + @update:value="changeUsername" /> + + <LoginButton v-if="validCredentials" + :loading="loading" + @click="authenticate" /> </form> - <div v-else-if="!hasPublicKeyCredential"> - {{ t('core', 'Passwordless authentication is not supported in your browser.') }} - </div> - <div v-else-if="!isHttps"> - {{ t('core', 'Passwordless authentication is only available over a secure connection.') }} - </div> + + <NcEmptyContent v-else-if="!isHttps && !isLocalhost" + :name="t('core', 'Your connection is not secure')" + :description="t('core', 'Passwordless authentication is only available over a secure connection.')"> + <template #icon> + <LockOpenIcon /> + </template> + </NcEmptyContent> + + <NcEmptyContent v-else + :name="t('core', 'Browser not supported')" + :description="t('core', 'Passwordless authentication is not supported in your browser.')"> + <template #icon> + <InformationIcon /> + </template> + </NcEmptyContent> </template> -<script> +<script type="ts"> +import { browserSupportsWebAuthn } from '@simplewebauthn/browser' +import { defineComponent } from 'vue' import { + NoValidCredentials, startAuthentication, finishAuthentication, -} from '../../services/WebAuthnAuthenticationService' -import LoginButton from './LoginButton' +} from '../../services/WebAuthnAuthenticationService.ts' -class NoValidCredentials extends Error { +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcTextField from '@nextcloud/vue/components/NcTextField' -} +import InformationIcon from 'vue-material-design-icons/InformationOutline.vue' +import LoginButton from './LoginButton.vue' +import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue' +import logger from '../../logger' -export default { +export default defineComponent({ name: 'PasswordLessLoginForm', components: { LoginButton, + InformationIcon, + LockOpenIcon, + NcEmptyContent, + NcTextField, }, props: { username: { @@ -59,10 +77,7 @@ export default { default: '', }, redirectUrl: { - type: String, - }, - invertedColors: { - type: Boolean, + type: [String, Boolean], default: false, }, autoCompleteAllowed: { @@ -73,11 +88,18 @@ export default { type: Boolean, default: false, }, - hasPublicKeyCredential: { + isLocalhost: { type: Boolean, default: false, }, }, + + setup() { + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + data() { return { user: this.username, @@ -86,114 +108,37 @@ export default { } }, methods: { - authenticate() { - console.debug('passwordless login initiated') + async authenticate() { + // check required fields + if (!this.$refs.loginForm.checkValidity()) { + return + } - this.getAuthenticationData(this.user) - .then(publicKey => { - console.debug(publicKey) - return publicKey - }) - .then(this.sign) - .then(this.completeAuthentication) - .catch(error => { - if (error instanceof NoValidCredentials) { - this.validCredentials = false - return - } - console.debug(error) - }) - }, - getAuthenticationData(uid) { - const base64urlDecode = function(input) { - // Replace non-url compatible chars with base64 standard chars - input = input - .replace(/-/g, '+') - .replace(/_/g, '/') + console.debug('passwordless login initiated') - // Pad out with standard base64 required padding characters - const pad = input.length % 4 - if (pad) { - if (pad === 1) { - throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding') - } - input += new Array(5 - pad).join('=') + try { + const params = await startAuthentication(this.user) + await this.completeAuthentication(params) + } catch (error) { + if (error instanceof NoValidCredentials) { + this.validCredentials = false + return } - - return window.atob(input) + logger.debug(error) } - - return startAuthentication(uid) - .then(publicKey => { - console.debug('Obtained PublicKeyCredentialRequestOptions') - console.debug(publicKey) - - if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) { - console.debug('No credentials found.') - throw new NoValidCredentials() - } - - publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0)) - publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) { - return { - ...data, - id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)), - } - }) - - console.debug('Converted PublicKeyCredentialRequestOptions') - console.debug(publicKey) - return publicKey - }) - .catch(error => { - console.debug('Error while obtaining data') - throw error - }) }, - sign(publicKey) { - const arrayToBase64String = function(a) { - return window.btoa(String.fromCharCode(...a)) - } - - const arrayToString = function(a) { - return String.fromCharCode(...a) - } - - return navigator.credentials.get({ publicKey }) - .then(data => { - console.debug(data) - console.debug(new Uint8Array(data.rawId)) - console.debug(arrayToBase64String(new Uint8Array(data.rawId))) - return { - id: data.id, - type: data.type, - rawId: arrayToBase64String(new Uint8Array(data.rawId)), - response: { - authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)), - clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)), - signature: arrayToBase64String(new Uint8Array(data.response.signature)), - userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null, - }, - } - }) - .then(challenge => { - console.debug(challenge) - return challenge - }) - .catch(error => { - console.debug('GOT AN ERROR!') - console.debug(error) // Example: timeout, interaction refused... - }) + changeUsername(username) { + this.user = username + this.$emit('update:username', this.user) }, completeAuthentication(challenge) { - console.debug('TIME TO COMPLETE') - - const location = this.redirectUrl + const redirectUrl = this.redirectUrl - return finishAuthentication(JSON.stringify(challenge)) - .then(data => { + return finishAuthentication(challenge) + .then(({ defaultRedirectUrl }) => { console.debug('Logged in redirecting') - window.location.href = location + // Redirect url might be false so || should be used instead of ??. + window.location.href = redirectUrl || defaultRedirectUrl }) .catch(error => { console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!') @@ -204,9 +149,14 @@ export default { // noop }, }, -} +}) </script> -<style scoped> - +<style lang="scss" scoped> +.password-less-login-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; +} </style> diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue index cba7a89fd88..fee1deacc36 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -1,87 +1,68 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <form @submit.prevent="submit"> - <fieldset> - <p> - <input id="user" - v-model="user" - type="text" - name="user" - autocapitalize="off" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="updateUsername"> - <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?> - autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"--> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - </p> - <div id="reset-password-wrapper"> - <input id="reset-password-submit" - type="submit" - class="login primary" - title="" - :value="t('core', 'Reset password')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> - <p v-if="message === 'send-success'" - class="update"> - {{ t('core', 'A password reset message has been sent to the e-mail address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} - <br> - {{ t('core', 'If it is not there ask your local administrator.') }} - </p> - <p v-else-if="message === 'send-error'" - class="update warning"> - {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} - </p> - <p v-else-if="message === 'reset-error'" - class="update warning"> - {{ t('core', 'Password can not be changed. Please contact your administrator.') }} - </p> - <p v-else-if="message" - class="update" - :class="{warning: error}" /> - - <a href="#" - @click.prevent="$emit('abort')"> - {{ t('core', 'Back to login') }} - </a> - </fieldset> + <form class="reset-password-form" @submit.prevent="submit"> + <h2>{{ t('core', 'Reset password') }}</h2> + + <NcTextField id="user" + :value.sync="user" + name="user" + :maxlength="255" + autocapitalize="off" + :label="t('core', 'Login or email')" + :error="userNameInputLengthIs255" + :helper-text="userInputHelperText" + required + @change="updateUsername" /> + + <LoginButton :loading="loading" :value="t('core', 'Reset password')" /> + + <NcButton type="tertiary" wide @click="$emit('abort')"> + {{ t('core', 'Back to login') }} + </NcButton> + + <NcNoteCard v-if="message === 'send-success'" + type="success"> + {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or Login, check your spam/junk folders or ask your local administration for help.') }} + </NcNoteCard> + <NcNoteCard v-else-if="message === 'send-error'" + type="error"> + {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} + </NcNoteCard> + <NcNoteCard v-else-if="message === 'reset-error'" + type="error"> + {{ t('core', 'Password cannot be changed. Please contact your administrator.') }} + </NcNoteCard> </form> </template> -<script> +<script lang="ts"> +import { generateUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + import axios from '@nextcloud/axios' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import { generateUrl } from '@nextcloud/router' +import AuthMixin from '../../mixins/auth.js' +import LoginButton from './LoginButton.vue' +import logger from '../../logger.js' -export default { +export default defineComponent({ name: 'ResetPassword', + components: { + LoginButton, + NcButton, + NcNoteCard, + NcTextField, + }, + + mixins: [AuthMixin], + props: { username: { type: String, @@ -91,16 +72,13 @@ export default { type: String, required: true, }, - invertedColors: { - type: Boolean, - default: false, - }, }, + data() { return { error: false, loading: false, - message: undefined, + message: '', user: this.username, } }, @@ -113,39 +91,38 @@ export default { updateUsername() { this.$emit('update:username', this.user) }, - submit() { + + async submit() { this.loading = true this.error = false this.message = '' const url = generateUrl('/lostpassword/email') - const data = { - user: this.user, - } + try { + const { data } = await axios.post(url, { user: this.user }) + if (data.status !== 'success') { + throw new Error(`got status ${data.status}`) + } - return axios.post(url, data) - .then(resp => resp.data) - .then(data => { - if (data.status !== 'success') { - throw new Error(`got status ${data.status}`) - } - - this.message = 'send-success' - }) - .catch(e => { - console.error('could not send reset e-mail request', e) - - this.error = true - this.message = 'send-error' - }) - .then(() => { this.loading = false }) + this.message = 'send-success' + } catch (error) { + logger.error('could not send reset email request', { error }) + + this.error = true + this.message = 'send-error' + } finally { + this.loading = false + } }, }, -} +}) </script> -<style scoped> - .update { - width: auto; - } +<style lang="scss" scoped> +.reset-password-form { + display: flex; + flex-direction: column; + gap: .5rem; + width: 100%; +} </style> diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue index 3fa3c60773c..b7b9ecccd0a 100644 --- a/core/src/components/login/UpdatePassword.vue +++ b/core/src/components/login/UpdatePassword.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <form @submit.prevent="submit"> @@ -29,6 +12,9 @@ v-model="password" type="password" name="password" + autocomplete="new-password" + autocapitalize="none" + spellcheck="false" required :placeholder="t('core', 'New password')"> </p> @@ -46,18 +32,9 @@ </label> </div> - <div id="submit-wrapper"> - <input id="submit" - type="submit" - class="login primary" - title="" - :value="!loading ? t('core', 'Reset password') : t('core', 'Resetting password')"> - <div class="submit-icon" - :class="{ - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors - }" /> - </div> + <LoginButton :loading="loading" + :value="t('core', 'Reset password')" + :value-loading="t('core', 'Resetting password')" /> <p v-if="error && message" :class="{warning: error}"> {{ message }} @@ -68,9 +45,13 @@ <script> import Axios from '@nextcloud/axios' +import LoginButton from './LoginButton.vue' export default { name: 'UpdatePassword', + components: { + LoginButton, + }, props: { username: { type: String, @@ -80,10 +61,6 @@ export default { type: String, required: true, }, - invertedColors: { - type: Boolean, - default: false, - }, }, data() { return { @@ -125,7 +102,7 @@ export default { } } catch (e) { this.error = true - this.message = e.message ? e.message : t('core', 'Password can not be changed. Please contact your administrator.') + this.message = e.message ? e.message : t('core', 'Password cannot be changed. Please contact your administrator.') } finally { this.loading = false } diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 25158d6e915..f2120c28402 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -1,70 +1,70 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="body-login-container"> + <div class="guest-box" data-cy-setup-recommended-apps> <h2>{{ t('core', 'Recommended apps') }}</h2> <p v-if="loadingApps" class="loading text-center"> {{ t('core', 'Loading apps …') }} </p> <p v-else-if="loadingAppsError" class="loading-error text-center"> - {{ t('core', 'Could not fetch list of apps from the app store.') }} - </p> - <p v-else class="text-center"> - {{ t('core', 'Installing apps …') }} + {{ t('core', 'Could not fetch list of apps from the App Store.') }} </p> + <div v-for="app in recommendedApps" :key="app.id" class="app"> - <img :src="customIcon(app.id)" alt=""> - <div class="info"> - <h3> - {{ app.name }} - <span v-if="app.loading" class="icon icon-loading-small-dark" /> - <span v-else-if="app.active" class="icon icon-checkmark-white" /> - </h3> - <p v-html="customDescription(app.id)" /> - <p v-if="app.installationError"> - <strong>{{ t('core', 'App download or installation failed') }}</strong> - </p> - <p v-else-if="!app.isCompatible"> - <strong>{{ t('core', 'Can\'t install this app because it is not compatible') }}</strong> - </p> - <p v-else-if="!app.canInstall"> - <strong>{{ t('core', 'Can\'t install this app') }}</strong> - </p> - </div> + <template v-if="!isHidden(app.id)"> + <img :src="customIcon(app.id)" alt=""> + <div class="info"> + <h3>{{ customName(app) }}</h3> + <p v-text="customDescription(app.id)" /> + <p v-if="app.installationError"> + <strong>{{ t('core', 'App download or installation failed') }}</strong> + </p> + <p v-else-if="!app.isCompatible"> + <strong>{{ t('core', 'Cannot install this app because it is not compatible') }}</strong> + </p> + <p v-else-if="!app.canInstall"> + <strong>{{ t('core', 'Cannot install this app') }}</strong> + </p> + </div> + <NcCheckboxRadioSwitch :checked="app.isSelected || app.active" + :disabled="!app.isCompatible || app.active" + :loading="app.loading" + @update:checked="toggleSelect(app.id)" /> + </template> + </div> + + <div class="dialog-row"> + <NcButton v-if="showInstallButton && !installingApps" + data-cy-setup-recommended-apps-skip + :href="defaultPageUrl" + variant="tertiary"> + {{ t('core', 'Skip') }} + </NcButton> + + <NcButton v-if="showInstallButton" + data-cy-setup-recommended-apps-install + :disabled="installingApps || !isAnyAppSelected" + variant="primary" + @click.stop.prevent="installApps"> + {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }} + </NcButton> </div> - <p class="text-center"> - <a :href="defaultPageUrl">{{ t('core', 'Cancel') }}</a> - </p> </div> </template> <script> -import axios from '@nextcloud/axios' -import { generateUrl, imagePath } from '@nextcloud/router' +import { t } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' +import { generateUrl, imagePath } from '@nextcloud/router' +import axios from '@nextcloud/axios' import pLimit from 'p-limit' -import { translate as t } from '@nextcloud/l10n' +import logger from '../../logger.js' -import logger from '../../logger' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' const recommended = { calendar: { @@ -80,69 +80,85 @@ const recommended = { icon: imagePath('core', 'actions/mail.svg'), }, spreed: { - description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'), + description: t('core', 'Chatting, video calls, screen sharing, online meetings and web conferencing – in your browser and with mobile apps.'), + icon: imagePath('core', 'apps/spreed.svg'), }, richdocuments: { - description: t('core', 'Collaboratively edit office documents.'), + name: 'Nextcloud Office', + description: t('core', 'Collaborative documents, spreadsheets and presentations, built on Collabora Online.'), + icon: imagePath('core', 'apps/richdocuments.svg'), + }, + notes: { + description: t('core', 'Distraction free note taking app.'), + icon: imagePath('core', 'apps/notes.svg'), }, richdocumentscode: { - description: t('core', 'Local document editing back-end used by the Collabora Online app.'), + hidden: true, }, } const recommendedIds = Object.keys(recommended) -const defaultPageUrl = loadState('core', 'defaultPageUrl') export default { name: 'RecommendedApps', + components: { + NcCheckboxRadioSwitch, + NcButton, + }, data() { return { + showInstallButton: false, + installingApps: false, loadingApps: true, loadingAppsError: false, apps: [], - defaultPageUrl, + defaultPageUrl: loadState('core', 'defaultPageUrl'), } }, computed: { recommendedApps() { return this.apps.filter(app => recommendedIds.includes(app.id)) }, + isAnyAppSelected() { + return this.recommendedApps.some(app => app.isSelected) + }, }, - mounted() { - return axios.get(generateUrl('settings/apps/list')) - .then(resp => resp.data) - .then(data => { - logger.info(`${data.apps.length} apps fetched`) - - this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false })) - logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }) - - this.installApps() - }) - .catch(error => { - logger.error('could not fetch app list', { error }) - - this.loadingAppsError = true - }) - .then(() => { - this.loadingApps = false - }) + async mounted() { + try { + const { data } = await axios.get(generateUrl('settings/apps/list')) + logger.info(`${data.apps.length} apps fetched`) + + this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible })) + logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }) + + this.showInstallButton = true + } catch (error) { + logger.error('could not fetch app list', { error }) + + this.loadingAppsError = true + } finally { + this.loadingApps = false + } }, methods: { installApps() { + this.installingApps = true + const limit = pLimit(1) const installing = this.recommendedApps - .filter(app => !app.active && app.isCompatible && app.canInstall) - .map(app => limit(() => { + .filter(app => !app.active && app.isCompatible && app.canInstall && app.isSelected) + .map(app => limit(async () => { logger.info(`installing ${app.id}`) app.loading = true return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] }) .catch(error => { logger.error(`could not install ${app.id}`, { error }) + app.isSelected = false app.installationError = true }) .then(() => { logger.info(`installed ${app.id}`) app.loading = false + app.active = true }) })) logger.debug(`installing ${installing.length} recommended apps`) @@ -150,7 +166,7 @@ export default { .then(() => { logger.info('all recommended apps installed, redirecting …') - window.location = defaultPageUrl + window.location = this.defaultPageUrl }) .catch(error => logger.error('could not install recommended apps', { error })) }, @@ -161,6 +177,12 @@ export default { } return recommended[appId].icon }, + customName(app) { + if (!(app.id in recommended)) { + return app.name + } + return recommended[app.id].name || app.name + }, customDescription(appId) { if (!(appId in recommended)) { logger.warn(`no app description for recommended app ${appId}`) @@ -168,17 +190,40 @@ export default { } return recommended[appId].description }, + isHidden(appId) { + if (!(appId in recommended)) { + return false + } + return !!recommended[appId].hidden + }, + toggleSelect(appId) { + // disable toggle when installButton is disabled + if (!(appId in recommended) || !this.showInstallButton) { + return + } + const index = this.apps.findIndex(app => app.id === appId) + this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected) + }, }, } </script> <style lang="scss" scoped> -.body-login-container { - +.dialog-row { + display: flex; + justify-content: end; + margin-top: 8px; } -p.loading, p.loading-error { - height: 100px; +p { + &.loading, + &.loading-error { + height: 100px; + } + + &:last-child { + margin-top: 10px; + } } .text-center { @@ -192,7 +237,7 @@ p.loading, p.loading-error { img { height: 50px; width: 50px; - filter: invert(1); + filter: var(--background-invert-if-dark); } img, .info { @@ -201,17 +246,17 @@ p.loading, p.loading-error { .info { h3, p { - text-align: left; + text-align: start; } h3 { - color: #fff; margin-top: 0; } + } - h3 > span.icon { - display: inline-block; - } + .checkbox-radio-switch { + margin-inline-start: auto; + padding: 0 2px; } } </style> diff --git a/core/src/eventbus.d.ts b/core/src/eventbus.d.ts new file mode 100644 index 00000000000..4fac9bc7841 --- /dev/null +++ b/core/src/eventbus.d.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare module '@nextcloud/event-bus' { + export interface NextcloudEvents { + // mapping of 'event name' => 'event type' + 'nextcloud:unified-search:reset': undefined + 'nextcloud:unified-search:search': { query: string } + } +} + +export {} diff --git a/core/src/files/client.js b/core/src/files/client.js index 4e606f4f5ac..7c69a65161b 100644 --- a/core/src/files/client.js +++ b/core/src/files/client.js @@ -1,14 +1,10 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import escapeHTML from 'escape-html' /* global dav */ @@ -20,7 +16,7 @@ import escapeHTML from 'escape-html' * * @param {Object} options * @param {String} options.host host name - * @param {int} [options.port] port + * @param {number} [options.port] port * @param {boolean} [options.useHTTPS] whether to use https * @param {String} [options.root] root path * @param {String} [options.userName] user name @@ -82,6 +78,7 @@ import escapeHTML from 'escape-html' Client.PROPERTY_GETCONTENTLENGTH = '{' + Client.NS_DAV + '}getcontentlength' Client.PROPERTY_ISENCRYPTED = '{' + Client.NS_DAV + '}is-encrypted' Client.PROPERTY_SHARE_PERMISSIONS = '{' + Client.NS_OCS + '}share-permissions' + Client.PROPERTY_SHARE_ATTRIBUTES = '{' + Client.NS_NEXTCLOUD + '}share-attributes' Client.PROPERTY_QUOTA_AVAILABLE_BYTES = '{' + Client.NS_DAV + '}quota-available-bytes' Client.PROTOCOL_HTTP = 'http' @@ -138,6 +135,10 @@ import escapeHTML from 'escape-html' * Share permissions */ [Client.NS_OCS, 'share-permissions'], + /** + * Share attributes + */ + [Client.NS_NEXTCLOUD, 'share-attributes'], ] /** @@ -394,6 +395,18 @@ import escapeHTML from 'escape-html' data.sharePermissions = parseInt(sharePermissionsProp) } + const shareAttributesProp = props[Client.PROPERTY_SHARE_ATTRIBUTES] + if (!_.isUndefined(shareAttributesProp)) { + try { + data.shareAttributes = JSON.parse(shareAttributesProp) + } catch (e) { + console.warn('Could not parse share attributes returned by server: "' + shareAttributesProp + '"') + data.shareAttributes = []; + } + } else { + data.shareAttributes = []; + } + const mounTypeProp = props['{' + Client.NS_NEXTCLOUD + '}mount-type'] if (!_.isUndefined(mounTypeProp)) { data.mountType = mounTypeProp @@ -427,7 +440,7 @@ import escapeHTML from 'escape-html' /** * Returns whether the given status code means success * - * @param {int} status status code + * @param {number} status status code * * @returns true if status code is between 200 and 299 included */ @@ -524,7 +537,7 @@ import escapeHTML from 'escape-html' * * @param {Object} filter filter criteria * @param {Object} [filter.systemTagIds] list of system tag ids to filter by - * @param {bool} [filter.favorite] set it to filter by favorites + * @param {boolean} [filter.favorite] set it to filter by favorites * @param {Object} [options] options * @param {Array} [options.properties] list of Webdav properties to retrieve * @@ -676,7 +689,7 @@ import escapeHTML from 'escape-html' * @param {String} body file body * @param {Object} [options] * @param {String} [options.contentType='text/plain'] content type - * @param {bool} [options.overwrite=true] whether to overwrite an existing file + * @param {boolean} [options.overwrite=true] whether to overwrite an existing file * * @returns {Promise} */ @@ -719,7 +732,7 @@ import escapeHTML from 'escape-html' return promise }, - _simpleCall: function(method, path) { + _simpleCall: function(method, path, headers) { if (!path) { throw 'Missing argument "path"' } @@ -730,7 +743,8 @@ import escapeHTML from 'escape-html' this._client.request( method, - this._buildUrl(path) + this._buildUrl(path), + headers ? headers : {} ).then( function(result) { if (self._isSuccessStatus(result.status)) { @@ -751,8 +765,8 @@ import escapeHTML from 'escape-html' * * @returns {Promise} */ - createDirectory: function(path) { - return this._simpleCall('MKCOL', path) + createDirectory: function(path, headers) { + return this._simpleCall('MKCOL', path, headers) }, /** diff --git a/core/src/files/fileinfo.js b/core/src/files/fileinfo.js index 19baad5a2d7..7ebe06a8349 100644 --- a/core/src/files/fileinfo.js +++ b/core/src/files/fileinfo.js @@ -1,14 +1,10 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ (function(OC) { /** @@ -140,7 +136,23 @@ */ sharePermissions: null, + /** + * @type Array + */ + shareAttributes: [], + quotaAvailableBytes: -1, + + canDownload: function() { + for (const i in this.shareAttributes) { + const attr = this.shareAttributes[i] + if (attr.scope === 'permissions' && attr.key === 'download') { + return attr.value === true + } + } + + return true + }, } if (!OC.Files) { diff --git a/core/src/files/iedavclient.js b/core/src/files/iedavclient.js deleted file mode 100644 index 447d05c0497..00000000000 --- a/core/src/files/iedavclient.js +++ /dev/null @@ -1,162 +0,0 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -/* global dav */ -(function(dav) { - - /** - * Override davclient.js methods with IE-compatible logic - */ - dav.Client.prototype = _.extend({}, dav.Client.prototype, { - - /** - * Performs a HTTP request, and returns a Promise - * - * @param {string} method HTTP method - * @param {string} url Relative or absolute url - * @param {Object} headers HTTP headers as an object. - * @param {string} body HTTP request body. - * @returns {Promise} - */ - request: function(method, url, headers, body) { - - const self = this - const xhr = this.xhrProvider() - headers = headers || {} - - if (this.userName) { - headers.Authorization = 'Basic ' + btoa(this.userName + ':' + this.password) - // xhr.open(method, this.resolveUrl(url), true, this.userName, this.password); - } - xhr.open(method, this.resolveUrl(url), true) - let ii - for (ii in headers) { - xhr.setRequestHeader(ii, headers[ii]) - } - - if (body === undefined) { - xhr.send() - } else { - xhr.send(body) - } - - return new Promise(function(fulfill, reject) { - - xhr.onreadystatechange = function() { - - if (xhr.readyState !== 4) { - return - } - - let resultBody = xhr.response - if (xhr.status === 207) { - resultBody = self.parseMultiStatus(xhr.responseXML) - } - - fulfill({ - body: resultBody, - status: xhr.status, - xhr: xhr, - }) - - } - - xhr.ontimeout = function() { - - reject(new Error('Timeout exceeded')) - - } - - }) - - }, - - _getElementsByTagName: function(node, name, resolver) { - const parts = name.split(':') - const tagName = parts[1] - const namespace = resolver(parts[0]) - // make sure we can get elements - if (typeof node === 'string') { - const parser = new DOMParser() - node = parser.parseFromString(node, 'text/xml') - } - if (node.getElementsByTagNameNS) { - return node.getElementsByTagNameNS(namespace, tagName) - } - return node.getElementsByTagName(name) - }, - - /** - * Parses a multi-status response body. - * - * @param {string} xmlBody - * @param {Array} - */ - parseMultiStatus: function(doc) { - const result = [] - const resolver = function(foo) { - let ii - for (ii in this.xmlNamespaces) { - if (this.xmlNamespaces[ii] === foo) { - return ii - } - } - }.bind(this) - - const responses = this._getElementsByTagName(doc, 'd:response', resolver) - let i - for (i = 0; i < responses.length; i++) { - const responseNode = responses[i] - const response = { - href: null, - propStat: [], - } - - const hrefNode = this._getElementsByTagName(responseNode, 'd:href', resolver)[0] - - response.href = hrefNode.textContent || hrefNode.text - - const propStatNodes = this._getElementsByTagName(responseNode, 'd:propstat', resolver) - let j = 0 - - for (j = 0; j < propStatNodes.length; j++) { - const propStatNode = propStatNodes[j] - const statusNode = this._getElementsByTagName(propStatNode, 'd:status', resolver)[0] - - const propStat = { - status: statusNode.textContent || statusNode.text, - properties: [], - } - - const propNode = this._getElementsByTagName(propStatNode, 'd:prop', resolver)[0] - if (!propNode) { - continue - } - let k = 0 - for (k = 0; k < propNode.childNodes.length; k++) { - const prop = propNode.childNodes[k] - const value = this._parsePropNode(prop) - propStat.properties['{' + prop.namespaceURI + '}' + (prop.localName || prop.baseName)] = value - - } - response.propStat.push(propStat) - } - - result.push(response) - } - - return result - - }, - - }) - -})(dav) diff --git a/core/src/globals.js b/core/src/globals.js index e0d2c4b8ead..4b07cc17c3e 100644 --- a/core/src/globals.js +++ b/core/src/globals.js @@ -1,62 +1,39 @@ -/* eslint-disable @nextcloud/no-deprecations */ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { initCore } from './init' +/* eslint-disable @nextcloud/no-deprecations */ +import { initCore } from './init.js' import _ from 'underscore' import $ from 'jquery' -import 'jquery-migrate/dist/jquery-migrate.min' // TODO: switch to `jquery-ui` package and import widgets and effects individually // `jquery-ui-dist` is used as a workaround for the issue of missing effects -import 'jquery-ui-dist/jquery-ui' +import 'jquery-ui-dist/jquery-ui.js' import 'jquery-ui-dist/jquery-ui.css' import 'jquery-ui-dist/jquery-ui.theme.css' // END TODO -import autosize from 'autosize' import Backbone from 'backbone' -import 'bootstrap/js/dist/tooltip' -import './Polyfill/tooltip' import ClipboardJS from 'clipboard' import { dav } from 'davclient.js' import Handlebars from 'handlebars' -import 'jcrop/js/jquery.Jcrop' -import 'jcrop/css/jquery.Jcrop.css' -import jstimezonedetect from 'jstimezonedetect' import md5 from 'blueimp-md5' import moment from 'moment' import 'select2' import 'select2/select2.css' -import 'snap.js/dist/snap' +import 'snap.js/dist/snap.js' import 'strengthify' import 'strengthify/strengthify.css' -import OC from './OC/index' -import OCP from './OCP/index' -import OCA from './OCA/index' -import { getToken as getRequestToken } from './OC/requesttoken' +import OC from './OC/index.js' +import OCP from './OCP/index.js' +import OCA from './OCA/index.js' +import { getRequestToken } from './OC/requesttoken.ts' const warnIfNotTesting = function() { if (window.TESTING === undefined) { - console.warn.apply(console, arguments) + OC.debug && console.warn.apply(console, arguments) } } @@ -65,9 +42,9 @@ const warnIfNotTesting = function() { * warn if used! * * @param {Function} func the library to deprecate - * @param {String} funcName the name of the library - * @param {Int} version the version this gets removed - * @returns {function} + * @param {string} funcName the name of the library + * @param {number} version the version this gets removed + * @return {Function} */ const deprecate = (func, funcName, version) => { const oldFunc = func @@ -80,7 +57,7 @@ const deprecate = (func, funcName, version) => { } const setDeprecatedProp = (global, cb, msg) => { - (Array.isArray(global) ? global : [global]).map(global => { + (Array.isArray(global) ? global : [global]).forEach(global => { if (window[global] !== undefined) { delete window[global] } @@ -99,13 +76,12 @@ const setDeprecatedProp = (global, cb, msg) => { } window._ = _ -setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be updated to v3.x in Nextcloud 21. In later versions of Nextcloud it might be removed completely. Please ship your own.') -setDeprecatedProp('autosize', () => autosize, 'please ship your own, this will be removed in Nextcloud 20') +setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be removed in a later versions without another warning. Please ship your own.') setDeprecatedProp('Backbone', () => Backbone, 'please ship your own, this will be removed in Nextcloud 20') setDeprecatedProp(['Clipboard', 'ClipboardJS'], () => ClipboardJS, 'please ship your own, this will be removed in Nextcloud 20') window.dav = dav setDeprecatedProp('Handlebars', () => Handlebars, 'please ship your own, this will be removed in Nextcloud 20') -setDeprecatedProp(['jstz', 'jstimezonedetect'], () => jstimezonedetect, 'please ship your own, this will be removed in Nextcloud 20') +// Global md5 only required for: apps/files/js/file-upload.js setDeprecatedProp('md5', () => md5, 'please ship your own, this will be removed in Nextcloud 20') setDeprecatedProp('moment', () => moment, 'please ship your own, this will be removed in Nextcloud 20') @@ -126,6 +102,7 @@ $.fn.select2 = deprecate($.fn.select2, 'select2', 19) /** * translate a string + * * @param {string} app the id of the app for which to translate the string * @param {string} text the string to translate * @param [vars] map of placeholder key to value @@ -136,6 +113,7 @@ window.t = _.bind(OC.L10N.translate, OC.L10N) /** * translate a string + * * @param {string} app the id of the app for which to translate the string * @param {string} text_singular the string to translate for exactly one object * @param {string} text_plural the string to translate for n objects diff --git a/core/src/icons.js b/core/src/icons.js new file mode 100644 index 00000000000..5845b01fea1 --- /dev/null +++ b/core/src/icons.js @@ -0,0 +1,357 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable quote-props */ +/* eslint-disable n/no-unpublished-import */ +import path from 'path' +import fs from 'fs' +import sass from 'sass' + +const colors = { + dark: '000', + white: 'fff', + // gold but for backwards compatibility called yellow + yellow: 'a08b00', + red: 'e9322d', + orange: 'eca700', + green: '46ba61', + grey: '969696', +} + +const variables = {} +const icons = { + 'add': path.join(__dirname, '../img', 'actions', 'add.svg'), + 'address': path.join(__dirname, '../img', 'actions', 'address.svg'), + 'alert-outline': path.join(__dirname, '../img', 'actions', 'alert-outline.svg'), + 'audio-off': path.join(__dirname, '../img', 'actions', 'audio-off.svg'), + 'audio': path.join(__dirname, '../img', 'actions', 'audio.svg'), + 'calendar': path.join(__dirname, '../img', 'places', 'calendar.svg'), + 'caret': path.join(__dirname, '../img', 'actions', 'caret.svg'), + 'category-app-bundles': path.join(__dirname, '../img', 'categories', 'bundles.svg'), + 'category-auth': path.join(__dirname, '../img', 'categories', 'auth.svg'), + 'category-customization': path.join(__dirname, '../img', 'categories', 'customization.svg'), + 'category-dashboard': path.join(__dirname, '../img', 'categories', 'dashboard.svg'), + 'category-files': path.join(__dirname, '../img', 'categories', 'files.svg'), + 'category-games': path.join(__dirname, '../img', 'categories', 'games.svg'), + 'category-integration': path.join(__dirname, '../img', 'categories', 'integration.svg'), + 'category-monitoring': path.join(__dirname, '../img', 'categories', 'monitoring.svg'), + 'category-multimedia': path.join(__dirname, '../img', 'categories', 'multimedia.svg'), + 'category-office': path.join(__dirname, '../img', 'categories', 'office.svg'), + 'category-organization': path.join(__dirname, '../img', 'categories', 'organization.svg'), + 'category-social': path.join(__dirname, '../img', 'categories', 'social.svg'), + 'category-workflow': path.join(__dirname, '../img', 'categories', 'workflow.svg'), + 'change': path.join(__dirname, '../img', 'actions', 'change.svg'), + 'checkmark': path.join(__dirname, '../img', 'actions', 'checkmark.svg'), + 'circles': path.join(__dirname, '../img', 'apps', 'circles.svg'), + 'clippy': path.join(__dirname, '../img', 'actions', 'clippy.svg'), + 'close': path.join(__dirname, '../img', 'actions', 'close.svg'), + 'comment': path.join(__dirname, '../img', 'actions', 'comment.svg'), + 'confirm-fade': path.join(__dirname, '../img', 'actions', 'confirm-fade.svg'), + 'confirm': path.join(__dirname, '../img', 'actions', 'confirm.svg'), + 'contacts': path.join(__dirname, '../img', 'places', 'contacts.svg'), + 'delete': path.join(__dirname, '../img', 'actions', 'delete.svg'), + 'desktop': path.join(__dirname, '../img', 'clients', 'desktop.svg'), + 'details': path.join(__dirname, '../img', 'actions', 'details.svg'), + 'disabled-user': path.join(__dirname, '../img', 'actions', 'disabled-user.svg'), + 'disabled-users': path.join(__dirname, '../img', 'actions', 'disabled-users.svg'), + 'download': path.join(__dirname, '../img', 'actions', 'download.svg'), + 'edit': path.join(__dirname, '../img', 'actions', 'edit.svg'), + 'encryption': path.join(__dirname, '../../', 'apps/files_external/img', 'app.svg'), + 'error': path.join(__dirname, '../img', 'actions', 'error.svg'), + 'external': path.join(__dirname, '../img', 'actions', 'external.svg'), + 'favorite': path.join(__dirname, '../img', 'actions', 'star-dark.svg'), + 'files': path.join(__dirname, '../img', 'places', 'files.svg'), + 'filter': path.join(__dirname, '../img', 'actions', 'filter.svg'), + 'folder': path.join(__dirname, '../img', 'filetypes', 'folder.svg'), + 'fullscreen': path.join(__dirname, '../img', 'actions', 'fullscreen.svg'), + 'group': path.join(__dirname, '../img', 'actions', 'group.svg'), + 'history': path.join(__dirname, '../img', 'actions', 'history.svg'), + 'home': path.join(__dirname, '../img', 'places', 'home.svg'), + 'info': path.join(__dirname, '../img', 'actions', 'info.svg'), + 'link': path.join(__dirname, '../img', 'places', 'link.svg'), + 'logout': path.join(__dirname, '../img', 'actions', 'logout.svg'), + 'mail': path.join(__dirname, '../img', 'actions', 'mail.svg'), + 'menu-sidebar': path.join(__dirname, '../img', 'actions', 'menu-sidebar.svg'), + 'menu': path.join(__dirname, '../img', 'actions', 'menu.svg'), + 'more': path.join(__dirname, '../img', 'actions', 'more.svg'), + 'music': path.join(__dirname, '../img', 'places', 'music.svg'), + 'password': path.join(__dirname, '../img', 'actions', 'password.svg'), + 'pause': path.join(__dirname, '../img', 'actions', 'pause.svg'), + 'phone': path.join(__dirname, '../img', 'clients', 'phone.svg'), + 'picture': path.join(__dirname, '../img', 'places', 'picture.svg'), + 'play-add': path.join(__dirname, '../img', 'actions', 'play-add.svg'), + 'play-next': path.join(__dirname, '../img', 'actions', 'play-next.svg'), + 'play-previous': path.join(__dirname, '../img', 'actions', 'play-previous.svg'), + 'play': path.join(__dirname, '../img', 'actions', 'play.svg'), + 'projects': path.join(__dirname, '../img', 'actions', 'projects.svg'), + 'public': path.join(__dirname, '../img', 'actions', 'public.svg'), + 'quota': path.join(__dirname, '../img', 'actions', 'quota.svg'), + 'recent': path.join(__dirname, '../img', 'actions', 'recent.svg'), + 'rename': path.join(__dirname, '../img', 'actions', 'rename.svg'), + 'screen-off': path.join(__dirname, '../img', 'actions', 'screen-off.svg'), + 'screen': path.join(__dirname, '../img', 'actions', 'screen.svg'), + 'search': path.join(__dirname, '../img', 'actions', 'search.svg'), + 'settings': path.join(__dirname, '../img', 'actions', 'settings-dark.svg'), + 'share': path.join(__dirname, '../img', 'actions', 'share.svg'), + 'shared': path.join(__dirname, '../img', 'actions', 'share.svg'), + 'sound-off': path.join(__dirname, '../img', 'actions', 'sound-off.svg'), + 'sound': path.join(__dirname, '../img', 'actions', 'sound.svg'), + 'star': path.join(__dirname, '../img', 'actions', 'star.svg'), + 'starred': path.join(__dirname, '../img', 'actions', 'star-dark.svg'), + 'star-rounded': path.join(__dirname, '../img', 'actions', 'star-rounded.svg'), + 'tablet': path.join(__dirname, '../img', 'clients', 'tablet.svg'), + 'tag': path.join(__dirname, '../img', 'actions', 'tag.svg'), + 'talk': path.join(__dirname, '../img', 'apps', 'spreed.svg'), + 'teams': path.join(__dirname, '../img', 'apps', 'circles.svg'), + 'template-add': path.join(__dirname, '../img', 'actions', 'template-add.svg'), + 'timezone': path.join(__dirname, '../img', 'actions', 'timezone.svg'), + 'toggle-background': path.join(__dirname, '../img', 'actions', 'toggle-background.svg'), + 'toggle-filelist': path.join(__dirname, '../img', 'actions', 'toggle-filelist.svg'), + 'toggle-pictures': path.join(__dirname, '../img', 'actions', 'toggle-pictures.svg'), + 'toggle': path.join(__dirname, '../img', 'actions', 'toggle.svg'), + 'triangle-e': path.join(__dirname, '../img', 'actions', 'triangle-e.svg'), + 'triangle-n': path.join(__dirname, '../img', 'actions', 'triangle-n.svg'), + 'triangle-s': path.join(__dirname, '../img', 'actions', 'triangle-s.svg'), + 'unshare': path.join(__dirname, '../img', 'actions', 'unshare.svg'), + 'upload': path.join(__dirname, '../img', 'actions', 'upload.svg'), + 'user-admin': path.join(__dirname, '../img', 'actions', 'user-admin.svg'), + 'user': path.join(__dirname, '../img', 'actions', 'user.svg'), + 'video-off': path.join(__dirname, '../img', 'actions', 'video-off.svg'), + 'video-switch': path.join(__dirname, '../img', 'actions', 'video-switch.svg'), + 'video': path.join(__dirname, '../img', 'actions', 'video.svg'), + 'view-close': path.join(__dirname, '../img', 'actions', 'view-close.svg'), + 'view-download': path.join(__dirname, '../img', 'actions', 'view-download.svg'), + 'view-next': path.join(__dirname, '../img', 'actions', 'arrow-right.svg'), + 'view-pause': path.join(__dirname, '../img', 'actions', 'view-pause.svg'), + 'view-play': path.join(__dirname, '../img', 'actions', 'view-play.svg'), + 'view-previous': path.join(__dirname, '../img', 'actions', 'arrow-left.svg'), +} + +const iconsColor = { + 'add-folder-description': { + path: path.join(__dirname, '../img', 'actions', 'add-folder-description.svg'), + color: 'grey', + }, + 'settings': { + path: path.join(__dirname, '../img', 'actions', 'settings.svg'), + color: 'black', + }, + 'error-color': { + path: path.join(__dirname, '../img', 'actions', 'error.svg'), + color: 'red', + }, + 'checkmark-color': { + path: path.join(__dirname, '../img', 'actions', 'checkmark.svg'), + color: 'green', + }, + 'starred': { + path: path.join(__dirname, '../img', 'actions', 'star-dark.svg'), + color: 'yellow', + }, + 'star': { + path: path.join(__dirname, '../img', 'actions', 'star-dark.svg'), + color: 'grey', + }, + 'delete-color': { + path: path.join(__dirname, '../img', 'actions', 'delete.svg'), + color: 'red', + }, + 'file': { + path: path.join(__dirname, '../img', 'filetypes', 'text.svg'), + color: 'grey', + }, + 'filetype-file': { + path: path.join(__dirname, '../img', 'filetypes', 'file.svg'), + color: 'grey', + }, + 'filetype-folder': { + path: path.join(__dirname, '../img', 'filetypes', 'folder.svg'), + // TODO: replace primary ? + color: 'primary', + }, + 'filetype-folder-drag-accept': { + path: path.join(__dirname, '../img', 'filetypes', 'folder-drag-accept.svg'), + // TODO: replace primary ? + color: 'primary', + }, + 'filetype-text': { + path: path.join(__dirname, '../img', 'filetypes', 'text.svg'), + color: 'grey', + }, + 'file-text': { + path: path.join(__dirname, '../img', 'filetypes', 'text.svg'), + color: 'black', + }, +} + +// use this to define aliases to existing icons +// key is the css selector, value is the variable +const iconsAliases = { + 'icon-caret': 'icon-caret-white', + // starring action + 'icon-star:hover': 'icon-starred', + 'icon-star:focus': 'icon-starred', + // Un-starring action + 'icon-starred:hover': 'icon-star-grey', + 'icon-starred:focus': 'icon-star-grey', + // Delete normal + 'icon-delete.no-permission:hover': 'icon-delete-dark', + 'icon-delete.no-permission:focus': 'icon-delete-dark', + 'icon-delete.no-hover:hover': 'icon-delete-dark', + 'icon-delete.no-hover:focus': 'icon-delete-dark', + 'icon-delete:hover': 'icon-delete-color-red', + 'icon-delete:focus': 'icon-delete-color-red', + // Delete white + 'icon-delete-white.no-permission:hover': 'icon-delete-white', + 'icon-delete-white.no-permission:focus': 'icon-delete-white', + 'icon-delete-white.no-hover:hover': 'icon-delete-white', + 'icon-delete-white.no-hover:focus': 'icon-delete-white', + 'icon-delete-white:hover': 'icon-delete-color-red', + 'icon-delete-white:focus': 'icon-delete-color-red', + // Default to white + 'icon-view-close': 'icon-view-close-white', + 'icon-view-download': 'icon-view-download-white', + 'icon-view-pause': 'icon-view-pause-white', + 'icon-view-play': 'icon-view-play-white', + // Default app place to white + 'icon-calendar': 'icon-calendar-white', + 'icon-contacts': 'icon-contacts-white', + 'icon-files': 'icon-files-white', + // Re-using existing icons + 'icon-category-installed': 'icon-user-dark', + 'icon-category-enabled': 'icon-checkmark-dark', + 'icon-category-disabled': 'icon-close-dark', + 'icon-category-updates': 'icon-download-dark', + 'icon-category-security': 'icon-password-dark', + 'icon-category-search': 'icon-search-dark', + 'icon-category-tools': 'icon-settings-dark', + 'nav-icon-systemtagsfilter': 'icon-tag-dark', +} + +const colorSvg = function(svg = '', color = '000') { + if (!color.match(/^[0-9a-f]{3,6}$/i)) { + // Prevent not-sane colors from being written into the SVG + console.warn(color, 'does not match the required format') + color = '000' + } + + // add fill (fill is not present on black elements) + const fillRe = /<((circle|rect|path)((?!fill=)[a-z0-9 =".\-#():;,])+)\/>/gmi + svg = svg.replace(fillRe, '<$1 fill="#' + color + '"/>') + + // replace any fill or stroke colors + svg = svg.replace(/stroke="#([a-z0-9]{3,6})"/gmi, 'stroke="#' + color + '"') + svg = svg.replace(/fill="#([a-z0-9]{3,6})"/gmi, 'fill="#' + color + '"') + + return svg +} + +const generateVariablesAliases = function(invert = false) { + let css = '' + Object.keys(variables).forEach(variable => { + if (variable.indexOf('original-') !== -1) { + let finalVariable = variable.replace('original-', '') + if (invert) { + finalVariable = finalVariable.replace('white', 'tempwhite') + .replace('dark', 'white') + .replace('tempwhite', 'dark') + } + css += `${finalVariable}: var(${variable});` + } + }) + return css +} + +const formatIcon = function(icon, invert = false) { + const color1 = invert ? 'white' : 'dark' + const color2 = invert ? 'dark' : 'white' + return ` + .icon-${icon}, + .icon-${icon}-dark { + background-image: var(--icon-${icon}-${color1}); + } + .icon-${icon}-white, + .icon-${icon}.icon-white { + background-image: var(--icon-${icon}-${color2}); + }` +} +const formatIconColor = function(icon) { + const { color } = iconsColor[icon] + return ` + .icon-${icon} { + background-image: var(--icon-${icon}-${color}); + }` +} +const formatAlias = function(alias, invert = false) { + let icon = iconsAliases[alias] + if (invert) { + icon = icon.replace('white', 'tempwhite') + .replace('dark', 'white') + .replace('tempwhite', 'dark') + } + return ` + .${alias} { + background-image: var(--${icon}) + }` +} + +let css = '' +Object.keys(icons).forEach(icon => { + const path = icons[icon] + + const svg = fs.readFileSync(path, 'utf8') + const darkSvg = colorSvg(svg, '000000') + const whiteSvg = colorSvg(svg, 'ffffff') + + variables[`--original-icon-${icon}-dark`] = Buffer.from(darkSvg, 'utf-8').toString('base64') + variables[`--original-icon-${icon}-white`] = Buffer.from(whiteSvg, 'utf-8').toString('base64') +}) + +Object.keys(iconsColor).forEach(icon => { + const { path, color } = iconsColor[icon] + + const svg = fs.readFileSync(path, 'utf8') + const coloredSvg = colorSvg(svg, colors[color]) + variables[`--icon-${icon}-${color}`] = Buffer.from(coloredSvg, 'utf-8').toString('base64') +}) + +// ICONS VARIABLES LIST +css += ':root {' +Object.keys(variables).forEach(variable => { + const data = variables[variable] + css += `${variable}: url(data:image/svg+xml;base64,${data});` +}) +css += '}' + +// DEFAULT THEME +css += 'body {' +css += generateVariablesAliases() +Object.keys(icons).forEach(icon => { + css += formatIcon(icon) +}) +Object.keys(iconsColor).forEach(icon => { + css += formatIconColor(icon) +}) +Object.keys(iconsAliases).forEach(alias => { + css += formatAlias(alias) +}) +css += '}' + +// DARK THEME MEDIA QUERY +css += '@media (prefers-color-scheme: dark) { body {' +css += generateVariablesAliases(true) +css += '}}' + +// DARK THEME +css += '[data-themes*=light] {' +css += generateVariablesAliases() +css += '}' + +// DARK THEME +css += '[data-themes*=dark] {' +css += generateVariablesAliases(true) +css += '}' + +// WRITE CSS +fs.writeFileSync(path.join(__dirname, '../../dist', 'icons.css'), sass.compileString(css).css) diff --git a/core/src/init.js b/core/src/init.js index b6bb49346bd..1bcd8218702 100644 --- a/core/src/init.js +++ b/core/src/init.js @@ -1,135 +1,65 @@ -/* globals Snap */ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* globals Snap */ import _ from 'underscore' import $ from 'jquery' import moment from 'moment' -import cssVars from 'css-vars-ponyfill' -import { initSessionHeartBeat } from './session-heartbeat' -import OC from './OC/index' -import { setUp as setUpContactsMenu } from './components/ContactsMenu' -import { setUp as setUpMainMenu } from './components/MainMenu' -import { setUp as setUpUserMenu } from './components/UserMenu' -import PasswordConfirmation from './OC/password-confirmation' +import OC from './OC/index.js' +import { initSessionHeartBeat } from './session-heartbeat.ts' +import { setUp as setUpContactsMenu } from './components/ContactsMenu.js' +import { setUp as setUpMainMenu } from './components/MainMenu.js' +import { setUp as setUpUserMenu } from './components/UserMenu.js' +import { interceptRequests } from './utils/xhr-request.js' +import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts' // keep in sync with core/css/variables.scss const breakpointMobileWidth = 1024 -const resizeMenu = () => { - const appList = $('#appmenu li') - const rightHeaderWidth = $('.header-right').outerWidth() - const headerWidth = $('header').outerWidth() - const usePercentualAppMenuLimit = 0.67 - const minAppsDesktop = 12 - let availableWidth = headerWidth - $('#nextcloud').outerWidth() - (rightHeaderWidth > 210 ? rightHeaderWidth : 210) - const isMobile = $(window).width() < breakpointMobileWidth - if (!isMobile) { - availableWidth = availableWidth * usePercentualAppMenuLimit - } - let appCount = Math.floor((availableWidth / $(appList).width())) - if (isMobile && appCount > minAppsDesktop) { - appCount = minAppsDesktop - } - if (!isMobile && appCount < minAppsDesktop) { - appCount = minAppsDesktop - } - - // show at least 2 apps in the popover - if (appList.length - 1 - appCount >= 1) { - appCount-- - } - - $('#more-apps a').removeClass('active') - let lastShownApp - for (let k = 0; k < appList.length - 1; k++) { - const name = $(appList[k]).data('id') - if (k < appCount) { - $(appList[k]).removeClass('hidden') - $('#apps li[data-id=' + name + ']').addClass('in-header') - lastShownApp = appList[k] - } else { - $(appList[k]).addClass('hidden') - $('#apps li[data-id=' + name + ']').removeClass('in-header') - // move active app to last position if it is active - if (appCount > 0 && $(appList[k]).children('a').hasClass('active')) { - $(lastShownApp).addClass('hidden') - $('#apps li[data-id=' + $(lastShownApp).data('id') + ']').removeClass('in-header') - $(appList[k]).removeClass('hidden') - $('#apps li[data-id=' + name + ']').addClass('in-header') - } - } - } - - // show/hide more apps icon - if ($('#apps li:not(.in-header)').length === 0) { - $('#more-apps').hide() - $('#navigation').hide() - } else { - $('#more-apps').show() - } -} - const initLiveTimestamps = () => { // Update live timestamps every 30 seconds setInterval(() => { $('.live-relative-timestamp').each(function() { - $(this).text(OC.Util.relativeModifiedDate(parseInt($(this).attr('data-timestamp'), 10))) + const timestamp = parseInt($(this).attr('data-timestamp'), 10) + $(this).text(moment(timestamp).fromNow()) }) }, 30 * 1000) } /** + * Moment doesn't have aliases for every locale and doesn't parse some locale IDs correctly so we need to alias them + */ +const localeAliases = { + zh: 'zh-cn', + zh_Hans: 'zh-cn', + zh_Hans_CN: 'zh-cn', + zh_Hans_HK: 'zh-cn', + zh_Hans_MO: 'zh-cn', + zh_Hans_SG: 'zh-cn', + zh_Hant: 'zh-hk', + zh_Hant_HK: 'zh-hk', + zh_Hant_MO: 'zh-mo', + zh_Hant_TW: 'zh-tw', +} +let locale = OC.getLocale() +if (Object.prototype.hasOwnProperty.call(localeAliases, locale)) { + locale = localeAliases[locale] +} + +/** * Set users locale to moment.js as soon as possible */ -moment.locale(OC.getLocale()) +moment.locale(locale) /** * Initializes core */ export const initCore = () => { - const userAgent = window.navigator.userAgent - const msie = userAgent.indexOf('MSIE ') - const trident = userAgent.indexOf('Trident/') - const edge = userAgent.indexOf('Edge/') - - if (msie > 0 || trident > 0) { - // (IE 10 or older) || IE 11 - $('html').addClass('ie') - } else if (edge > 0) { - // for edge - $('html').addClass('edge') - } - - // css variables fallback for IE - if (msie > 0 || trident > 0 || edge > 0) { - console.info('Legacy browser detected, applying css vars polyfill') - cssVars({ - watch: true, - // set edge < 16 as incompatible - onlyLegacy: !(/Edge\/([0-9]{2})\./i.test(navigator.userAgent) - && parseInt(/Edge\/([0-9]{2})\./i.exec(navigator.userAgent)[1]) < 16), - }) - } + interceptRequests() + initFallbackClipboardAPI() $(window).on('unload.main', () => { OC._unloadCalled = true }) $(window).on('beforeunload.main', () => { @@ -178,30 +108,6 @@ export const initCore = () => { setUpUserMenu() setUpContactsMenu() - // move triangle of apps dropdown to align with app name triangle - // 2 is the additional offset between the triangles - if ($('#navigation').length) { - $('#header #nextcloud + .menutoggle').on('click', () => { - $('#menu-css-helper').remove() - const caretPosition = $('.header-appname + .icon-caret').offset().left - 2 - if (caretPosition > 255) { - // if the app name is longer than the menu, just put the triangle in the middle - - } else { - $('head').append('<style id="menu-css-helper">#navigation:after { left: ' + caretPosition + 'px }</style>') - } - }) - $('#header #appmenu .menutoggle').on('click', () => { - $('#appmenu').toggleClass('menu-open') - if ($('#appmenu').is(':visible')) { - $('#menu-css-helper').remove() - } - }) - } - - $(window).resize(resizeMenu) - setTimeout(resizeMenu, 0) - // just add snapper for logged in users // and if the app doesn't handle the nav slider itself if ($('#app-navigation').length && !$('html').hasClass('lte9') @@ -237,6 +143,12 @@ export const initCore = () => { // we need this because dragging stop triggers that animating = false }) + snapper.on('open', () => { + $appNavigation.attr('aria-hidden', 'false') + }) + snapper.on('close', () => { + $appNavigation.attr('aria-hidden', 'true') + }) // These are necessary because calling open or close // on snapper during an animation makes it trigger an @@ -290,6 +202,7 @@ export const initCore = () => { // close sidebar when switching navigation entry const $appNavigation = $('#app-navigation') + $appNavigation.attr('aria-hidden', 'true') $appNavigation.delegate('a, :button', 'click', event => { const $target = $(event.target) // don't hide navigation when changing settings or adding things @@ -341,6 +254,7 @@ export const initCore = () => { const toggleSnapperOnSize = () => { if ($(window).width() > breakpointMobileWidth) { + $appNavigation.attr('aria-hidden', 'false') snapper.close() snapper.disable() @@ -364,5 +278,4 @@ export const initCore = () => { } initLiveTimestamps() - PasswordConfirmation.init() } diff --git a/core/src/install.js b/core/src/install.js deleted file mode 100644 index a25945e7591..00000000000 --- a/core/src/install.js +++ /dev/null @@ -1,142 +0,0 @@ -import $ from 'jquery' -import { translate as t } from '@nextcloud/l10n' -import { getToken } from './OC/requesttoken' -import getURLParameter from './Util/get-url-parameter' - -import './jquery/showpassword' - -import 'jquery-ui/ui/widgets/button' -import 'jquery-ui/themes/base/theme.css' -import 'jquery-ui/themes/base/button.css' - -import 'strengthify' -import 'strengthify/strengthify.css' - -window.addEventListener('DOMContentLoaded', function() { - const dbtypes = { - sqlite: !!$('#hasSQLite').val(), - mysql: !!$('#hasMySQL').val(), - postgresql: !!$('#hasPostgreSQL').val(), - oracle: !!$('#hasOracle').val(), - } - - $('#selectDbType').buttonset() - // change links inside an info box back to their default appearance - $('#selectDbType p.info a').button('destroy') - - if ($('#hasSQLite').val()) { - $('#use_other_db').hide() - $('#use_oracle_db').hide() - } else { - $('#sqliteInformation').hide() - } - $('#adminlogin').change(function() { - $('#adminlogin').val($.trim($('#adminlogin').val())) - }) - $('#sqlite').click(function() { - $('#use_other_db').slideUp(250) - $('#use_oracle_db').slideUp(250) - $('#sqliteInformation').show() - $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+') - }) - - $('#mysql,#pgsql').click(function() { - $('#use_other_db').slideDown(250) - $('#use_oracle_db').slideUp(250) - $('#sqliteInformation').hide() - $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+') - }) - - $('#oci').click(function() { - $('#use_other_db').slideDown(250) - $('#use_oracle_db').show(250) - $('#sqliteInformation').hide() - $('#dbname').attr('pattern', '[0-9a-zA-Z$_-.]+') - }) - - $('#showAdvanced').click(function(e) { - e.preventDefault() - $('#datadirContent').slideToggle(250) - $('#databaseBackend').slideToggle(250) - $('#databaseField').slideToggle(250) - }) - $('form').submit(function() { - // Save form parameters - const post = $(this).serializeArray() - - // Show spinner while finishing setup - $('.float-spinner').show(250) - - // Disable inputs - $(':submit', this).attr('disabled', 'disabled').val($(':submit', this).data('finishing')) - $('input', this).addClass('ui-state-disabled').attr('disabled', 'disabled') - // only disable buttons if they are present - if ($('#selectDbType').find('.ui-button').length > 0) { - $('#selectDbType').buttonset('disable') - } - $('.strengthify-wrapper, .tipsy') - .css('-ms-filter', '"progid:DXImageTransform.Microsoft.Alpha(Opacity=30)"') - .css('filter', 'alpha(opacity=30)') - .css('opacity', 0.3) - - // Create the form - const form = $('<form>') - form.attr('action', $(this).attr('action')) - form.attr('method', 'POST') - - for (let i = 0; i < post.length; i++) { - const input = $('<input type="hidden">') - input.attr(post[i]) - form.append(input) - } - - // Add redirect_url - const redirectURL = getURLParameter('redirect_url') - if (redirectURL) { - const redirectURLInput = $('<input type="hidden">') - redirectURLInput.attr({ - name: 'redirect_url', - value: redirectURL, - }) - form.append(redirectURLInput) - } - - // Submit the form - form.appendTo(document.body) - form.submit() - return false - }) - - // Expand latest db settings if page was reloaded on error - const currentDbType = $('input[type="radio"]:checked').val() - - if (currentDbType === undefined) { - $('input[type="radio"]').first().click() - } - - if ( - currentDbType === 'sqlite' - || (dbtypes.sqlite && currentDbType === undefined) - ) { - $('#datadirContent').hide(250) - $('#databaseBackend').hide(250) - $('#databaseField').hide(250) - $('.float-spinner').hide(250) - } - - $('#adminpass').strengthify({ - zxcvbn: OC.linkTo('core', 'vendor/zxcvbn/dist/zxcvbn.js'), - titles: [ - t('core', 'Very weak password'), - t('core', 'Weak password'), - t('core', 'So-so password'), - t('core', 'Good password'), - t('core', 'Strong password'), - ], - drawTitles: true, - nonce: btoa(getToken()), - }) - - $('#dbpass').showPassword().keyup() - $('#adminpass').showPassword().keyup() -}) diff --git a/core/src/install.ts b/core/src/install.ts new file mode 100644 index 00000000000..4ef608ec2bd --- /dev/null +++ b/core/src/install.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import Setup from './views/Setup.vue' + +type Error = { + error: string + hint: string +} + +export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci' + +export type SetupConfig = { + adminlogin: string + adminpass: string + directory: string + dbuser: string + dbpass: string + dbname: string + dbtablespace: string + dbhost: string + dbtype: DbType | '' + + databases: Partial<Record<DbType, string>> + + hasAutoconfig: boolean + htaccessWorking: boolean + serverRoot: string + + errors: string[]|Error[] +} + +export type SetupLinks = { + adminInstall: string + adminSourceInstall: string + adminDBConfiguration: string +} + +const SetupVue = Vue.extend(Setup) +new SetupVue().$mount('#content') diff --git a/core/src/jquery/avatar.js b/core/src/jquery/avatar.js index 8c17e65e98c..3851a26ce31 100644 --- a/core/src/jquery/avatar.js +++ b/core/src/jquery/avatar.js @@ -1,28 +1,12 @@ -/* - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' import $ from 'jquery' -import OC from '../OC' - /** * This plugin inserts the right avatar for the user, depending on, whether a * custom avatar is uploaded - which it uses then - or not, and display a @@ -106,8 +90,8 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) { let url // If this is our own avatar we have to use the version attribute - if (user === OC.getCurrentUser().uid) { - url = OC.generateUrl( + if (user === getCurrentUser()?.uid) { + url = generateUrl( '/avatar/{user}/{size}?v={version}', { user, @@ -115,7 +99,7 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) { version: oc_userconfig.avatar.version, }) } else { - url = OC.generateUrl( + url = generateUrl( '/avatar/{user}/{size}', { user, diff --git a/core/src/jquery/contactsmenu.js b/core/src/jquery/contactsmenu.js index 447f5adb62a..fba014c364e 100644 --- a/core/src/jquery/contactsmenu.js +++ b/core/src/jquery/contactsmenu.js @@ -1,27 +1,12 @@ -/* - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' -import OC from '../OC' +import { generateUrl } from '@nextcloud/router' +import { isA11yActivation } from '../Util/a11y.js' const LIST = '' + '<div class="menu popovermenu menu-left hidden contactsmenu-popover">' @@ -47,7 +32,11 @@ $.fn.contactsMenu = function(shareWith, shareType, appendTo) { appendTo.append(LIST) const $list = appendTo.find('div.contactsmenu-popover') - $div.click(function() { + $div.on('click keydown', function(event) { + if (!isA11yActivation(event)) { + return + } + if (!$list.hasClass('hidden')) { $list.addClass('hidden') $list.hide() @@ -62,7 +51,7 @@ $.fn.contactsMenu = function(shareWith, shareType, appendTo) { } $list.addClass('loaded') - $.ajax(OC.generateUrl('/contactsmenu/findOne'), { + $.ajax(generateUrl('/contactsmenu/findOne'), { method: 'POST', data: { shareType, diff --git a/core/src/jquery/css/jquery-ui-fixes.scss b/core/src/jquery/css/jquery-ui-fixes.scss index 42c684ad510..637f4bfe14b 100644 --- a/core/src/jquery/css/jquery-ui-fixes.scss +++ b/core/src/jquery/css/jquery-ui-fixes.scss @@ -1,3 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ /* Component containers ----------------------------------*/ .ui-widget-content { @@ -5,14 +9,17 @@ background: var(--color-main-background) none; color: var(--color-main-text); } + .ui-widget-content a { color: var(--color-main-text); } + .ui-widget-header { border: none; color: var(--color-main-text); background-image: none; } + .ui-widget-header a { color: var(--color-main-text); } @@ -27,11 +34,13 @@ font-weight: bold; color: #555; } + .ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555; } + .ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, @@ -43,20 +52,23 @@ font-weight: bold; color: var(--color-main-text); } + .ui-state-hover a, .ui-state-hover a:hover, .ui-state-hover a:link, .ui-state-hover a:visited { color: var(--color-main-text); } + .ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { - border: 1px solid var(--color-primary); + border: 1px solid var(--color-primary-element); background: var(--color-main-background) none; font-weight: bold; color: var(--color-main-text); } + .ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { @@ -73,11 +85,13 @@ color: var(--color-text-light); font-weight: 600; } + .ui-state-highlight a, .ui-widget-content .ui-state-highlight a, .ui-widget-header .ui-state-highlight a { color: var(--color-text-lighter); } + .ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error { @@ -85,11 +99,13 @@ background: var(--color-error) none; color: #ffffff; } + .ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #ffffff; } + .ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { @@ -101,20 +117,25 @@ .ui-state-default .ui-icon { background-image: url('images/ui-icons_1d2d44_256x240.png'); } + .ui-state-hover .ui-icon, .ui-state-focus .ui-icon { background-image: url('images/ui-icons_1d2d44_256x240.png'); } + .ui-state-active .ui-icon { background-image: url('images/ui-icons_1d2d44_256x240.png'); } + .ui-state-highlight .ui-icon { background-image: url('images/ui-icons_ffffff_256x240.png'); } + .ui-state-error .ui-icon, .ui-state-error-text .ui-icon { background-image: url('images/ui-icons_ffd27a_256x240.png'); } + .ui-icon.ui-icon-none { display: none; } @@ -126,6 +147,7 @@ background: #666666 url('images/ui-bg_diagonals-thick_20_666666_40x40.png') 50% 50% repeat; opacity: .5; } + .ui-widget-shadow { margin: -5px 0 0 -5px; padding: 5px; @@ -139,8 +161,8 @@ border: none; .ui-tabs-nav.ui-corner-all { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + border-end-start-radius: 0; + border-end-end-radius: 0; } .ui-tabs-nav { @@ -185,7 +207,8 @@ .ui-menu-item a { color: var(--color-text-lighter); display: block; - padding: 4px 4px 4px 14px; + padding: 4px; + padding-inline-start: 14px; &.ui-state-focus, &.ui-state-active { box-shadow: inset 4px 0 var(--color-primary-element); @@ -201,8 +224,8 @@ &.ui-corner-all { border-radius: 0; - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); + border-end-start-radius: var(--border-radius); + border-end-end-radius: var(--border-radius); } .ui-state-hover, .ui-widget-content .ui-state-hover, @@ -223,9 +246,14 @@ } .ui-button.primary { - background-color: var(--color-primary); - color: var(--color-primary-text); - border: 1px solid var(--color-primary-text); + background-color: var(--color-primary-element); + color: var(--color-primary-element-text); + border: 1px solid var(--color-primary-element-text); +} + +// fix ui-buttons on hover +.ui-button:hover { + font-weight:bold !important; } diff --git a/core/src/jquery/css/jquery.ocdialog.scss b/core/src/jquery/css/jquery.ocdialog.scss index b7e762f4cb6..b950d98c381 100644 --- a/core/src/jquery/css/jquery.ocdialog.scss +++ b/core/src/jquery/css/jquery.ocdialog.scss @@ -1,28 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ .oc-dialog { background: var(--color-main-background); color: var(--color-text-light); border-radius: var(--border-radius-large); box-shadow: 0 0 30px var(--color-box-shadow); - padding: 15px; - z-index: 10000; + padding: 24px; + z-index: 100001; font-size: 100%; box-sizing: border-box; min-width: 200px; top: 50%; - left: 50%; + inset-inline-start: 50%; transform: translate(-50%, -50%); max-height: calc(100% - 20px); max-width: calc(100% - 20px); overflow: auto; } + .oc-dialog-title { background: var(--color-main-background); } + .oc-dialog-buttonrow { position: relative; display: flex; background: transparent; - right: 0; + inset-inline-end: 0; bottom: 0; padding: 0; padding-top: 10px; @@ -50,11 +56,14 @@ .oc-dialog-close { position: absolute; - top: 0; - right: 0; + width: 44px !important; + height: 44px !important; + top: 4px; + inset-inline-end: 4px; padding: 25px; - background: var(--icon-close-000) no-repeat center; + background: var(--icon-close-dark) no-repeat center; opacity: .5; + border-radius: var(--border-radius-pill); &:hover, &:focus, @@ -66,10 +75,10 @@ .oc-dialog-dim { background-color: #000; opacity: .2; - z-index: 9999; + z-index: 100001; position: fixed; top: 0; - left: 0; + inset-inline-start: 0; width: 100%; height: 100%; } diff --git a/core/src/jquery/exists.js b/core/src/jquery/exists.js index 3481faeb6cd..8a8efdb5a63 100644 --- a/core/src/jquery/exists.js +++ b/core/src/jquery/exists.js @@ -1,22 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -24,8 +8,9 @@ import $ from 'jquery' /** * check if an element exists. * allows you to write if ($('#myid').exists()) to increase readability - * @link http://stackoverflow.com/questions/31044/is-there-an-exists-function-for-jquery - * @returns {boolean} + * + * @see {@link http://stackoverflow.com/questions/31044/is-there-an-exists-function-for-jquery} + * @return {boolean} */ $.fn.exists = function() { return this.length > 0 diff --git a/core/src/jquery/filterattr.js b/core/src/jquery/filterattr.js index 204d04b46a0..f577e55e4e0 100644 --- a/core/src/jquery/filterattr.js +++ b/core/src/jquery/filterattr.js @@ -1,22 +1,6 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -26,7 +10,7 @@ import $ from 'jquery' * * @param {string} attrName attribute name * @param {string} attrValue attribute value - * @returns {Void} + * @return {void} */ $.fn.filterAttr = function(attrName, attrValue) { return this.filter(function() { diff --git a/core/src/jquery/index.js b/core/src/jquery/index.js index aceee5cf87b..f285ba19449 100644 --- a/core/src/jquery/index.js +++ b/core/src/jquery/index.js @@ -1,37 +1,21 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' -import './avatar' -import './contactsmenu' -import './exists' -import './filterattr' -import './ocdialog' -import './octemplate' -import './placeholder' -import './requesttoken' -import './selectrange' -import './showpassword' -import './ui-fixes' +import './avatar.js' +import './contactsmenu.js' +import './exists.js' +import './filterattr.js' +import './ocdialog.js' +import './octemplate.js' +import './placeholder.js' +import './requesttoken.js' +import './selectrange.js' +import './showpassword.js' +import './ui-fixes.js' import './css/jquery-ui-fixes.scss' import './css/jquery.ocdialog.scss' diff --git a/core/src/jquery/ocdialog.js b/core/src/jquery/ocdialog.js index e08db2530e3..a5f588ec659 100644 --- a/core/src/jquery/ocdialog.js +++ b/core/src/jquery/ocdialog.js @@ -1,25 +1,11 @@ -/* - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' +import { createFocusTrap } from 'focus-trap' +import { isA11yActivation } from '../Util/a11y.js' $.widget('oc.ocdialog', { options: { @@ -42,16 +28,32 @@ $.widget('oc.ocdialog', { this.originalTitle = this.element.attr('title') this.options.title = this.options.title || this.originalTitle - this.$dialog = $('<div class="oc-dialog" />') + this.$dialog = $('<div class="oc-dialog"></div>') .attr({ // Setting tabIndex makes the div focusable tabIndex: -1, role: 'dialog', + 'aria-modal': true, }) .insertBefore(this.element) this.$dialog.append(this.element.detach()) this.element.removeAttr('title').addClass('oc-dialog-content').appendTo(this.$dialog) + // Activate the primary button on enter if there is a single input + if (self.element.find('input').length === 1) { + const $input = self.element.find('input') + $input.on('keydown', function(event) { + if (isA11yActivation(event)) { + if (self.$buttonrow) { + const $button = self.$buttonrow.find('button.primary') + if ($button && !$button.prop('disabled')) { + $button.click() + } + } + } + }) + } + this.$dialog.css({ display: 'inline-block', position: 'fixed', @@ -88,27 +90,15 @@ $.widget('oc.ocdialog', { event.preventDefault() return false } - // If no button is selected we trigger the primary - if ( - self.$buttonrow - && self.$buttonrow.find($(event.target)).length === 0 - ) { - const $button = self.$buttonrow.find('button.primary') - if ($button && !$button.prop('disabled')) { - $button.trigger('click') - } - } else if (self.$buttonrow) { - $(event.target).trigger('click') - } return false } }) this._setOptions(this.options) this._createOverlay() + this._useFocusTrap() }, _init() { - this.$dialog.focus() this._trigger('open') }, _setOption(key, value) { @@ -129,7 +119,7 @@ $.widget('oc.ocdialog', { if (this.$buttonrow) { this.$buttonrow.empty() } else { - const $buttonrow = $('<div class="oc-dialog-buttonrow" />') + const $buttonrow = $('<div class="oc-dialog-buttonrow"></div>') this.$buttonrow = $buttonrow.appendTo(this.$dialog) } if (value.length === 1) { @@ -149,8 +139,10 @@ $.widget('oc.ocdialog', { self.$defaultButton = $button } self.$buttonrow.append($button) - $button.click(function() { - val.click.apply(self.element[0], arguments) + $button.on('click keydown', function(event) { + if (isA11yActivation(event)) { + val.click.apply(self.element[0], arguments) + } }) }) this.$buttonrow.find('button') @@ -167,11 +159,14 @@ $.widget('oc.ocdialog', { break case 'closeButton': if (value) { - const $closeButton = $('<a class="oc-dialog-close"></a>') + const $closeButton = $('<button class="oc-dialog-close"></button>') + $closeButton.attr('aria-label', t('core', 'Close "{dialogTitle}" dialog', { dialogTitle: this.$title || this.options.title })) this.$dialog.prepend($closeButton) - $closeButton.on('click', function() { - self.options.closeCallback && self.options.closeCallback() - self.close() + $closeButton.on('click keydown', function(event) { + if (isA11yActivation(event)) { + self.options.closeCallback && self.options.closeCallback() + self.close() + } }) } else { this.$dialog.find('.oc-dialog-close').remove() @@ -219,7 +214,7 @@ $.widget('oc.ocdialog', { } this.overlay = $('<div>') .addClass('oc-dialog-dim') - .appendTo(contentDiv) + .insertBefore(this.$dialog) this.overlay.on('click keydown keyup', function(event) { if (event.target !== self.$dialog.get(0) && self.$dialog.find($(event.target)).length === 0) { event.preventDefault() @@ -239,6 +234,23 @@ $.widget('oc.ocdialog', { this.overlay = null } }, + _useFocusTrap() { + // Create global stack if undefined + Object.assign(window, { _nc_focus_trap: window._nc_focus_trap || [] }) + + const dialogElement = this.$dialog[0] + this.focusTrap = createFocusTrap(dialogElement, { + allowOutsideClick: true, + trapStack: window._nc_focus_trap, + fallbackFocus: dialogElement, + }) + + this.focusTrap.activate() + }, + _clearFocusTrap() { + this.focusTrap?.deactivate() + this.focusTrap = null + }, widget() { return this.$dialog }, @@ -249,6 +261,7 @@ $.widget('oc.ocdialog', { this.enterCallback = null }, close() { + this._clearFocusTrap() this._destroyOverlay() const self = this // Ugly hack to catch remaining keyup events. diff --git a/core/src/jquery/octemplate.js b/core/src/jquery/octemplate.js index f89e13b000f..cecbe880aa6 100644 --- a/core/src/jquery/octemplate.js +++ b/core/src/jquery/octemplate.js @@ -1,3 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + import $ from 'jquery' import escapeHTML from 'escape-html' @@ -41,14 +46,14 @@ import escapeHTML from 'escape-html' * var contacts = // fetched in some ajax call * * $.each(contacts, function(idx, contact) { - * $contactList.append( - * $tmpl.octemplate({ - * id: contact.getId(), - * name: contact.getDisplayName(), - * email: contact.getPreferredEmail(), - * phone: contact.getPreferredPhone(), - * }); - * ); + * $contactList.append( + * $tmpl.octemplate({ + * id: contact.getId(), + * name: contact.getDisplayName(), + * email: contact.getPreferredEmail(), + * phone: contact.getPreferredPhone(), + * }); + * ); * }); */ /** @@ -84,7 +89,7 @@ const Template = { function(a, b) { const r = o[b] return typeof r === 'string' || typeof r === 'number' ? r : a - } + }, ) } catch (e) { console.error(e, 'data:', data) diff --git a/core/src/jquery/placeholder.js b/core/src/jquery/placeholder.js index 6c5f94313f6..e57951af5e4 100644 --- a/core/src/jquery/placeholder.js +++ b/core/src/jquery/placeholder.js @@ -1,27 +1,10 @@ -/* eslint-disable */ /** - * ownCloud - * - * @author John Molakvoæ - * @copyright 2016-2018 John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke - * @copyright 2013 Morris Jobke <morris.jobke@gmail.com> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library 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 library. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2013-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import $ from 'jquery' import md5 from 'blueimp-md5' @@ -137,7 +120,7 @@ const toRgb = (s) => { } String.prototype.toRgb = function() { - console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.') + OC.debug && console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.') return toRgb(this) } diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js index 5f15d43aa17..1e9e06515a6 100644 --- a/core/src/jquery/requesttoken.js +++ b/core/src/jquery/requesttoken.js @@ -1,31 +1,15 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' -import { getToken } from '../OC/requesttoken' +import { getRequestToken } from '../OC/requesttoken.ts' $(document).on('ajaxSend', function(elm, xhr, settings) { if (settings.crossDomain === false) { - xhr.setRequestHeader('requesttoken', getToken()) + xhr.setRequestHeader('requesttoken', getRequestToken()) xhr.setRequestHeader('OCS-APIREQUEST', 'true') } }) diff --git a/core/src/jquery/selectrange.js b/core/src/jquery/selectrange.js index 3076726024a..a4d8f49ce43 100644 --- a/core/src/jquery/selectrange.js +++ b/core/src/jquery/selectrange.js @@ -1,32 +1,17 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' /** * select a range in an input field - * @link http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area - * @param {int} start start selection from - * @param {int} end number of char from start - * @returns {Void} + * + * @see {@link http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area} + * @param {number} start start selection from + * @param {number} end number of char from start + * @return {void} */ $.fn.selectRange = function(start, end) { return this.each(function() { diff --git a/core/src/jquery/showpassword.js b/core/src/jquery/showpassword.js index ac0a9556ae8..8d938d7853b 100644 --- a/core/src/jquery/showpassword.js +++ b/core/src/jquery/showpassword.js @@ -1,40 +1,24 @@ -/* - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/** @typedef {import('jquery')} jQuery */ import $ from 'jquery' -/* -* @name Show Password -* @description -* @version 1.3 -* @requires Jquery 1.5 -* -* @author Jan Jarfalk -* @author-email jan.jarfalk@unwrongest.com -* @author-website http://www.unwrongest.com -* -* @special-thanks Michel Gratton -* -* @licens MIT License - http://www.opensource.org/licenses/mit-license.php -*/ +/** + * @name Show Password + * @description + * @version 1.3.0 + * @requires jQuery 1.5 + * + * @author Jan Jarfalk <jan.jarfalk@unwrongest.com> + * author-website http://www.unwrongest.com + * + * special-thanks Michel Gratton + * + * @license MIT + */ $.fn.extend({ showPassword(c) { diff --git a/core/src/jquery/ui-fixes.js b/core/src/jquery/ui-fixes.js index d70c5579f94..e23464b2f9d 100644 --- a/core/src/jquery/ui-fixes.js +++ b/core/src/jquery/ui-fixes.js @@ -1,3 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + import $ from 'jquery' // Set autocomplete width the same as the related input diff --git a/core/src/legacy-unified-search.js b/core/src/legacy-unified-search.js new file mode 100644 index 00000000000..59ee462fbf5 --- /dev/null +++ b/core/src/legacy-unified-search.js @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' +import { getCSPNonce } from '@nextcloud/auth' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import Vue from 'vue' + +import UnifiedSearch from './views/LegacyUnifiedSearch.vue' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() + +const logger = getLoggerBuilder() + .setApp('unified-search') + .detectUser() + .build() + +Vue.mixin({ + data() { + return { + logger, + } + }, + methods: { + t, + n, + }, +}) + +export default new Vue({ + el: '#unified-search', + // eslint-disable-next-line vue/match-component-file-name + name: 'UnifiedSearchRoot', + render: h => h(UnifiedSearch), +}) diff --git a/core/src/logger.js b/core/src/logger.js index bd01f58a413..78d51a798e4 100644 --- a/core/src/logger.js +++ b/core/src/logger.js @@ -1,22 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCurrentUser } from '@nextcloud/auth' @@ -35,3 +19,8 @@ const getLogger = user => { } export default getLogger(getCurrentUser()) + +export const unifiedSearchLogger = getLoggerBuilder() + .setApp('unified-search') + .detectUser() + .build() diff --git a/core/src/login.js b/core/src/login.js index a7d738ee9ef..29affcda762 100644 --- a/core/src/login.js +++ b/core/src/login.js @@ -1,71 +1,16 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { loadState } from '@nextcloud/initial-state' -import queryString from 'query-string' import Vue from 'vue' // eslint-disable-next-line no-unused-vars -import OC from './OC/index' // TODO: Not needed but L10n breaks if removed +import OC from './OC/index.js' // TODO: Not needed but L10n breaks if removed import LoginView from './views/Login.vue' -import Nextcloud from './mixins/Nextcloud' - -const query = queryString.parse(location.search) -if (query.clear === '1') { - try { - window.localStorage.clear() - window.sessionStorage.clear() - console.debug('Browser storage cleared') - } catch (e) { - console.error('Could not clear browser storage', e) - } -} +import Nextcloud from './mixins/Nextcloud.js' Vue.mixin(Nextcloud) -const fromStateOr = (key, orValue) => { - try { - return loadState('core', key) - } catch (e) { - return orValue - } -} - const View = Vue.extend(LoginView) -new View({ - propsData: { - errors: fromStateOr('loginErrors', []), - messages: fromStateOr('loginMessages', []), - redirectUrl: fromStateOr('loginRedirectUrl', undefined), - username: fromStateOr('loginUsername', ''), - throttleDelay: fromStateOr('loginThrottleDelay', 0), - invertedColors: OCA.Theming && OCA.Theming.inverted, - canResetPassword: fromStateOr('loginCanResetPassword', false), - resetPasswordLink: fromStateOr('loginResetPasswordLink', ''), - autoCompleteAllowed: fromStateOr('loginAutocomplete', true), - resetPasswordTarget: fromStateOr('resetPasswordTarget', ''), - resetPasswordUser: fromStateOr('resetPasswordUser', ''), - directLogin: query.direct === '1', - hasPasswordless: fromStateOr('webauthn-available', false), - isHttps: window.location.protocol === 'https:', - hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined', - }, -}).$mount('#login') +new View().$mount('#login') diff --git a/core/src/main.js b/core/src/main.js index 492ac37c4ac..2d88f15562b 100644 --- a/core/src/main.js +++ b/core/src/main.js @@ -1,37 +1,25 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import $ from 'jquery' -import 'core-js/stable' -import 'regenerator-runtime/runtime' -import './Polyfill/index' +import 'core-js/stable/index.js' +import 'regenerator-runtime/runtime.js' // If you remove the line below, tests won't pass // eslint-disable-next-line no-unused-vars -import OC from './OC/index' +import OC from './OC/index.js' -import './globals' -import './jquery/index' -import { initCore } from './init' -import { registerAppsSlideToggle } from './OC/apps' +import './globals.js' +import './jquery/index.js' +import { initCore } from './init.js' +import { registerAppsSlideToggle } from './OC/apps.js' +import { getCSPNonce } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' +import Axios from '@nextcloud/axios' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() window.addEventListener('DOMContentLoaded', function() { initCore() @@ -41,6 +29,23 @@ window.addEventListener('DOMContentLoaded', function() { if (window.history.pushState) { window.onpopstate = _.bind(OC.Util.History._onPopState, OC.Util.History) } else { - $(window).on('hashchange', _.bind(OC.Util.History._onPopState, OC.Util.History)) + window.onhashchange = _.bind(OC.Util.History._onPopState, OC.Util.History) + } +}) + +// Fix error "CSRF check failed" +document.addEventListener('DOMContentLoaded', function() { + const form = document.getElementById('password-input-form') + if (form) { + form.addEventListener('submit', async function(event) { + event.preventDefault() + const requestToken = document.getElementById('requesttoken') + if (requestToken) { + const url = generateUrl('/csrftoken') + const resp = await Axios.get(url) + requestToken.value = resp.data.token + } + form.submit() + }) } }) diff --git a/core/src/maintenance.js b/core/src/maintenance.js index 3abc20ea8fc..e66b14a88f5 100644 --- a/core/src/maintenance.js +++ b/core/src/maintenance.js @@ -1,22 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Axios from '@nextcloud/axios' diff --git a/core/src/mixins/Nextcloud.js b/core/src/mixins/Nextcloud.js index 3ca755b3052..3a94f85d2c6 100644 --- a/core/src/mixins/Nextcloud.js +++ b/core/src/mixins/Nextcloud.js @@ -1,26 +1,10 @@ -/* - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import L10n from '../OC/l10n' -import OC from '../OC/index' +import L10n from '../OC/l10n.js' +import OC from '../OC/index.js' export default { data() { diff --git a/core/src/mixins/auth.js b/core/src/mixins/auth.js new file mode 100644 index 00000000000..f5b9365516e --- /dev/null +++ b/core/src/mixins/auth.js @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export default { + + computed: { + userNameInputLengthIs255() { + return this.user.length >= 255 + }, + userInputHelperText() { + if (this.userNameInputLengthIs255) { + return t('core', 'Email length is at max (255)') + } + return undefined + }, + }, +} diff --git a/core/src/public-page-menu.ts b/core/src/public-page-menu.ts new file mode 100644 index 00000000000..b290d1d03e9 --- /dev/null +++ b/core/src/public-page-menu.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import Vue from 'vue' + +import PublicPageMenu from './views/PublicPageMenu.vue' + +__webpack_nonce__ = getCSPNonce() + +const View = Vue.extend(PublicPageMenu) +const instance = new View() +instance.$mount('#public-page-menu') diff --git a/core/src/public-page-user-menu.ts b/core/src/public-page-user-menu.ts new file mode 100644 index 00000000000..25024271fb5 --- /dev/null +++ b/core/src/public-page-user-menu.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import Vue from 'vue' + +import PublicPageUserMenu from './views/PublicPageUserMenu.vue' + +__webpack_nonce__ = getCSPNonce() + +const View = Vue.extend(PublicPageUserMenu) +const instance = new View() +instance.$mount('#public-page-user-menu') diff --git a/core/src/public.ts b/core/src/public.ts new file mode 100644 index 00000000000..ce4af8aa2ac --- /dev/null +++ b/core/src/public.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const body = document.body +const footer = document.querySelector('footer') +let prevHeight = footer?.offsetHeight + +const onResize: ResizeObserverCallback = (entries) => { + for (const entry of entries) { + const height = entry.contentRect.height + if (height === prevHeight) { + return + } + prevHeight = height + body.style.setProperty('--footer-height', `${height}px`) + } +} + +if (footer) { + new ResizeObserver(onResize) + .observe(footer, { + box: 'border-box', // <footer> is border-box + }) +} diff --git a/core/src/recommendedapps.js b/core/src/recommendedapps.js index ef9071eb42c..13f16436ed3 100644 --- a/core/src/recommendedapps.js +++ b/core/src/recommendedapps.js @@ -1,36 +1,17 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getRequestToken } from '@nextcloud/auth' -import { generateFilePath } from '@nextcloud/router' +import { getCSPNonce } from '@nextcloud/auth' import { translate as t } from '@nextcloud/l10n' import Vue from 'vue' -import logger from './logger' -import RecommendedApps from './components/setup/RecommendedApps' +import logger from './logger.js' +import RecommendedApps from './components/setup/RecommendedApps.vue' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) -// eslint-disable-next-line camelcase -__webpack_public_path__ = generateFilePath('core', '', 'js/') +__webpack_nonce__ = getCSPNonce() Vue.mixin({ methods: { diff --git a/core/src/services/BrowserStorageService.js b/core/src/services/BrowserStorageService.js new file mode 100644 index 00000000000..b7d34bf1716 --- /dev/null +++ b/core/src/services/BrowserStorageService.js @@ -0,0 +1,11 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getBuilder } from '@nextcloud/browser-storage' + +export default getBuilder('core') + .clearOnLogout() + .persist() + .build() diff --git a/core/src/services/BrowsersListService.js b/core/src/services/BrowsersListService.js new file mode 100644 index 00000000000..77f217a86ac --- /dev/null +++ b/core/src/services/BrowsersListService.js @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getUserAgentRegex } from 'browserslist-useragent-regexp' +// eslint-disable-next-line n/no-extraneous-import +import browserslist from 'browserslist' +import browserslistConfig from '@nextcloud/browserslist-config' + +// Generate a regex that matches user agents to detect incompatible browsers +export const supportedBrowsersRegExp = getUserAgentRegex({ allowHigherVersions: true, browsers: browserslistConfig }) +export const supportedBrowsers = browserslist(browserslistConfig) diff --git a/core/src/services/LegacyUnifiedSearchService.js b/core/src/services/LegacyUnifiedSearchService.js new file mode 100644 index 00000000000..5b79c09b8b2 --- /dev/null +++ b/core/src/services/LegacyUnifiedSearchService.js @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateOcsUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import axios from '@nextcloud/axios' + +export const defaultLimit = loadState('unified-search', 'limit-default') +export const minSearchLength = loadState('unified-search', 'min-search-length', 1) +export const enableLiveSearch = loadState('unified-search', 'live-search', true) + +export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig +export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig + +/** + * Create a cancel token + * + * @return {import('axios').CancelTokenSource} + */ +const createCancelToken = () => axios.CancelToken.source() + +/** + * Get the list of available search providers + * + * @return {Promise<Array>} + */ +export async function getTypes() { + try { + const { data } = await axios.get(generateOcsUrl('search/providers'), { + params: { + // Sending which location we're currently at + from: window.location.pathname.replace('/index.php', '') + window.location.search, + }, + }) + if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) { + // Providers are sorted by the api based on their order key + return data.ocs.data + } + } catch (error) { + console.error(error) + } + return [] +} + +/** + * Get the list of available search providers + * + * @param {object} options destructuring object + * @param {string} options.type the type to search + * @param {string} options.query the search + * @param {number|string|undefined} options.cursor the offset for paginated searches + * @return {object} {request: Promise, cancel: Promise} + */ +export function search({ type, query, cursor }) { + /** + * Generate an axios cancel token + */ + const cancelToken = createCancelToken() + + const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), { + cancelToken: cancelToken.token, + params: { + term: query, + cursor, + // Sending which location we're currently at + from: window.location.pathname.replace('/index.php', '') + window.location.search, + }, + }) + + return { + request, + cancel: cancelToken.cancel, + } +} diff --git a/core/src/services/UnifiedSearchService.js b/core/src/services/UnifiedSearchService.js index eb91f18f8c9..7067c994c90 100644 --- a/core/src/services/UnifiedSearchService.js +++ b/core/src/services/UnifiedSearchService.js @@ -1,47 +1,27 @@ /** - * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { generateOcsUrl } from '@nextcloud/router' -import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' - -export const defaultLimit = loadState('unified-search', 'limit-default') -export const minSearchLength = 2 -export const regexFilterIn = /[^-]in:([a-z_-]+)/ig -export const regexFilterNot = /-in:([a-z_-]+)/ig +import { getCurrentUser } from '@nextcloud/auth' /** * Create a cancel token - * @returns {CancelTokenSource} + * + * @return {import('axios').CancelTokenSource} */ const createCancelToken = () => axios.CancelToken.source() /** * Get the list of available search providers * - * @returns {Array} + * @return {Promise<Array>} */ -export async function getTypes() { +export async function getProviders() { try { - const { data } = await axios.get(generateOcsUrl('search', 2) + 'providers', { + const { data } = await axios.get(generateOcsUrl('search/providers'), { params: { // Sending which location we're currently at from: window.location.pathname.replace('/index.php', '') + window.location.search, @@ -60,25 +40,35 @@ export async function getTypes() { /** * Get the list of available search providers * - * @param {Object} options destructuring object + * @param {object} options destructuring object * @param {string} options.type the type to search * @param {string} options.query the search - * @param {int|string|undefined} options.cursor the offset for paginated searches - * @returns {Object} {request: Promise, cancel: Promise} + * @param {number|string|undefined} options.cursor the offset for paginated searches + * @param {string} options.since the search + * @param {string} options.until the search + * @param {string} options.limit the search + * @param {string} options.person the search + * @param {object} options.extraQueries additional queries to filter search results + * @return {object} {request: Promise, cancel: Promise} */ -export function search({ type, query, cursor }) { +export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) { /** * Generate an axios cancel token */ const cancelToken = createCancelToken() - const request = async() => axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, { + const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), { cancelToken: cancelToken.token, params: { term: query, cursor, + since, + until, + limit, + person, // Sending which location we're currently at from: window.location.pathname.replace('/index.php', '') + window.location.search, + ...extraQueries, }, }) @@ -87,3 +77,32 @@ export function search({ type, query, cursor }) { cancel: cancelToken.cancel, } } + +/** + * Get the list of active contacts + * + * @param {object} filter filter contacts by string + * @param {string} filter.searchTerm the query + * @return {object} {request: Promise} + */ +export async function getContacts({ searchTerm }) { + const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), { + filter: searchTerm, + }) + /* + * Add authenticated user to list of contacts for search filter + * If authtenicated user is searching/filtering, do not add them to the list + */ + if (!searchTerm) { + let authenticatedUser = getCurrentUser() + authenticatedUser = { + id: authenticatedUser.uid, + fullName: authenticatedUser.displayName, + emailAddresses: [], + } + contacts.unshift(authenticatedUser) + return contacts + } + + return contacts +} diff --git a/core/src/services/WebAuthnAuthenticationService.js b/core/src/services/WebAuthnAuthenticationService.js deleted file mode 100644 index 91f19177066..00000000000 --- a/core/src/services/WebAuthnAuthenticationService.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import Axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' - -export function startAuthentication(loginName) { - const url = generateUrl('/login/webauthn/start') - - return Axios.post(url, { loginName }) - .then(resp => resp.data) -} - -export function finishAuthentication(data) { - const url = generateUrl('/login/webauthn/finish') - - return Axios.post(url, { data }) - .then(resp => resp.data) -} diff --git a/core/src/services/WebAuthnAuthenticationService.ts b/core/src/services/WebAuthnAuthenticationService.ts new file mode 100644 index 00000000000..df1837254ad --- /dev/null +++ b/core/src/services/WebAuthnAuthenticationService.ts @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser' + +import { startAuthentication as startWebauthnAuthentication } from '@simplewebauthn/browser' +import { generateUrl } from '@nextcloud/router' + +import Axios from '@nextcloud/axios' +import logger from '../logger' + +export class NoValidCredentials extends Error {} + +/** + * Start webautn authentication + * This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server. + * + * @param loginName Name to login + */ +export async function startAuthentication(loginName: string) { + const url = generateUrl('/login/webauthn/start') + + const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName }) + if (!data.allowCredentials || data.allowCredentials.length === 0) { + logger.error('No valid credentials returned for webauthn') + throw new NoValidCredentials() + } + return await startWebauthnAuthentication({ optionsJSON: data }) +} + +/** + * Verify webauthn authentication + * @param authData The authentication data to sent to the server + */ +export async function finishAuthentication(authData: AuthenticationResponseJSON) { + const url = generateUrl('/login/webauthn/finish') + + const { data } = await Axios.post(url, { data: JSON.stringify(authData) }) + return data +} diff --git a/core/src/session-heartbeat.js b/core/src/session-heartbeat.js deleted file mode 100644 index 38db6b49a5c..00000000000 --- a/core/src/session-heartbeat.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import $ from 'jquery' -import { emit } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' -import { getCurrentUser } from '@nextcloud/auth' -import { generateUrl } from '@nextcloud/router' - -import OC from './OC' -import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken' - -let config = null -/** - * The legacy jsunit tests overwrite OC.config before calling initCore - * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat - */ -const loadConfig = () => { - try { - config = loadState('core', 'config') - } catch (e) { - // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls - config = OC.config - } -} - -/** - * session heartbeat (defaults to enabled) - * @returns {boolean} - */ -const keepSessionAlive = () => { - return config.session_keepalive === undefined - || !!config.session_keepalive -} - -/** - * get interval in seconds - * @returns {Number} - */ -const getInterval = () => { - let interval = NaN - if (config.session_lifetime) { - interval = Math.floor(config.session_lifetime / 2) - } - - // minimum one minute, max 24 hours, default 15 minutes - return Math.min( - 24 * 3600, - Math.max( - 60, - isNaN(interval) ? 900 : interval - ) - ) -} - -const getToken = async() => { - const url = generateUrl('/csrftoken') - - // Not using Axios here as Axios is not stubbable with the sinon fake server - // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises - // see js/tests/specs/coreSpec.js for the tests - const resp = await $.get(url) - - return resp.token -} - -const poll = async() => { - try { - const token = await getToken() - setRequestToken(token) - } catch (e) { - console.error('session heartbeat failed', e) - } -} - -const startPolling = () => { - const interval = setInterval(poll, getInterval() * 1000) - - console.info('session heartbeat polling started') - - return interval -} - -const registerAutoLogout = () => { - if (!config.auto_logout || !getCurrentUser()) { - return - } - - let lastActive = Date.now() - window.addEventListener('mousemove', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('touchstart', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('storage', e => { - if (e.key !== 'lastActive') { - return - } - lastActive = e.newValue - }) - - setInterval(function() { - const timeout = Date.now() - config.session_lifetime * 1000 - if (lastActive < timeout) { - console.info('Inactivity timout reached, logging out') - const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) - window.location = logoutUrl - } - }, 1000) -} - -/** - * Calls the server periodically to ensure that session and CSRF - * token doesn't expire - */ -export const initSessionHeartBeat = () => { - loadConfig() - - registerAutoLogout() - - if (!keepSessionAlive()) { - console.info('session heartbeat disabled') - return - } - let interval = startPolling() - - window.addEventListener('online', async() => { - console.info('browser is online again, resuming heartbeat') - interval = startPolling() - try { - await poll() - console.info('session token successfully updated after resuming network') - - // Let apps know we're online and requests will have the new token - emit('networkOnline', { - success: true, - }) - } catch (e) { - console.error('could not update session token after resuming network', e) - - // Let apps know we're online but requests might have an outdated token - emit('networkOnline', { - success: false, - }) - } - }) - window.addEventListener('offline', () => { - console.info('browser is offline, stopping heartbeat') - - // Let apps know we're offline - emit('networkOffline', {}) - - clearInterval(interval) - console.info('session heartbeat polling stopped') - }) -} diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts new file mode 100644 index 00000000000..42a9bfccef7 --- /dev/null +++ b/core/src/session-heartbeat.ts @@ -0,0 +1,158 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' +import { + fetchRequestToken, + getRequestToken, +} from './OC/requesttoken.ts' +import logger from './logger.js' + +interface OcJsConfig { + auto_logout: boolean + session_keepalive: boolean + session_lifetime: number +} + +// This is always set, exception would be e.g. error pages where this is undefined +const { + auto_logout: autoLogout, + session_keepalive: keepSessionAlive, + session_lifetime: sessionLifetime, +} = loadState<Partial<OcJsConfig>>('core', 'config', {}) + +/** + * Calls the server periodically to ensure that session and CSRF + * token doesn't expire + */ +export function initSessionHeartBeat() { + registerAutoLogout() + + if (!keepSessionAlive) { + logger.info('Session heartbeat disabled') + return + } + + let interval = startPolling() + window.addEventListener('online', async () => { + logger.info('Browser is online again, resuming heartbeat') + + interval = startPolling() + try { + await poll() + logger.info('Session token successfully updated after resuming network') + + // Let apps know we're online and requests will have the new token + emit('networkOnline', { + success: true, + }) + } catch (error) { + logger.error('could not update session token after resuming network', { error }) + + // Let apps know we're online but requests might have an outdated token + emit('networkOnline', { + success: false, + }) + } + }) + + window.addEventListener('offline', () => { + logger.info('Browser is offline, stopping heartbeat') + + // Let apps know we're offline + emit('networkOffline', {}) + + clearInterval(interval) + logger.info('Session heartbeat polling stopped') + }) +} + +/** + * Get interval in seconds + */ +function getInterval(): number { + const interval = sessionLifetime + ? Math.floor(sessionLifetime / 2) + : 900 + + // minimum one minute, max 24 hours, default 15 minutes + return Math.min( + 24 * 3600, + Math.max( + 60, + interval, + ), + ) +} + +/** + * Poll the CSRF token for changes. + * This will also extend the current session if needed. + */ +async function poll() { + try { + await fetchRequestToken() + } catch (error) { + logger.error('session heartbeat failed', { error }) + } +} + +/** + * Start an window interval with the polling as the callback. + * + * @return The interval id + */ +function startPolling(): number { + const interval = window.setInterval(poll, getInterval() * 1000) + + logger.info('session heartbeat polling started') + return interval +} + +/** + * If enabled this will register event listeners to track if a user is active. + * If not the user will be automatically logged out after the configured IDLE time. + */ +function registerAutoLogout() { + if (!autoLogout || !getCurrentUser()) { + return + } + + let lastActive = Date.now() + window.addEventListener('mousemove', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('touchstart', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('storage', (event) => { + if (event.key !== 'lastActive') { + return + } + if (event.newValue === null) { + return + } + lastActive = JSON.parse(event.newValue) + }) + + let intervalId = 0 + const logoutCheck = () => { + const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000 + if (lastActive < timeout) { + clearTimeout(intervalId) + logger.info('Inactivity timout reached, logging out') + const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) + window.location.href = logoutUrl + } + } + intervalId = window.setInterval(logoutCheck, 1000) +} diff --git a/core/src/store/unified-search-external-filters.js b/core/src/store/unified-search-external-filters.js new file mode 100644 index 00000000000..55de34b8b2a --- /dev/null +++ b/core/src/store/unified-search-external-filters.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { defineStore } from 'pinia' + +export const useSearchStore = defineStore('search', { + state: () => ({ + externalFilters: [], + }), + + actions: { + registerExternalFilter({ id, appId, searchFrom, label, callback, icon }) { + this.externalFilters.push({ id, appId, searchFrom, name: label, callback, icon, isPluginFilter: true }) + }, + }, +}) diff --git a/core/src/systemtags/merged-systemtags.js b/core/src/systemtags/merged-systemtags.js index 011544baa08..e4ccb1d3802 100644 --- a/core/src/systemtags/merged-systemtags.js +++ b/core/src/systemtags/merged-systemtags.js @@ -1,3 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + import './systemtags.js' import './systemtagmodel.js' import './systemtagsmappingcollection.js' diff --git a/core/src/systemtags/systemtagmodel.js b/core/src/systemtags/systemtagmodel.js index f0f564e012a..1d2cd3ae57d 100644 --- a/core/src/systemtags/systemtagmodel.js +++ b/core/src/systemtags/systemtagmodel.js @@ -1,60 +1,58 @@ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + * @param {object} OC The OC namespace */ (function(OC) { + if (OC?.Files?.Client) { + _.extend(OC.Files.Client, { + PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id', + PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign', + PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name', + PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible', + PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable', + }) - _.extend(OC.Files.Client, { - PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id', - PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign', - PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name', - PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible', - PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable', - }) - - /** - * @class OCA.SystemTags.SystemTagsCollection - * @classdesc - * - * System tag - * - */ - const SystemTagModel = OC.Backbone.Model.extend( - /** @lends OCA.SystemTags.SystemTagModel.prototype */ { - sync: OC.Backbone.davSync, + /** + * @class OCA.SystemTags.SystemTagsCollection + * @classdesc + * + * System tag + * + */ + const SystemTagModel = OC.Backbone.Model.extend( + /** @lends OCA.SystemTags.SystemTagModel.prototype */ { + sync: OC.Backbone.davSync, - defaults: { - userVisible: true, - userAssignable: true, - canAssign: true, - }, + defaults: { + userVisible: true, + userAssignable: true, + canAssign: true, + }, - davProperties: { - id: OC.Files.Client.PROPERTY_FILEID, - name: OC.Files.Client.PROPERTY_DISPLAYNAME, - userVisible: OC.Files.Client.PROPERTY_USERVISIBLE, - userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE, - // read-only, effective permissions computed by the server, - canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN, - }, + davProperties: { + id: OC.Files.Client.PROPERTY_FILEID, + name: OC.Files.Client.PROPERTY_DISPLAYNAME, + userVisible: OC.Files.Client.PROPERTY_USERVISIBLE, + userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE, + // read-only, effective permissions computed by the server, + canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN, + }, - parse(data) { - return { - id: data.id, - name: data.name, - userVisible: data.userVisible === true || data.userVisible === 'true', - userAssignable: data.userAssignable === true || data.userAssignable === 'true', - canAssign: data.canAssign === true || data.canAssign === 'true', - } - }, - }) + parse(data) { + return { + id: data.id, + name: data.name, + userVisible: data.userVisible === true || data.userVisible === 'true', + userAssignable: data.userAssignable === true || data.userAssignable === 'true', + canAssign: data.canAssign === true || data.canAssign === 'true', + } + }, + }) - OC.SystemTags = OC.SystemTags || {} - OC.SystemTags.SystemTagModel = SystemTagModel + OC.SystemTags = OC.SystemTags || {} + OC.SystemTags.SystemTagModel = SystemTagModel + } })(OC) diff --git a/core/src/systemtags/systemtags.js b/core/src/systemtags/systemtags.js index 676f1b8de73..ceb4652fe1c 100644 --- a/core/src/systemtags/systemtags.js +++ b/core/src/systemtags/systemtags.js @@ -1,14 +1,10 @@ -/* eslint-disable */ -/* - * Copyright (c) 2016 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import escapeHTML from 'escape-html' (function(OC) { @@ -19,34 +15,37 @@ import escapeHTML from 'escape-html' /** * * @param {OC.SystemTags.SystemTagModel|Object|String} tag - * @returns {jQuery} + * @returns {HTMLElement} */ getDescriptiveTag: function(tag) { if (_.isUndefined(tag.name) && !_.isUndefined(tag.toJSON)) { tag = tag.toJSON() } + var $span = document.createElement('span') + if (_.isUndefined(tag.name)) { - return $('<span>').addClass('non-existing-tag').text( - t('core', 'Non-existing tag #{tag}', { + $span.classList.add('non-existing-tag') + $span.textContent = t('core', 'Non-existing tag #{tag}', { tag: tag - }) - ) + }) + return $span } - var $span = $('<span>') - $span.append(escapeHTML(tag.name)) + $span.textContent = escapeHTML(tag.name) var scope if (!tag.userAssignable) { - scope = t('core', 'restricted') + scope = t('core', 'Restricted') } if (!tag.userVisible) { // invisible also implicitly means not assignable - scope = t('core', 'invisible') + scope = t('core', 'Invisible') } if (scope) { - $span.append($('<em>').text(' (' + scope + ')')) + var $scope = document.createElement('em') + $scope.textContent = ' (' + scope + ')' + $span.appendChild($scope) } return $span } diff --git a/core/src/systemtags/systemtagscollection.js b/core/src/systemtags/systemtagscollection.js index fe3f2868558..960d26ed36e 100644 --- a/core/src/systemtags/systemtagscollection.js +++ b/core/src/systemtags/systemtagscollection.js @@ -1,14 +1,10 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ (function(OC) { function filterFunction(model, term) { @@ -48,7 +44,7 @@ * Lazy fetch. * Only fetches once, subsequent calls will directly call the success handler. * - * @param options + * @param {any} options - * @param [options.force] true to force fetch even if cached entries exist * * @see Backbone.Collection#fetch @@ -56,7 +52,7 @@ fetch: function(options) { var self = this options = options || {} - if (this.fetched || options.force) { + if (this.fetched || this.working || options.force) { // directly call handler if (options.success) { options.success(this, null, options) @@ -66,10 +62,13 @@ return Promise.resolve() } + this.working = true + var success = options.success options = _.extend({}, options) options.success = function() { self.fetched = true + self.working = false if (success) { return success.apply(this, arguments) } diff --git a/core/src/systemtags/systemtagsinputfield.js b/core/src/systemtags/systemtagsinputfield.js index d05ac5899fd..b31d24dd0b5 100644 --- a/core/src/systemtags/systemtagsinputfield.js +++ b/core/src/systemtags/systemtagsinputfield.js @@ -1,14 +1,10 @@ -/* eslint-disable */ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable */ import templateResult from './templates/result.handlebars' import templateResultForm from './templates/result_form.handlebars' import templateSelection from './templates/selection.handlebars' @@ -42,10 +38,10 @@ import templateSelection from './templates/selection.handlebars' * * @param {Object} [options] * @param {string} [options.objectType=files] object type for which tags are assigned to - * @param {bool} [options.multiple=false] whether to allow selecting multiple tags - * @param {bool} [options.allowActions=true] whether tags can be renamed/delete within the dropdown - * @param {bool} [options.allowCreate=true] whether new tags can be created - * @param {bool} [options.isAdmin=true] whether the user is an administrator + * @param {boolean} [options.multiple=false] whether to allow selecting multiple tags + * @param {boolean} [options.allowActions=true] whether tags can be renamed/delete within the dropdown + * @param {boolean} [options.allowCreate=true] whether new tags can be created + * @param {boolean} [options.isAdmin=true] whether the user is an administrator * @param {Function} options.initSelection function to convert selection to data */ initialize: function(options) { @@ -162,7 +158,7 @@ import templateSelection from './templates/selection.handlebars' var $item = $(ev.target).closest('.systemtags-item') var tagId = $item.attr('data-id') this.collection.get(tagId).destroy() - $(ev.target).tooltip('hide') + $(ev.target).tooltip('option', 'hide') $item.closest('.select2-result').remove() // TODO: spinner return false @@ -277,7 +273,7 @@ import templateSelection from './templates/selection.handlebars' return templateResult(_.extend({ renameTooltip: t('core', 'Rename'), allowActions: this._allowActions, - tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data)[0].innerHTML : null, + tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data).innerHTML : null, isAdmin: this._isAdmin }, data)) }, @@ -290,7 +286,7 @@ import templateSelection from './templates/selection.handlebars' */ _formatSelection: function(data) { return templateSelection(_.extend({ - tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data)[0].innerHTML : null, + tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data).innerHTML : null, isAdmin: this._isAdmin }, data)) }, diff --git a/core/src/systemtags/systemtagsmappingcollection.js b/core/src/systemtags/systemtagsmappingcollection.js index 62c9fa69b88..78c23ff67f0 100644 --- a/core/src/systemtags/systemtagsmappingcollection.js +++ b/core/src/systemtags/systemtagsmappingcollection.js @@ -1,11 +1,7 @@ -/* - * Copyright (c) 2015 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateRemoteUrl } from '@nextcloud/router' @@ -24,22 +20,22 @@ import { generateRemoteUrl } from '@nextcloud/router' sync: OC.Backbone.davSync, /** - * Use PUT instead of PROPPATCH - */ + * Use PUT instead of PROPPATCH + */ usePUT: true, /** - * Id of the file for which to filter activities by - * - * @var int - */ + * Id of the file for which to filter activities by + * + * @member int + */ _objectId: null, /** - * Type of the object to filter by - * - * @var string - */ + * Type of the object to filter by + * + * @member string + */ _objectType: 'files', model: OC.SystemTags.SystemTagModel, @@ -49,19 +45,19 @@ import { generateRemoteUrl } from '@nextcloud/router' }, /** - * Sets the object id to filter by or null for all. - * - * @param {int} objectId file id or null - */ + * Sets the object id to filter by or null for all. + * + * @param {number} objectId file id or null + */ setObjectId(objectId) { this._objectId = objectId }, /** - * Sets the object type to filter by or null for all. - * - * @param {int} objectType file id or null - */ + * Sets the object type to filter by or null for all. + * + * @param {number} objectType file id or null + */ setObjectType(objectType) { this._objectType = objectType }, diff --git a/core/src/tests/.eslintrc.js b/core/src/tests/.eslintrc.js new file mode 100644 index 00000000000..598fc5c28b4 --- /dev/null +++ b/core/src/tests/.eslintrc.js @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +module.exports = { + globals: { + jsdom: true, + sinon: true, + }, + rules: { + 'node/no-unpublished-import': 'off', + }, +} diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js deleted file mode 100644 index d19a4b8e9c8..00000000000 --- a/core/src/tests/OC/requesttoken.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -/* - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import {JSDOM} from 'jsdom' -import {subscribe, unsubscribe} from '@nextcloud/event-bus' - -import {manageToken, setToken} from '../../OC/requesttoken' - -describe('request token', () => { - - let dom - let emit - let manager - const token = 'abc123' - - beforeEach(() => { - dom = new JSDOM() - emit = sinon.spy() - const head = dom.window.document.getElementsByTagName('head')[0] - head.setAttribute('data-requesttoken', token) - - manager = manageToken(dom.window.document, emit) - }) - - it('reads the token from the document', () => { - expect(manager.getToken()).to.equal('abc123') - }) - - it('remembers the updated token', () => { - manager.setToken('bca321') - - expect(manager.getToken()).to.equal('bca321') - }) - - describe('@nextcloud/auth integration', () => { - let listener - - beforeEach(() => { - listener = sinon.spy() - - subscribe('csrf-token-update', listener) - }) - - afterEach(() => { - unsubscribe('csrf-token-update', listener) - }) - - it('fires off an event for @nextcloud/auth', () => { - setToken('123') - - expect(listener).to.have.been.calledWith({token: '123'}) - }) - }) - -}) diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts new file mode 100644 index 00000000000..8f92dbed153 --- /dev/null +++ b/core/src/tests/OC/requesttoken.spec.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts' + +const eventbus = vi.hoisted(() => ({ emit: vi.fn() })) +vi.mock('@nextcloud/event-bus', () => eventbus) + +const server = setupServer() + +describe('getRequestToken', () => { + it('can read the token from DOM', () => { + mockToken('tokenmock-123') + expect(getRequestToken()).toBe('tokenmock-123') + }) + + it('can handle missing token', () => { + mockToken(undefined) + expect(getRequestToken()).toBeUndefined() + }) +}) + +describe('setRequestToken', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('does emit an event on change', () => { + setRequestToken('new-token') + expect(eventbus.emit).toBeCalledTimes(1) + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('does set the new token to the DOM', () => { + setRequestToken('new-token') + expect(document.head.dataset.requesttoken).toBe('new-token') + }) + + it('does remember the new token', () => { + mockToken('old-token') + setRequestToken('new-token') + expect(getRequestToken()).toBe('new-token') + }) + + it('throws if the token is not a string', () => { + // @ts-expect-error mocking + expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given') + }) + + it('throws if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + }) + + it('does not emit an event if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + expect(eventbus.emit).not.toBeCalled() + }) +}) + +describe('fetchRequestToken', () => { + const successfullCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json({ token: 'new-token' }) + }) + const forbiddenCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 403 }) + }) + const serverErrorCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 500 }) + }) + const networkErrorCsrf = http.get('/index.php/csrftoken', () => { + return new HttpResponse(null, { type: 'error' }) + }) + + beforeAll(() => { + server.listen() + }) + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('correctly parses response', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + const token = await fetchRequestToken() + expect(token).toBe('new-token') + }) + + it('sets the token', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + await fetchRequestToken() + expect(getRequestToken()).toBe('new-token') + }) + + it('does emit an event', async () => { + server.use(successfullCsrf) + + await fetchRequestToken() + expect(eventbus.emit).toHaveBeenCalledOnce() + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('handles 403 error due to invalid cookies', async () => { + server.use(forbiddenCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles server error', async () => { + server.use(serverErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles network error', async () => { + server.use(networkErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrow() + expect(getRequestToken()).toBe('oldToken') + }) +}) + +/** + * Mock the request token directly so we can test reading it. + * + * @param token - The CSRF token to mock + */ +function mockToken(token?: string) { + if (token === undefined) { + delete document.head.dataset.requesttoken + } else { + document.head.dataset.requesttoken = token + } +} diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts new file mode 100644 index 00000000000..61b82d92887 --- /dev/null +++ b/core/src/tests/OC/session-heartbeat.spec.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const requestToken = vi.hoisted(() => ({ + fetchRequestToken: vi.fn<() => Promise<string>>(), + setRequestToken: vi.fn<(token: string) => void>(), +})) +vi.mock('../../OC/requesttoken.ts', () => requestToken) + +const initialState = vi.hoisted(() => ({ loadState: vi.fn() })) +vi.mock('@nextcloud/initial-state', () => initialState) + +describe('Session heartbeat', () => { + beforeAll(() => { + vi.useFakeTimers() + }) + + beforeEach(() => { + vi.clearAllTimers() + vi.resetModules() + vi.resetAllMocks() + }) + + it('sends heartbeat half the session lifetime when heartbeat enabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // reach past half, one call + await vi.advanceTimersByTimeAsync(60 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // almost there to the next, still one + await vi.advanceTimersByTimeAsync(135 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // past it, second call + await vi.advanceTimersByTimeAsync(5 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(2) + }) + + it('does not send heartbeat when heartbeat disabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: false, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // more than one, still nothing + await vi.advanceTimersByTimeAsync(300 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 55, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 30 / 55 seconds + await vi.advanceTimersByTimeAsync(30 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // 59 / 55 seconds should not be called except it does not limit + await vi.advanceTimersByTimeAsync(29 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // now one minute has passed + await vi.advanceTimersByTimeAsync(1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 50 * 60 * 60, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 23 hours + await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // one day - it should be called now + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) +}) diff --git a/core/src/tests/components/ContactsMenu/Contact.spec.js b/core/src/tests/components/ContactsMenu/Contact.spec.js new file mode 100644 index 00000000000..e83f75bfd15 --- /dev/null +++ b/core/src/tests/components/ContactsMenu/Contact.spec.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import { shallowMount } from '@vue/test-utils' + +import Contact from '../../../components/ContactsMenu/Contact.vue' + +describe('Contact', function() { + it('links to the top action', () => { + const view = shallowMount(Contact, { + propsData: { + contact: { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com', + }, + emailAddresses: [], + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: '', + }, + }, + }) + + expect(view.find('li a').exists()).toBe(true) + expect(view.find('li a').attributes('href')).toBe('mailto:deboraoliver%40centrexin.com') + }) +}) diff --git a/core/src/tests/setup.js b/core/src/tests/setup.js deleted file mode 100644 index bec4e11eaea..00000000000 --- a/core/src/tests/setup.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -require('jsdom-global')() -const chai = require('chai') -var sinon = require('sinon') -var sinonChai = require('sinon-chai'); - -chai.use(sinonChai) -global.expect = chai.expect -global.sinon = sinon - -// https://github.com/vuejs/vue-test-utils/issues/936 -// better fix for "TypeError: Super expression must either be null or -// a function" than pinning an old version of prettier. -// -// https://github.com/vuejs/vue-cli/issues/2128#issuecomment-453109575 -window.Date = Date diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js new file mode 100644 index 00000000000..084c3215e47 --- /dev/null +++ b/core/src/tests/views/ContactsMenu.spec.js @@ -0,0 +1,143 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount, shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import ContactsMenu from '../../views/ContactsMenu.vue' + +const axios = vi.hoisted(() => ({ + post: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axios })) + +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: () => ({ uid: 'user', isAdmin: false, displayName: 'User' }), +})) + +describe('ContactsMenu', function() { + it('is closed by default', () => { + const view = shallowMount(ContactsMenu) + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + }) + + it('shows a loading text', async () => { + const view = shallowMount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + const opening = view.vm.handleOpen() + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe('Loading your contacts …') + await opening + }) + + it('shows error view when contacts can not be loaded', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await view.vm.handleOpen() + + throw new Error('should not be reached') + } catch (error) { + expect(console.error).toHaveBeenCalled() + console.error.mockRestore() + expect(view.vm.error).toBe(true) + expect(view.vm.contacts).toEqual([]) + expect(view.text()).toContain('Could not load your contacts') + } + }) + + it('shows text when there are no contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValueOnce({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + expect(view.text()).toContain('No contacts found') + }) + + it('shows contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [ + { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com', + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: '', + emailAddresses: [], + }, + { + id: null, + fullName: 'Adeline Snider', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:ceciliasoto%40essensia.com', + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:pearliesellers%40inventure.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: 'cu', + emailAddresses: [], + }, + ], + contactsAppEnabled: true, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts.length).toBe(2) + expect(view.text()).toContain('Acosta Lancaster') + expect(view.text()).toContain('Adeline Snider') + expect(view.text()).toContain('Show all contacts') + }) +}) diff --git a/core/src/twofactor-request-token.ts b/core/src/twofactor-request-token.ts new file mode 100644 index 00000000000..868ceec01e9 --- /dev/null +++ b/core/src/twofactor-request-token.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { onRequestTokenUpdate } from '@nextcloud/auth' +import { getBaseUrl } from '@nextcloud/router' + +document.addEventListener('DOMContentLoaded', () => { + onRequestTokenUpdate((token) => { + const cancelLink = window.document.getElementById('cancel-login') + if (!cancelLink) { + return + } + + const href = cancelLink.getAttribute('href') + if (!href) { + return + } + + const parsedHref = new URL(href, getBaseUrl()) + parsedHref.searchParams.set('requesttoken', token) + cancelLink.setAttribute('href', parsedHref.pathname + parsedHref.search) + }) +}) diff --git a/core/src/types/navigation.d.ts b/core/src/types/navigation.d.ts new file mode 100644 index 00000000000..5698aab205e --- /dev/null +++ b/core/src/types/navigation.d.ts @@ -0,0 +1,30 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** See NavigationManager */ +export interface INavigationEntry { + /** Navigation id */ + id: string + /** If this is the currently active app */ + active: boolean + /** Order where this entry should be shown */ + order: number + /** Target of the navigation entry */ + href: string + /** The icon used for the naviation entry */ + icon: string + /** Type of the navigation entry ('link' vs 'settings') */ + type: 'link' | 'settings' + /** Localized name of the navigation entry */ + name: string + /** Whether this is the default app */ + default?: boolean + /** App that registered this navigation entry (not necessarly the same as the id) */ + app?: string + /** If this app has unread notification */ + unread: number + /** True when the link should be opened in a new tab */ + target?: boolean +} diff --git a/core/src/unified-search.js b/core/src/unified-search.js deleted file mode 100644 index 0d977383586..00000000000 --- a/core/src/unified-search.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -import { generateFilePath } from '@nextcloud/router' -import { getLoggerBuilder } from '@nextcloud/logger' -import { getRequestToken } from '@nextcloud/auth' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import Vue from 'vue' - -import UnifiedSearch from './views/UnifiedSearch.vue' - -// eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) - -// eslint-disable-next-line camelcase -__webpack_public_path__ = generateFilePath('core', '', 'js/') - -const logger = getLoggerBuilder() - .setApp('unified-search') - .detectUser() - .build() - -Vue.mixin({ - data() { - return { - logger, - } - }, - methods: { - t, - n, - }, -}) - -export default new Vue({ - el: '#unified-search', - // eslint-disable-next-line vue/match-component-file-name - name: 'UnifiedSearchRoot', - render: h => h(UnifiedSearch), -}) diff --git a/core/src/unified-search.ts b/core/src/unified-search.ts new file mode 100644 index 00000000000..a13b1036da1 --- /dev/null +++ b/core/src/unified-search.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' +import { getCSPNonce } from '@nextcloud/auth' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { createPinia, PiniaVuePlugin } from 'pinia' +import Vue from 'vue' + +import UnifiedSearch from './views/UnifiedSearch.vue' +import { useSearchStore } from '../src/store/unified-search-external-filters.js' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() + +const logger = getLoggerBuilder() + .setApp('unified-search') + .detectUser() + .build() + +Vue.mixin({ + data() { + return { + logger, + } + }, + methods: { + t, + n, + }, +}) + +// Define type structure for unified searc action +interface UnifiedSearchAction { + id: string; + appId: string; + searchFrom: string; + label: string; + icon: string; + callback: () => void; +} + +// Register the add/register filter action API globally +window.OCA = window.OCA || {} +window.OCA.UnifiedSearch = { + registerFilterAction: ({ id, appId, searchFrom, label, callback, icon }: UnifiedSearchAction) => { + const searchStore = useSearchStore() + searchStore.registerExternalFilter({ id, appId, searchFrom, label, callback, icon }) + }, +} + +Vue.use(PiniaVuePlugin) +const pinia = createPinia() + +export default new Vue({ + el: '#unified-search', + pinia, + // eslint-disable-next-line vue/match-component-file-name + name: 'UnifiedSearchRoot', + render: h => h(UnifiedSearch), +}) diff --git a/core/src/unsupported-browser-redirect.js b/core/src/unsupported-browser-redirect.js new file mode 100644 index 00000000000..64620afa085 --- /dev/null +++ b/core/src/unsupported-browser-redirect.js @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() + +if (!window.TESTING && !OC?.config?.no_unsupported_browser_warning) { + window.addEventListener('DOMContentLoaded', async function() { + const { testSupportedBrowser } = await import('./utils/RedirectUnsupportedBrowsers.js') + testSupportedBrowser() + }) +} diff --git a/core/src/unsupported-browser.js b/core/src/unsupported-browser.js new file mode 100644 index 00000000000..d54b1c8fb24 --- /dev/null +++ b/core/src/unsupported-browser.js @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' +import Vue from 'vue' + +import { browserStorageKey } from './utils/RedirectUnsupportedBrowsers.js' +import browserStorage from './services/BrowserStorageService.js' +import UnsupportedBrowser from './views/UnsupportedBrowser.vue' + +// If the ignore token is set, redirect +if (browserStorage.getItem(browserStorageKey) === 'true') { + window.location = generateUrl('/') +} + +export default new Vue({ + el: '#unsupported-browser', + // eslint-disable-next-line vue/match-component-file-name + name: 'UnsupportedBrowserRoot', + render: h => h(UnsupportedBrowser), +}) diff --git a/core/src/utils/ClipboardFallback.ts b/core/src/utils/ClipboardFallback.ts new file mode 100644 index 00000000000..b374f9d0a44 --- /dev/null +++ b/core/src/utils/ClipboardFallback.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { t } from '@nextcloud/l10n' + +/** + * + * @param text + */ +function unsecuredCopyToClipboard(text) { + const textArea = document.createElement('textarea') + const textAreaContent = document.createTextNode(text) + textArea.appendChild(textAreaContent) + document.body.appendChild(textArea) + + textArea.focus({ preventScroll: true }) + textArea.select() + + try { + // This is a fallback for browsers that do not support the Clipboard API + // execCommand is deprecated, but it is the only way to copy text to the clipboard in some browsers + document.execCommand('copy') + } catch (err) { + window.prompt(t('core', 'Clipboard not available, please copy manually'), text) + console.error('[ERROR] core: files Unable to copy to clipboard', err) + } + + document.body.removeChild(textArea) +} + +/** + * + */ +function initFallbackClipboardAPI() { + if (!window.navigator?.clipboard?.writeText) { + console.info('[INFO] core: Clipboard API not available, using fallback') + Object.defineProperty(window.navigator, 'clipboard', { + value: { + writeText: unsecuredCopyToClipboard, + }, + writable: false, + }) + } +} + +export { initFallbackClipboardAPI } diff --git a/core/src/utils/RedirectUnsupportedBrowsers.js b/core/src/utils/RedirectUnsupportedBrowsers.js new file mode 100644 index 00000000000..2880d051ca2 --- /dev/null +++ b/core/src/utils/RedirectUnsupportedBrowsers.js @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' +import { supportedBrowsersRegExp } from '../services/BrowsersListService.js' +import browserStorage from '../services/BrowserStorageService.js' +import logger from '../logger.js' + +export const browserStorageKey = 'unsupported-browser-ignore' +const redirectPath = generateUrl('/unsupported') + +const isBrowserOverridden = browserStorage.getItem(browserStorageKey) === 'true' + +/** + * Test the current browser user agent against our official browserslist config + * and redirect if unsupported + */ +export const testSupportedBrowser = function() { + if (supportedBrowsersRegExp.test(navigator.userAgent)) { + logger.debug('this browser is officially supported ! 🚀') + return + } + + // If incompatible BUT ignored, let's keep going + if (isBrowserOverridden) { + logger.debug('this browser is NOT supported but has been manually overridden ! ⚠️') + return + } + + // If incompatible, NOT overridden AND NOT already on the warning page, + // redirect to the unsupported warning page + if (window.location.pathname.indexOf(redirectPath) === -1) { + const redirectUrl = window.location.href.replace(window.location.origin, '') + const base64Param = Buffer.from(redirectUrl).toString('base64') + history.pushState(null, null, `${redirectPath}?redirect_url=${base64Param}`) + window.location.reload() + } +} diff --git a/core/src/utils/xhr-request.js b/core/src/utils/xhr-request.js new file mode 100644 index 00000000000..7f074a857a6 --- /dev/null +++ b/core/src/utils/xhr-request.js @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl, getRootUrl } from '@nextcloud/router' +import logger from '../logger.js' + +/** + * + * @param {string} url the URL to check + * @return {boolean} + */ +const isRelativeUrl = (url) => { + return !url.startsWith('https://') && !url.startsWith('http://') +} + +/** + * @param {string} url The URL to check + * @return {boolean} true if the URL points to this nextcloud instance + */ +const isNextcloudUrl = (url) => { + const nextcloudBaseUrl = window.location.protocol + '//' + window.location.host + getRootUrl() + // if the URL is absolute and starts with the baseUrl+rootUrl + // OR if the URL is relative and starts with rootUrl + return url.startsWith(nextcloudBaseUrl) + || (isRelativeUrl(url) && url.startsWith(getRootUrl())) +} + +/** + * Check if a user was logged in but is now logged-out. + * If this is the case then the user will be forwarded to the login page. + * @return {Promise<void>} + */ +async function checkLoginStatus() { + // skip if no logged in user + if (getCurrentUser() === null) { + return + } + + // skip if already running + if (checkLoginStatus.running === true) { + return + } + + // only run one request in parallel + checkLoginStatus.running = true + + try { + // We need to check this as a 401 in the first place could also come from other reasons + const { status } = await window.fetch(generateUrl('/apps/files')) + if (status === 401) { + console.warn('User session was terminated, forwarding to login page.') + await wipeBrowserStorages() + window.location = generateUrl('/login?redirect_url={url}', { + url: window.location.pathname + window.location.search + window.location.hash, + }) + } + } catch (error) { + console.warn('Could not check login-state') + } finally { + delete checkLoginStatus.running + } +} + +/** + * Clear all Browser storages connected to current origin. + * @return {Promise<void>} + */ +export async function wipeBrowserStorages() { + try { + window.localStorage.clear() + window.sessionStorage.clear() + const indexedDBList = await window.indexedDB.databases() + for (const indexedDB of indexedDBList) { + await window.indexedDB.deleteDatabase(indexedDB.name) + } + logger.debug('Browser storages cleared') + } catch (error) { + logger.error('Could not clear browser storages', { error }) + } +} + +/** + * Intercept XMLHttpRequest and fetch API calls to add X-Requested-With header + * + * This is also done in @nextcloud/axios but not all requests pass through that + */ +export const interceptRequests = () => { + XMLHttpRequest.prototype.open = (function(open) { + return function(method, url, async) { + open.apply(this, arguments) + if (isNextcloudUrl(url)) { + if (!this.getResponseHeader('X-Requested-With')) { + this.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + } + this.addEventListener('loadend', function() { + if (this.status === 401) { + checkLoginStatus() + } + }) + } + } + })(XMLHttpRequest.prototype.open) + + window.fetch = (function(fetch) { + return async (resource, options) => { + // fetch allows the `input` to be either a Request object or any stringifyable value + if (!isNextcloudUrl(resource.url ?? resource.toString())) { + return await fetch(resource, options) + } + if (!options) { + options = {} + } + if (!options.headers) { + options.headers = new Headers() + } + + if (options.headers instanceof Headers && !options.headers.has('X-Requested-With')) { + options.headers.append('X-Requested-With', 'XMLHttpRequest') + } else if (options.headers instanceof Object && !options.headers['X-Requested-With']) { + options.headers['X-Requested-With'] = 'XMLHttpRequest' + } + + const response = await fetch(resource, options) + if (response.status === 401) { + checkLoginStatus() + } + return response + } + })(window.fetch) +} diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue new file mode 100644 index 00000000000..5b7ead636bd --- /dev/null +++ b/core/src/views/AccountMenu.vue @@ -0,0 +1,247 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcHeaderMenu id="user-menu" + class="account-menu" + is-nav + :aria-label="t('core', 'Settings menu')" + :description="avatarDescription"> + <template #trigger> + <!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change --> + <NcAvatar :key="String(showUserStatus)" + class="account-menu__avatar" + disable-menu + disable-tooltip + :show-user-status="showUserStatus" + :user="currentUserId" + :preloaded-user-status="userStatus" /> + </template> + <ul class="account-menu__list"> + <AccountMenuProfileEntry :id="profileEntry.id" + :name="profileEntry.name" + :href="profileEntry.href" + :active="profileEntry.active" /> + <AccountMenuEntry v-for="entry in otherEntries" + :id="entry.id" + :key="entry.id" + :name="entry.name" + :href="entry.href" + :active="entry.active" + :icon="entry.icon" /> + </ul> + </NcHeaderMenu> +</template> + +<script lang="ts"> +import { getCurrentUser } from '@nextcloud/auth' +import { emit, subscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { generateOcsUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { defineComponent } from 'vue' +import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js' + +import axios from '@nextcloud/axios' +import logger from '../logger.js' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue' +import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue' + +interface ISettingsNavigationEntry { + /** + * id of the entry, used as HTML ID, for example, "settings" + */ + id: string + /** + * Label of the entry, for example, "Personal Settings" + */ + name: string + /** + * Icon of the entry, for example, "/apps/settings/img/personal.svg" + */ + icon: string + /** + * Type of the entry + */ + type: 'settings'|'link'|'guest' + /** + * Link of the entry, for example, "/settings/user" + */ + href: string + /** + * Whether the entry is active + */ + active: boolean + /** + * Order of the entry + */ + order: number + /** + * Number of unread pf this items + */ + unread: number + /** + * Classes for custom styling + */ + classes: string +} + +const USER_DEFINABLE_STATUSES = getAllStatusOptions() + +export default defineComponent({ + name: 'AccountMenu', + + components: { + AccountMenuEntry, + AccountMenuProfileEntry, + NcAvatar, + NcHeaderMenu, + }, + + setup() { + const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {}) + const { profile: profileEntry, ...otherEntries } = settingsNavEntries + + return { + currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid, + currentUserId: getCurrentUser()!.uid, + + profileEntry, + otherEntries, + + t, + } + }, + + data() { + return { + showUserStatus: false, + userStatus: { + status: null, + icon: null, + message: null, + }, + } + }, + + computed: { + translatedUserStatus() { + return { + ...this.userStatus, + status: this.translateStatus(this.userStatus.status), + } + }, + + avatarDescription() { + const description = [ + t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }), + ...Object.values(this.translatedUserStatus).filter(Boolean), + ].join(' — ') + return description + }, + }, + + async created() { + if (!getCapabilities()?.user_status?.enabled) { + return + } + + const url = generateOcsUrl('/apps/user_status/api/v1/user_status') + try { + const response = await axios.get(url) + const { status, icon, message } = response.data.ocs.data + this.userStatus = { status, icon, message } + } catch (e) { + logger.error('Failed to load user status') + } + this.showUserStatus = true + }, + + mounted() { + subscribe('user_status:status.updated', this.handleUserStatusUpdated) + emit('core:user-menu:mounted') + }, + + methods: { + handleUserStatusUpdated(state) { + if (this.currentUserId === state.userId) { + this.userStatus = { + status: state.status, + icon: state.icon, + message: state.message, + } + } + }, + + translateStatus(status) { + const statusMap = Object.fromEntries( + USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]), + ) + if (statusMap[status]) { + return statusMap[status] + } + return status + }, + }, +}) +</script> + +<style lang="scss" scoped> +:deep(#header-menu-user-menu) { + padding: 0 !important; +} + +.account-menu { + &__avatar { + --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%); + outline: var(--account-menu-outline); + position: fixed; + // do not apply the alpha mask on the avatar div + mask: none !important; + + &:hover { + --account-menu-outline: none; + // Add hover styles similar to the focus-visible style + border: var(--border-width-input-focused) solid var(--color-background-plain-text); + } + } + + &__list { + display: inline-flex; + flex-direction: column; + padding-block: var(--default-grid-baseline) 0; + padding-inline: 0 var(--default-grid-baseline); + + > :deep(li) { + box-sizing: border-box; + // basically "fit-content" + flex: 0 1; + } + } + + // Ensure we do not waste space, as the header menu sets a default width of 350px + :deep(.header-menu__content) { + width: fit-content !important; + } + + :deep(button) { + // Normally header menus are slightly translucent when not active + // this is generally ok but for the avatar this is weird so fix the opacity + opacity: 1 !important; + + // The avatar is just the "icon" of the button + // So we add the focus-visible manually + &:focus-visible { + .account-menu__avatar { + --account-menu-outline: none; + border: var(--border-width-input-focused) solid var(--color-background-plain-text); + } + } + } +} +</style> diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue new file mode 100644 index 00000000000..924ddcea56b --- /dev/null +++ b/core/src/views/ContactsMenu.vue @@ -0,0 +1,233 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcHeaderMenu id="contactsmenu" + class="contactsmenu" + :aria-label="t('core', 'Search contacts')" + @open="handleOpen"> + <template #trigger> + <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" /> + </template> + <div class="contactsmenu__menu"> + <div class="contactsmenu__menu__input-wrapper"> + <NcTextField id="contactsmenu__menu__search" + ref="contactsMenuInput" + :value.sync="searchTerm" + trailing-button-icon="close" + :label="t('core', 'Search contacts')" + :trailing-button-label="t('core','Reset search')" + :show-trailing-button="searchTerm !== ''" + :placeholder="t('core', 'Search contacts …')" + class="contactsmenu__menu__search" + @input="onInputDebounced" + @trailing-button-click="onReset" /> + </div> + <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + </NcEmptyContent> + <NcEmptyContent v-else-if="loadingText" :name="loadingText"> + <template #icon> + <NcLoadingIcon /> + </template> + </NcEmptyContent> + <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + </NcEmptyContent> + <div v-else class="contactsmenu__menu__content"> + <div id="contactsmenu-contacts"> + <ul> + <Contact v-for="contact in contacts" :key="contact.id" :contact="contact" /> + </ul> + </div> + <div v-if="contactsAppEnabled" class="contactsmenu__menu__content__footer"> + <NcButton type="tertiary" :href="contactsAppURL"> + {{ t('core', 'Show all contacts') }} + </NcButton> + </div> + <div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer"> + <NcButton type="tertiary" :href="contactsAppMgmtURL"> + {{ t('core', 'Install the Contacts app') }} + </NcButton> + </div> + </div> + </div> + </NcHeaderMenu> +</template> + +<script> +import { mdiContacts, mdiMagnify } from '@mdi/js' +import { generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import debounce from 'debounce' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import Contact from '../components/ContactsMenu/Contact.vue' +import logger from '../logger.js' +import Nextcloud from '../mixins/Nextcloud.js' + +export default { + name: 'ContactsMenu', + + components: { + Contact, + NcButton, + NcEmptyContent, + NcHeaderMenu, + NcIconSvgWrapper, + NcLoadingIcon, + NcTextField, + }, + + mixins: [Nextcloud], + + setup() { + return { + mdiContacts, + mdiMagnify, + } + }, + + data() { + const user = getCurrentUser() + return { + contactsAppEnabled: false, + contactsAppURL: generateUrl('/apps/contacts'), + contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'), + canInstallApp: user.isAdmin, + contacts: [], + loadingText: undefined, + error: false, + searchTerm: '', + } + }, + + methods: { + async handleOpen() { + await this.getContacts('') + }, + async getContacts(searchTerm) { + if (searchTerm === '') { + this.loadingText = t('core', 'Loading your contacts …') + } else { + this.loadingText = t('core', 'Looking for {term} …', { + term: searchTerm, + }) + } + + // Let the user try a different query if the previous one failed + this.error = false + + try { + const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), { + filter: searchTerm, + }) + this.contacts = contacts + this.contactsAppEnabled = contactsAppEnabled + this.loadingText = undefined + } catch (error) { + logger.error('could not load contacts', { + error, + searchTerm, + }) + this.error = true + } + }, + onInputDebounced: debounce(function() { + this.getContacts(this.searchTerm) + }, 500), + + /** + * Reset the search state + */ + onReset() { + this.searchTerm = '' + this.contacts = [] + this.focusInput() + }, + + /** + * Focus the search input on next tick + */ + focusInput() { + this.$nextTick(() => { + this.$refs.contactsMenuInput.focus() + this.$refs.contactsMenuInput.select() + }) + }, + + }, +} +</script> + +<style lang="scss" scoped> +.contactsmenu { + overflow-y: hidden; + + &__trigger-icon { + color: var(--color-background-plain-text) !important; + } + + &__menu { + display: flex; + flex-direction: column; + overflow: hidden; + height: calc(50px * 6 + 2px + 26px); + max-height: inherit; + + label[for="contactsmenu__menu__search"] { + font-weight: bold; + font-size: 19px; + margin-inline-start: 13px; + } + + &__input-wrapper { + padding: 10px; + z-index: 2; + top: 0; + } + + &__search { + width: 100%; + height: 34px; + margin-top: 0!important; + } + + &__content { + overflow-y: auto; + margin-top: 10px; + flex: 1 1 auto; + + &__footer { + display: flex; + flex-direction: column; + align-items: center; + } + } + + a { + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--color-main-text) !important; // override rule in core/css/headers.scss #header a:focus-visible + } + } + } + + :deep(.empty-content) { + margin: 0 !important; + } +} +</style> diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue new file mode 100644 index 00000000000..1277970ba0e --- /dev/null +++ b/core/src/views/LegacyUnifiedSearch.vue @@ -0,0 +1,848 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcHeaderMenu id="unified-search" + class="unified-search" + :exclude-click-outside-selectors="['.popover']" + :open.sync="open" + :aria-label="ariaLabel" + @open="onOpen" + @close="onClose"> + <!-- Header icon --> + <template #trigger> + <Magnify class="unified-search__trigger-icon" :size="20" /> + </template> + + <!-- Search form & filters wrapper --> + <div class="unified-search__input-wrapper"> + <div class="unified-search__input-row"> + <NcTextField ref="input" + :value.sync="query" + trailing-button-icon="close" + :label="ariaLabel" + :trailing-button-label="t('core','Reset search')" + :show-trailing-button="query !== ''" + aria-describedby="unified-search-desc" + class="unified-search__form-input" + :class="{'unified-search__form-input--with-reset': !!query}" + :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })" + @trailing-button-click="onReset" + @input="onInputDebounced" /> + <p id="unified-search-desc" class="hidden-visually"> + {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }} + </p> + + <!-- Search filters --> + <NcActions v-if="availableFilters.length > 1" + class="unified-search__filters" + placement="bottom-end" + container=".unified-search__input-wrapper"> + <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 --> + <NcActionButton v-for="filter in availableFilters" + :key="filter" + icon="icon-filter" + @click.stop="onClickFilter(`in:${filter}`)"> + {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }} + </NcActionButton> + </NcActions> + </div> + </div> + + <template v-if="!hasResults"> + <!-- Loading placeholders --> + <SearchResultPlaceholders v-if="isLoading" /> + + <NcEmptyContent v-else-if="isValidQuery" + :title="validQueryTitle"> + <template #icon> + <Magnify /> + </template> + </NcEmptyContent> + + <NcEmptyContent v-else-if="!isLoading || isShortQuery" + :title="t('core', 'Start typing to search')" + :description="shortQueryDescription"> + <template #icon> + <Magnify /> + </template> + </NcEmptyContent> + </template> + + <!-- Grouped search results --> + <template v-for="({list, type}, typesIndex) in orderedResults" v-else> + <h2 :key="type" class="unified-search__results-header"> + {{ typesMap[type] }} + </h2> + <ul :key="type" + class="unified-search__results" + :class="`unified-search__results-${type}`" + :aria-label="typesMap[type]"> + <!-- Search results --> + <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl"> + <SearchResult v-bind="result" + :query="query" + :focused="focused === 0 && typesIndex === 0 && index === 0" + @focus="setFocusedIndex" /> + </li> + + <!-- Load more button --> + <li> + <SearchResult v-if="!reached[type]" + class="unified-search__result-more" + :title="loading[type] + ? t('core', 'Loading more results …') + : t('core', 'Load more results')" + :icon-class="loading[type] ? 'icon-loading-small' : ''" + @click.prevent.stop="loadMore(type)" + @focus="setFocusedIndex" /> + </li> + </ul> + </template> + </NcHeaderMenu> +</template> + +<script> +import debounce from 'debounce' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import Magnify from 'vue-material-design-icons/Magnify.vue' + +import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue' +import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue' + +import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js' + +const REQUEST_FAILED = 0 +const REQUEST_OK = 1 +const REQUEST_CANCELED = 2 + +export default { + name: 'LegacyUnifiedSearch', + + components: { + Magnify, + NcActionButton, + NcActions, + NcEmptyContent, + NcHeaderMenu, + SearchResult, + SearchResultPlaceholders, + NcTextField, + }, + + data() { + return { + types: [], + + // Cursors per types + cursors: {}, + // Various search limits per types + limits: {}, + // Loading types + loading: {}, + // Reached search types + reached: {}, + // Pending cancellable requests + requests: [], + // List of all results + results: {}, + + query: '', + focused: null, + triggered: false, + + defaultLimit, + minSearchLength, + enableLiveSearch, + + open: false, + } + }, + + computed: { + typesIDs() { + return this.types.map(type => type.id) + }, + typesNames() { + return this.types.map(type => type.name) + }, + typesMap() { + return this.types.reduce((prev, curr) => { + prev[curr.id] = curr.name + return prev + }, {}) + }, + + ariaLabel() { + return t('core', 'Search') + }, + + /** + * Is there any result to display + * + * @return {boolean} + */ + hasResults() { + return Object.keys(this.results).length !== 0 + }, + + /** + * Return ordered results + * + * @return {Array} + */ + orderedResults() { + return this.typesIDs + .filter(type => type in this.results) + .map(type => ({ + type, + list: this.results[type], + })) + }, + + /** + * Available filters + * We only show filters that are available on the results + * + * @return {string[]} + */ + availableFilters() { + return Object.keys(this.results) + }, + + /** + * Applied filters + * + * @return {string[]} + */ + usedFiltersIn() { + let match + const filters = [] + while ((match = regexFilterIn.exec(this.query)) !== null) { + filters.push(match[2]) + } + return filters + }, + + /** + * Applied anti filters + * + * @return {string[]} + */ + usedFiltersNot() { + let match + const filters = [] + while ((match = regexFilterNot.exec(this.query)) !== null) { + filters.push(match[2]) + } + return filters + }, + + /** + * Valid query empty content title + * + * @return {string} + */ + validQueryTitle() { + return this.triggered + ? t('core', 'No results for {query}', { query: this.query }) + : t('core', 'Press Enter to start searching') + }, + + /** + * Short query empty content description + * + * @return {string} + */ + shortQueryDescription() { + if (!this.isShortQuery) { + return '' + } + + return n('core', + 'Please enter {minSearchLength} character or more to search', + 'Please enter {minSearchLength} characters or more to search', + this.minSearchLength, + { minSearchLength: this.minSearchLength }) + }, + + /** + * Is the current search too short + * + * @return {boolean} + */ + isShortQuery() { + return this.query && this.query.trim().length < minSearchLength + }, + + /** + * Is the current search valid + * + * @return {boolean} + */ + isValidQuery() { + return this.query && this.query.trim() !== '' && !this.isShortQuery + }, + + /** + * Have we reached the end of all types searches + * + * @return {boolean} + */ + isDoneSearching() { + return Object.values(this.reached).every(state => state === false) + }, + + /** + * Is there any search in progress + * + * @return {boolean} + */ + isLoading() { + return Object.values(this.loading).some(state => state === true) + }, + }, + + async created() { + this.types = await getTypes() + this.logger.debug('Unified Search initialized with the following providers', this.types) + }, + + beforeDestroy() { + unsubscribe('files:navigation:changed', this.onNavigationChange) + }, + + mounted() { + // subscribe in mounted, as onNavigationChange relys on $el + subscribe('files:navigation:changed', this.onNavigationChange) + + if (OCP.Accessibility.disableKeyboardShortcuts()) { + return + } + + document.addEventListener('keydown', (event) => { + // if not already opened, allows us to trigger default browser on second keydown + if (event.ctrlKey && event.code === 'KeyF' && !this.open) { + event.preventDefault() + this.open = true + } else if (event.ctrlKey && event.key === 'f' && this.open) { + // User wants to use the native browser search, so we close ours again + this.open = false + } + + // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus + if (this.open) { + // If arrow down, focus next result + if (event.key === 'ArrowDown') { + this.focusNext(event) + } + + // If arrow up, focus prev result + if (event.key === 'ArrowUp') { + this.focusPrev(event) + } + } + }) + }, + + methods: { + async onOpen() { + // Update types list in the background + this.types = await getTypes() + }, + onClose() { + emit('nextcloud:unified-search.close') + }, + + onNavigationChange() { + this.$el?.querySelector?.('form[role="search"]')?.reset?.() + }, + + /** + * Reset the search state + */ + onReset() { + emit('nextcloud:unified-search.reset') + this.logger.debug('Search reset') + this.query = '' + this.resetState() + this.focusInput() + }, + async resetState() { + this.cursors = {} + this.limits = {} + this.reached = {} + this.results = {} + this.focused = null + this.triggered = false + await this.cancelPendingRequests() + }, + + /** + * Cancel any ongoing searches + */ + async cancelPendingRequests() { + // Cloning so we can keep processing other requests + const requests = this.requests.slice(0) + this.requests = [] + + // Cancel all pending requests + await Promise.all(requests.map(cancel => cancel())) + }, + + /** + * Focus the search input on next tick + */ + focusInput() { + this.$nextTick(() => { + this.$refs.input.focus() + this.$refs.input.select() + }) + }, + + /** + * If we have results already, open first one + * If not, trigger the search again + */ + onInputEnter() { + if (this.hasResults) { + const results = this.getResultsList() + results[0].click() + return + } + this.onInput() + }, + + /** + * Start searching on input + */ + async onInput() { + // emit the search query + emit('nextcloud:unified-search.search', { query: this.query }) + + // Do not search if not long enough + if (this.query.trim() === '' || this.isShortQuery) { + for (const type of this.typesIDs) { + this.$delete(this.results, type) + } + return + } + + let types = this.typesIDs + let query = this.query + + // Filter out types + if (this.usedFiltersNot.length > 0) { + types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1) + } + + // Only use those filters if any and check if they are valid + if (this.usedFiltersIn.length > 0) { + types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1) + } + + // Remove any filters from the query + query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') + + // Reset search if the query changed + await this.resetState() + this.triggered = true + + if (!types.length) { + // no results since no types were selected + this.logger.error('No types to search in') + return + } + + this.$set(this.loading, 'all', true) + this.logger.debug(`Searching ${query} in`, types) + + Promise.all(types.map(async type => { + try { + // Init cancellable request + const { request, cancel } = search({ type, query }) + this.requests.push(cancel) + + // Fetch results + const { data } = await request() + + // Process results + if (data.ocs.data.entries.length > 0) { + this.$set(this.results, type, data.ocs.data.entries) + } else { + this.$delete(this.results, type) + } + + // Save cursor if any + if (data.ocs.data.cursor) { + this.$set(this.cursors, type, data.ocs.data.cursor) + } else if (!data.ocs.data.isPaginated) { + // If no cursor and no pagination, we save the default amount + // provided by server's initial state `defaultLimit` + this.$set(this.limits, type, this.defaultLimit) + } + + // Check if we reached end of pagination + if (data.ocs.data.entries.length < this.defaultLimit) { + this.$set(this.reached, type, true) + } + + // If none already focused, focus the first rendered result + if (this.focused === null) { + this.focused = 0 + } + return REQUEST_OK + } catch (error) { + this.$delete(this.results, type) + + // If this is not a cancelled throw + if (error.response && error.response.status) { + this.logger.error(`Error searching for ${this.typesMap[type]}`, error) + showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] })) + return REQUEST_FAILED + } + return REQUEST_CANCELED + } + })).then(results => { + // Do not declare loading finished if the request have been cancelled + // This means another search was triggered and we're therefore still loading + if (results.some(result => result === REQUEST_CANCELED)) { + return + } + // We finished all searches + this.loading = {} + }) + }, + onInputDebounced: enableLiveSearch + ? debounce(function(e) { + this.onInput(e) + }, 500) + : function() { + this.triggered = false + }, + + /** + * Load more results for the provided type + * + * @param {string} type type + */ + async loadMore(type) { + // If already loading, ignore + if (this.loading[type]) { + return + } + + if (this.cursors[type]) { + // Init cancellable request + const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] }) + this.requests.push(cancel) + + // Fetch results + const { data } = await request() + + // Save cursor if any + if (data.ocs.data.cursor) { + this.$set(this.cursors, type, data.ocs.data.cursor) + } + + // Process results + if (data.ocs.data.entries.length > 0) { + this.results[type].push(...data.ocs.data.entries) + } + + // Check if we reached end of pagination + if (data.ocs.data.entries.length < this.defaultLimit) { + this.$set(this.reached, type, true) + } + } else { + // If no cursor, we might have all the results already, + // let's fake pagination and show the next xxx entries + if (this.limits[type] && this.limits[type] >= 0) { + this.limits[type] += this.defaultLimit + + // Check if we reached end of pagination + if (this.limits[type] >= this.results[type].length) { + this.$set(this.reached, type, true) + } + } + } + + // Focus result after render + if (this.focused !== null) { + this.$nextTick(() => { + this.focusIndex(this.focused) + }) + } + }, + + /** + * Return a subset of the array if the search provider + * doesn't supports pagination + * + * @param {Array} list the results + * @param {string} type the type + * @return {Array} + */ + limitIfAny(list, type) { + if (type in this.limits) { + return list.slice(0, this.limits[type]) + } + return list + }, + + getResultsList() { + return this.$el.querySelectorAll('.unified-search__results .unified-search__result') + }, + + /** + * Focus the first result if any + * + * @param {Event} event the keydown event + */ + focusFirst(event) { + const results = this.getResultsList() + if (results && results.length > 0) { + if (event) { + event.preventDefault() + } + this.focused = 0 + this.focusIndex(this.focused) + } + }, + + /** + * Focus the next result if any + * + * @param {Event} event the keydown event + */ + focusNext(event) { + if (this.focused === null) { + this.focusFirst(event) + return + } + + const results = this.getResultsList() + // If we're not focusing the last, focus the next one + if (results && results.length > 0 && this.focused + 1 < results.length) { + event.preventDefault() + this.focused++ + this.focusIndex(this.focused) + } + }, + + /** + * Focus the previous result if any + * + * @param {Event} event the keydown event + */ + focusPrev(event) { + if (this.focused === null) { + this.focusFirst(event) + return + } + + const results = this.getResultsList() + // If we're not focusing the first, focus the previous one + if (results && results.length > 0 && this.focused > 0) { + event.preventDefault() + this.focused-- + this.focusIndex(this.focused) + } + + }, + + /** + * Focus the specified result index if it exists + * + * @param {number} index the result index + */ + focusIndex(index) { + const results = this.getResultsList() + if (results && results[index]) { + results[index].focus() + } + }, + + /** + * Set the current focused element based on the target + * + * @param {Event} event the focus event + */ + setFocusedIndex(event) { + const entry = event.target + const results = this.getResultsList() + const index = [...results].findIndex(search => search === entry) + if (index > -1) { + // let's not use focusIndex as the entry is already focused + this.focused = index + } + }, + + onClickFilter(filter) { + this.query = `${this.query} ${filter}` + .replace(/ {2}/g, ' ') + .trim() + this.onInput() + }, + }, +} +</script> + +<style lang="scss" scoped> +@use "sass:math"; + +$margin: 10px; +$input-height: 34px; +$input-padding: 10px; + +.unified-search { + &__trigger-icon { + color: var(--color-background-plain-text) !important; + } + + &__input-wrapper { + position: sticky; + // above search results + z-index: 2; + top: 0; + display: inline-flex; + flex-direction: column; + align-items: center; + width: 100%; + background-color: var(--color-main-background); + + label[for="unified-search__input"] { + align-self: flex-start; + font-weight: bold; + font-size: 19px; + margin-inline-start: 13px; + } + } + + &__input-row { + display: flex; + width: 100%; + align-items: center; + } + + &__filters { + margin-block: $margin; + margin-inline: math.div($margin, 2) 0; + padding-top: 5px; + ul { + display: inline-flex; + justify-content: space-between; + } + } + + &__form { + position: relative; + width: 100%; + margin: $margin 0; + + // Loading spinner + &::after { + inset-inline-start: auto $input-padding; + } + + &-input, + &-reset { + margin: math.div($input-padding, 2); + } + + &-input { + width: 100%; + height: $input-height; + padding: $input-padding; + + &:focus, + &:focus-visible, + &:active { + border-color: 2px solid var(--color-main-text) !important; + box-shadow: 0 0 0 2px var(--color-main-background) !important; + } + + &, + &[placeholder], + &::placeholder { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + // Hide webkit clear search + &::-webkit-search-decoration, + &::-webkit-search-cancel-button, + &::-webkit-search-results-button, + &::-webkit-search-results-decoration { + -webkit-appearance: none; + } + } + + &-reset, + &-submit { + position: absolute; + top: 0; + inset-inline-end: 4px; + width: $input-height - $input-padding; + height: $input-height - $input-padding; + min-height: 30px; + padding: 0; + opacity: .5; + border: none; + background-color: transparent; + margin-inline-end: 0; + + &:hover, + &:focus, + &:active { + opacity: 1; + } + } + + &-submit { + inset-inline-end: 28px; + } + } + + &__results { + display: flex; + flex-direction: column; + gap: 4px; + + &-header { + display: block; + margin: $margin; + margin-bottom: $margin - 4px; + margin-inline-start: 13px; + color: var(--color-primary-element); + font-size: 19px; + font-weight: bold; + } + } + + :deep(.unified-search__result-more) { + color: var(--color-text-maxcontrast); + } + + .empty-content { + margin: 10vh 0; + + :deep(.empty-content__title) { + font-weight: normal; + font-size: var(--default-font-size); + text-align: center; + } + } +} + +</style> diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index 0a5708bb5c3..a6fe8442779 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -1,181 +1,189 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div> - <transition name="fade" mode="out-in"> - <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" - key="login"> - <LoginForm - :username.sync="user" - :redirect-url="redirectUrl" - :direct-login="directLogin" - :messages="messages" - :errors="errors" - :throttle-delay="throttleDelay" - :inverted-colors="invertedColors" - :auto-complete-allowed="autoCompleteAllowed" - @submit="loading = true" /> - <a v-if="canResetPassword && resetPasswordLink !== ''" - id="lost-password" - :href="resetPasswordLink"> - {{ t('core', 'Forgot password?') }} - </a> - <a v-else-if="canResetPassword && !resetPassword" - id="lost-password" - :href="resetPasswordLink" - @click.prevent="resetPassword = true"> - {{ t('core', 'Forgot password?') }} - </a> - <br> - <a v-if="hasPasswordless" @click.prevent="passwordlessLogin = true"> - {{ t('core', 'Log in with a device') }} - </a> - </div> - <div v-else-if="!loading && passwordlessLogin" - key="reset" - class="login-additional"> - <PasswordLessLoginForm - :username.sync="user" - :redirect-url="redirectUrl" - :inverted-colors="invertedColors" - :auto-complete-allowed="autoCompleteAllowed" - :is-https="isHttps" - :has-public-key-credential="hasPublicKeyCredential" - @submit="loading = true" /> - <a @click.prevent="passwordlessLogin = false"> - {{ t('core', 'Back') }} - </a> - </div> - <div v-else-if="!loading && canResetPassword" - key="reset" - class="login-additional"> - <div class="lost-password-container"> - <ResetPassword v-if="resetPassword" - :username.sync="user" - :reset-password-link="resetPasswordLink" - :inverted-colors="invertedColors" - @abort="resetPassword = false" /> + <div class="guest-box login-box"> + <template v-if="!hideLoginForm || directLogin"> + <transition name="fade" mode="out-in"> + <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper"> + <LoginForm :username.sync="user" + :redirect-url="redirectUrl" + :direct-login="directLogin" + :messages="messages" + :errors="errors" + :throttle-delay="throttleDelay" + :auto-complete-allowed="autoCompleteAllowed" + :email-states="emailStates" + @submit="loading = true" /> + <NcButton v-if="hasPasswordless" + type="tertiary" + wide + @click.prevent="passwordlessLogin = true"> + {{ t('core', 'Log in with a device') }} + </NcButton> + <NcButton v-if="canResetPassword && resetPasswordLink !== ''" + id="lost-password" + :href="resetPasswordLink" + type="tertiary-no-background" + wide> + {{ t('core', 'Forgot password?') }} + </NcButton> + <NcButton v-else-if="canResetPassword && !resetPassword" + id="lost-password" + type="tertiary" + wide + @click.prevent="resetPassword = true"> + {{ t('core', 'Forgot password?') }} + </NcButton> </div> - </div> - <div v-else-if="resetPasswordTarget !== ''"> - <UpdatePassword :username.sync="user" - :reset-password-target="resetPasswordTarget" - :inverted-colors="invertedColors" - @done="passwordResetFinished" /> - </div> - </transition> + <div v-else-if="!loading && passwordlessLogin" + key="reset-pw-less" + class="login-additional login-box__wrapper"> + <PasswordLessLoginForm :username.sync="user" + :redirect-url="redirectUrl" + :auto-complete-allowed="autoCompleteAllowed" + :is-https="isHttps" + :is-localhost="isLocalhost" + @submit="loading = true" /> + <NcButton type="tertiary" + :aria-label="t('core', 'Back to login form')" + :wide="true" + @click="passwordlessLogin = false"> + {{ t('core', 'Back') }} + </NcButton> + </div> + <div v-else-if="!loading && canResetPassword" + key="reset-can-reset" + class="login-additional"> + <div class="lost-password-container"> + <ResetPassword v-if="resetPassword" + :username.sync="user" + :reset-password-link="resetPasswordLink" + @abort="resetPassword = false" /> + </div> + </div> + <div v-else-if="resetPasswordTarget !== ''"> + <UpdatePassword :username.sync="user" + :reset-password-target="resetPasswordTarget" + @done="passwordResetFinished" /> + </div> + </transition> + </template> + <template v-else> + <transition name="fade" mode="out-in"> + <NcNoteCard type="info" :title="t('core', 'Login form is disabled.')"> + {{ t('core', 'The Nextcloud login form is disabled. Use another login option if available or contact your administration.') }} + </NcNoteCard> + </transition> + </template> + + <div id="alternative-logins" class="login-box__alternative-logins"> + <NcButton v-for="(alternativeLogin, index) in alternativeLogins" + :key="index" + type="secondary" + :wide="true" + :class="[alternativeLogin.class]" + role="link" + :href="alternativeLogin.href"> + {{ alternativeLogin.name }} + </NcButton> + </div> </div> </template> <script> +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' + +import queryString from 'query-string' + import LoginForm from '../components/login/LoginForm.vue' import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue' import ResetPassword from '../components/login/ResetPassword.vue' import UpdatePassword from '../components/login/UpdatePassword.vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import { wipeBrowserStorages } from '../utils/xhr-request.js' + +const query = queryString.parse(location.search) +if (query.clear === '1') { + wipeBrowserStorages() +} export default { name: 'Login', + components: { LoginForm, PasswordLessLoginForm, ResetPassword, UpdatePassword, + NcButton, + NcNoteCard, }, - props: { - username: { - type: String, - default: '', - }, - redirectUrl: { - type: String, - }, - errors: { - type: Array, - default: () => [], - }, - messages: { - type: Array, - default: () => [], - }, - throttleDelay: { - type: Number, - }, - canResetPassword: { - type: Boolean, - default: false, - }, - resetPasswordLink: { - type: String, - }, - resetPasswordTarget: { - type: String, - }, - invertedColors: { - type: Boolean, - default: false, - }, - autoCompleteAllowed: { - type: Boolean, - default: true, - }, - directLogin: { - type: Boolean, - default: false, - }, - hasPasswordless: { - type: Boolean, - default: false, - }, - isHttps: { - type: Boolean, - default: false, - }, - hasPublicKeyCredential: { - type: Boolean, - default: false, - }, - }, + data() { return { loading: false, - user: this.username, + user: loadState('core', 'loginUsername', ''), passwordlessLogin: false, resetPassword: false, + + // Initial data + errors: loadState('core', 'loginErrors', []), + messages: loadState('core', 'loginMessages', []), + redirectUrl: loadState('core', 'loginRedirectUrl', false), + throttleDelay: loadState('core', 'loginThrottleDelay', 0), + canResetPassword: loadState('core', 'loginCanResetPassword', false), + resetPasswordLink: loadState('core', 'loginResetPasswordLink', ''), + autoCompleteAllowed: loadState('core', 'loginAutocomplete', true), + resetPasswordTarget: loadState('core', 'resetPasswordTarget', ''), + resetPasswordUser: loadState('core', 'resetPasswordUser', ''), + directLogin: query.direct === '1', + hasPasswordless: loadState('core', 'webauthn-available', false), + countAlternativeLogins: loadState('core', 'countAlternativeLogins', false), + alternativeLogins: loadState('core', 'alternativeLogins', []), + isHttps: window.location.protocol === 'https:', + isLocalhost: window.location.hostname === 'localhost', + hideLoginForm: loadState('core', 'hideLoginForm', false), + emailStates: loadState('core', 'emailStates', []), } }, + methods: { passwordResetFinished() { - this.resetPasswordTarget = '' - this.directLogin = true + window.location.href = generateUrl('login') }, }, } </script> -<style> - .fade-enter-active, .fade-leave-active { - transition: opacity .3s; +<style scoped lang="scss"> +.login-box { + // Same size as dashboard panels + width: 320px; + box-sizing: border-box; + + &__wrapper { + display: flex; + flex-direction: column; + gap: calc(2 * var(--default-grid-baseline)); } - .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { - opacity: 0; + + &__alternative-logins { + display: flex; + flex-direction: column; + gap: 0.75rem; } +} + +.fade-enter-active, .fade-leave-active { + transition: opacity .3s; +} + +.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { + opacity: 0; +} </style> diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue new file mode 100644 index 00000000000..a05f3a6b889 --- /dev/null +++ b/core/src/views/PublicPageMenu.vue @@ -0,0 +1,131 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <div class="public-page-menu__wrapper"> + <NcButton v-if="primaryAction" + id="public-page-menu--primary" + class="public-page-menu__primary" + :href="primaryAction.href" + type="primary" + @click="openDialogIfNeeded"> + <template v-if="primaryAction.icon" #icon> + <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" /> + </template> + {{ primaryAction.label }} + </NcButton> + + <NcHeaderMenu v-if="secondaryActions.length > 0" + id="public-page-menu" + :aria-label="t('core', 'More actions')" + :open.sync="showMenu"> + <template #trigger> + <IconMore :size="20" /> + </template> + <ul :aria-label="t('core', 'More actions')" + class="public-page-menu" + role="menu"> + <component :is="getComponent(entry)" + v-for="entry, index in secondaryActions" + :key="index" + v-bind="entry" + @click="showMenu = false" /> + </ul> + </NcHeaderMenu> + </div> +</template> + +<script setup lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile' +import { spawnDialog } from '@nextcloud/vue/functions/dialog' +import { computed, ref, type Ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import IconMore from 'vue-material-design-icons/DotsHorizontal.vue' +import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue' +import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue' +import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue' +import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue' +import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue' + +interface IPublicPageMenu { + id: string + label: string + href: string + icon?: string + html?: string + details?: string +} + +const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu') + +/** used to conditionally close the menu when clicking entry */ +const showMenu = ref(false) + +const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>> +/** The primary menu action - only showed when not on mobile */ +const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0]) +/** All other secondary actions (including primary action on mobile) */ +const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1)) + +/** + * Get the render component for an entry + * @param entry The entry to get the component for + */ +function getComponent(entry: IPublicPageMenu) { + if ('html' in entry) { + return PublicPageMenuCustomEntry + } + switch (entry.id) { + case 'save': + return PublicPageMenuExternalEntry + case 'directLink': + return PublicPageMenuLinkEntry + default: + return PublicPageMenuEntry + } +} + +/** + * Open the "federated share" dialog if needed + */ +function openDialogIfNeeded() { + if (primaryAction.value?.id !== 'save') { + return + } + spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label }) +} +</script> + +<style scoped lang="scss"> +.public-page-menu { + box-sizing: border-box; + + > :deep(*) { + box-sizing: border-box; + } + + &__wrapper { + display: flex; + flex-direction: row; + gap: var(--default-grid-baseline); + } + + &__primary { + height: var(--default-clickable-area); + margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2); + + // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image)) + &:focus-visible { + border-color: var(--color-background-plain-text) !important; + } + } + + &__primary-icon { + filter: var(--primary-invert-if-bright); + } +} +</style> diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue new file mode 100644 index 00000000000..7bd6521e7aa --- /dev/null +++ b/core/src/views/PublicPageUserMenu.vue @@ -0,0 +1,138 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcHeaderMenu id="public-page-user-menu" + class="public-page-user-menu" + is-nav + :aria-label="t('core', 'User menu')" + :description="avatarDescription"> + <template #trigger> + <NcAvatar class="public-page-user-menu__avatar" + disable-menu + disable-tooltip + is-guest + :user="displayName || '?'" /> + </template> + + <!-- Privacy notice --> + <NcNoteCard class="public-page-user-menu__list-note" + :text="privacyNotice" + type="info" /> + + <ul class="public-page-user-menu__list"> + <!-- Nickname dialog --> + <AccountMenuEntry id="set-nickname" + :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')" + href="#" + @click.prevent.stop="setNickname"> + <template #icon> + <IconAccount /> + </template> + </AccountMenuEntry> + </ul> + </NcHeaderMenu> +</template> + +<script lang="ts"> +import type { NextcloudUser } from '@nextcloud/auth' + +import '@nextcloud/dialogs/style.css' +import { defineComponent } from 'vue' +import { getGuestUser } from '@nextcloud/auth' +import { showGuestUserPrompt } from '@nextcloud/dialogs' +import { subscribe } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import IconAccount from 'vue-material-design-icons/AccountOutline.vue' + +import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue' + +export default defineComponent({ + name: 'PublicPageUserMenu', + components: { + AccountMenuEntry, + IconAccount, + NcAvatar, + NcHeaderMenu, + NcNoteCard, + }, + + setup() { + return { + t, + } + }, + + data() { + return { + displayName: getGuestUser().displayName, + } + }, + + computed: { + avatarDescription(): string { + return t('core', 'User menu') + }, + + privacyNotice(): string { + return this.displayName + ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName }) + : t('core', 'You are currently not identified.') + }, + }, + + mounted() { + subscribe('user:info:changed', (user: NextcloudUser) => { + this.displayName = user.displayName || '' + }) + }, + + methods: { + setNickname() { + showGuestUserPrompt({ + nickname: this.displayName, + cancellable: true, + }) + }, + }, +}) +</script> + +<style scoped lang="scss"> +.public-page-user-menu { + &, * { + box-sizing: border-box; + } + + // Ensure we do not waste space, as the header menu sets a default width of 350px + :deep(.header-menu__content) { + width: fit-content !important; + } + + &__list-note { + padding-block: 5px !important; + padding-inline: 5px !important; + max-width: 300px; + margin: 5px !important; + margin-bottom: 0 !important; + } + + &__list { + display: inline-flex; + flex-direction: column; + padding-block: var(--default-grid-baseline) 0; + width: 100%; + + > :deep(li) { + box-sizing: border-box; + // basically "fit-content" + flex: 0 1; + } + } +} +</style> diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts new file mode 100644 index 00000000000..f252801c4d8 --- /dev/null +++ b/core/src/views/Setup.cy.ts @@ -0,0 +1,369 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { SetupConfig, SetupLinks } from '../install' +import SetupView from './Setup.vue' + +import '../../css/guest.css' + +const defaultConfig = Object.freeze({ + adminlogin: '', + adminpass: '', + dbuser: '', + dbpass: '', + dbname: '', + dbtablespace: '', + dbhost: '', + dbtype: '', + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + }, + directory: '', + hasAutoconfig: false, + htaccessWorking: true, + serverRoot: '/var/www/html', + errors: [], +}) as SetupConfig + +const links = { + adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install', + adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install', + adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration', +} as SetupLinks + +describe('Default setup page', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders default config', () => { + cy.mockInitialState('core', 'config', defaultConfig) + cy.mount(SetupView) + + cy.get('[data-cy-setup-form]').scrollIntoView() + cy.get('[data-cy-setup-form]').should('be.visible') + + // Single note is the footer help + cy.get('[data-cy-setup-form-note]') + .should('have.length', 1) + .should('be.visible') + cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation') + + // DB radio selectors + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('exist') + .find('input') + .should('be.checked') + + cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist') + cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist') + cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist') + + // Sqlite warning + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('be.visible') + + // admin login, password, data directory and 3 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 6) + }) + + it('Renders single DB sqlite', () => { + const config = { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // No DB radio selectors if only sqlite + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('not.exist') + + // Two warnings: sqlite and single db support + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('be.visible') + cy.get('[data-cy-setup-form-db-note="single-db"]') + .should('be.visible') + + // Admin login, password and data directory + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 3) + }) + + it('Renders single DB mysql', () => { + const config = { + ...defaultConfig, + databases: { + mysql: 'MySQL/MariaDB', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // No DB radio selectors if only mysql + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('not.exist') + + // Single db support warning + cy.get('[data-cy-setup-form-db-note="single-db"]') + .should('be.visible') + .invoke('html') + .should('contains', links.adminSourceInstall) + + // No SQLite warning + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('not.exist') + + // Admin login, password, data directory, db user, + // db password, db name and db host + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 7) + }) + + it('Changes fields from sqlite to mysql then oci', () => { + const config = { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + oci: 'Oracle', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // SQLite selected + cy.get('[data-cy-setup-form-field="dbtype-sqlite"]') + .should('be.visible') + .find('input') + .should('be.checked') + + // Admin login, password, data directory and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 7) + + // Change to MySQL + cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click() + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked') + + // Admin login, password, data directory, db user, db password, + // db name, db host and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 11) + + // Change to Oracle + cy.get('[data-cy-setup-form-field="dbtype-oci"]').click() + cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked') + + // Admin login, password, data directory, db user, db password, + // db name, db table space, db host and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 12) + cy.get('[data-cy-setup-form-field="dbtablespace"]') + .should('be.visible') + }) +}) + +describe('Setup page with errors and warning', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders error from backend', () => { + const config = { + ...defaultConfig, + errors: [ + { + error: 'Error message', + hint: 'Error hint', + }, + ], + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Error message and hint + cy.get('[data-cy-setup-form-note="error"]') + .should('be.visible') + .should('have.length', 1) + .should('contain', 'Error message') + .should('contain', 'Error hint') + }) + + it('Renders errors from backend', () => { + const config = { + ...defaultConfig, + errors: [ + 'Error message 1', + { + error: 'Error message', + hint: 'Error hint', + }, + ], + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Error message and hint + cy.get('[data-cy-setup-form-note="error"]') + .should('be.visible') + .should('have.length', 2) + cy.get('[data-cy-setup-form-note="error"]').eq(0) + .should('contain', 'Error message 1') + cy.get('[data-cy-setup-form-note="error"]').eq(1) + .should('contain', 'Error message') + .should('contain', 'Error hint') + }) + + it('Renders all the submitted fields on error', () => { + const config = { + ...defaultConfig, + adminlogin: 'admin', + adminpass: 'password', + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + cy.get('input[data-cy-setup-form-field="adminlogin"]') + .should('have.value', 'admin') + cy.get('input[data-cy-setup-form-field="adminpass"]') + .should('have.value', 'password') + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input') + .should('be.checked') + cy.get('input[data-cy-setup-form-field="dbname"]') + .should('have.value', 'nextcloud') + cy.get('input[data-cy-setup-form-field="dbuser"]') + .should('have.value', 'nextcloud') + cy.get('input[data-cy-setup-form-field="dbpass"]') + .should('have.value', 'password') + cy.get('input[data-cy-setup-form-field="dbhost"]') + .should('have.value', 'localhost') + cy.get('input[data-cy-setup-form-field="directory"]') + .should('have.value', '/var/www/html/nextcloud') + }) + + it('Renders the htaccess warning', () => { + const config = { + ...defaultConfig, + htaccessWorking: false, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + cy.get('[data-cy-setup-form-note="htaccess"]') + .should('be.visible') + .should('contain', 'Security warning') + .invoke('html') + .should('contains', links.adminInstall) + }) +}) + +describe('Setup page with autoconfig', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders autoconfig', () => { + const config = { + ...defaultConfig, + hasAutoconfig: true, + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Autoconfig info note + cy.get('[data-cy-setup-form-note="autoconfig"]') + .should('be.visible') + .should('contain', 'Autoconfig file detected') + + // Database and storage section is hidden as already set in autoconfig + cy.get('[data-cy-setup-form-advanced-config]').should('be.visible') + .invoke('attr', 'open') + .should('equal', undefined) + + // Oracle tablespace is hidden + cy.get('[data-cy-setup-form-field="dbtablespace"]') + .should('not.exist') + }) +}) + +describe('Submit a full form sends the data', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Submits a full form', () => { + const config = { + ...defaultConfig, + adminlogin: 'admin', + adminpass: 'password', + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + dbtablespace: 'tablespace', + directory: '/var/www/html/nextcloud', + } as SetupConfig + + cy.intercept('POST', '**', { + delay: 2000, + }).as('setup') + + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Not chaining breaks the test as the POST prevents the element from being retrieved twice + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('[data-cy-setup-form-submit]') + .click() + .invoke('attr', 'disabled') + .should('equal', 'disabled', { timeout: 500 }) + + cy.wait('@setup') + .its('request.body') + .should('deep.equal', new URLSearchParams({ + adminlogin: 'admin', + adminpass: 'password', + directory: '/var/www/html/nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbname: 'nextcloud', + dbhost: 'localhost', + }).toString()) + }) +}) diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue new file mode 100644 index 00000000000..50ec0da9035 --- /dev/null +++ b/core/src/views/Setup.vue @@ -0,0 +1,460 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <form ref="form" + class="setup-form" + :class="{ 'setup-form--loading': loading }" + action="" + data-cy-setup-form + method="POST" + @submit="onSubmit"> + <!-- Autoconfig info --> + <NcNoteCard v-if="config.hasAutoconfig" + :heading="t('core', 'Autoconfig file detected')" + data-cy-setup-form-note="autoconfig" + type="success"> + {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }} + </NcNoteCard> + + <!-- Htaccess warning --> + <NcNoteCard v-if="config.htaccessWorking === false" + :heading="t('core', 'Security warning')" + data-cy-setup-form-note="htaccess" + type="warning"> + <p v-html="htaccessWarning" /> + </NcNoteCard> + + <!-- Various errors --> + <NcNoteCard v-for="(error, index) in errors" + :key="index" + :heading="error.heading" + data-cy-setup-form-note="error" + type="error"> + {{ error.message }} + </NcNoteCard> + + <!-- Admin creation --> + <fieldset class="setup-form__administration"> + <legend>{{ t('core', 'Create administration account') }}</legend> + + <!-- Username --> + <NcTextField v-model="config.adminlogin" + :label="t('core', 'Administration account name')" + data-cy-setup-form-field="adminlogin" + name="adminlogin" + required /> + + <!-- Password --> + <NcPasswordField v-model="config.adminpass" + :label="t('core', 'Administration account password')" + data-cy-setup-form-field="adminpass" + name="adminpass" + required /> + + <!-- Password entropy --> + <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType"> + {{ passwordHelperText }} + </NcNoteCard> + </fieldset> + + <!-- Autoconfig toggle --> + <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config> + <summary>{{ t('core', 'Storage & database') }}</summary> + + <!-- Data folder --> + <fieldset class="setup-form__data-folder"> + <NcTextField v-model="config.directory" + :label="t('core', 'Data folder')" + :placeholder="config.serverRoot + '/data'" + required + autocomplete="off" + autocapitalize="none" + data-cy-setup-form-field="directory" + name="directory" + spellcheck="false" /> + </fieldset> + + <!-- Database --> + <fieldset class="setup-form__database"> + <legend>{{ t('core', 'Database configuration') }}</legend> + + <!-- Database type select --> + <fieldset class="setup-form__database-type"> + <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select"> + <NcCheckboxRadioSwitch v-for="(name, db) in config.databases" + :key="db" + v-model="config.dbtype" + :button-variant="true" + :data-cy-setup-form-field="`dbtype-${db}`" + :value="db" + :button-variant-grouped="DBTypeGroupDirection" + name="dbtype" + type="radio"> + {{ name }} + </NcCheckboxRadioSwitch> + </p> + + <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning"> + {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br> + {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br> + <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener"> + {{ t('core', 'For more details check out the documentation.') }} ↗ + </a> + </NcNoteCard> + + <NcNoteCard v-if="config.dbtype === 'sqlite'" + :heading="t('core', 'Performance warning')" + data-cy-setup-form-db-note="sqlite" + type="warning"> + {{ t('core', 'You chose SQLite as database.') }}<br> + {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br> + {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }} + </NcNoteCard> + </fieldset> + + <!-- Database configuration --> + <fieldset v-if="config.dbtype !== 'sqlite'"> + <NcTextField v-model="config.dbuser" + :label="t('core', 'Database user')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbuser" + name="dbuser" + spellcheck="false" + required /> + + <NcPasswordField v-model="config.dbpass" + :label="t('core', 'Database password')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbpass" + name="dbpass" + spellcheck="false" + required /> + + <NcTextField v-model="config.dbname" + :label="t('core', 'Database name')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbname" + name="dbname" + pattern="[0-9a-zA-Z\$_\-]+" + spellcheck="false" + required /> + + <NcTextField v-if="config.dbtype === 'oci'" + v-model="config.dbtablespace" + :label="t('core', 'Database tablespace')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbtablespace" + name="dbtablespace" + spellcheck="false" /> + + <NcTextField v-model="config.dbhost" + :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')" + :label="t('core', 'Database host')" + :placeholder="t('core', 'localhost')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbhost" + name="dbhost" + spellcheck="false" /> + </fieldset> + </fieldset> + </details> + + <!-- Submit --> + <NcButton class="setup-form__button" + :class="{ 'setup-form__button--loading': loading }" + :disabled="loading" + :loading="loading" + :wide="true" + alignment="center-reverse" + data-cy-setup-form-submit + native-type="submit" + type="primary"> + <template #icon> + <NcLoadingIcon v-if="loading" /> + <IconArrowRight v-else /> + </template> + {{ loading ? t('core', 'Installing …') : t('core', 'Install') }} + </NcButton> + + <!-- Help note --> + <NcNoteCard data-cy-setup-form-note="help" type="info"> + {{ t('core', 'Need help?') }} + <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a> + </NcNoteCard> + </form> +</template> +<script lang="ts"> +import type { DbType, SetupConfig, SetupLinks } from '../install' + +import { defineComponent } from 'vue' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import DomPurify from 'dompurify' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' + +enum PasswordStrength { + VeryWeak, + Weak, + Moderate, + Strong, + VeryStrong, + ExtremelyStrong, +} + +const checkPasswordEntropy = (password: string = ''): PasswordStrength => { + const uniqueCharacters = new Set(password) + const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2)) + if (entropy < 16) { + return PasswordStrength.VeryWeak + } else if (entropy < 31) { + return PasswordStrength.Weak + } else if (entropy < 46) { + return PasswordStrength.Moderate + } else if (entropy < 61) { + return PasswordStrength.Strong + } else if (entropy < 76) { + return PasswordStrength.VeryStrong + } + + return PasswordStrength.ExtremelyStrong +} + +export default defineComponent({ + name: 'Setup', + + components: { + IconArrowRight, + NcButton, + NcCheckboxRadioSwitch, + NcLoadingIcon, + NcNoteCard, + NcPasswordField, + NcTextField, + }, + + setup() { + return { + t, + } + }, + + data() { + return { + config: {} as SetupConfig, + links: {} as SetupLinks, + isValidAutoconfig: false, + loading: false, + } + }, + + computed: { + passwordHelperText(): string { + if (this.config?.adminpass === '') { + return '' + } + + const passwordStrength = checkPasswordEntropy(this.config?.adminpass) + switch (passwordStrength) { + case PasswordStrength.VeryWeak: + return t('core', 'Password is too weak') + case PasswordStrength.Weak: + return t('core', 'Password is weak') + case PasswordStrength.Moderate: + return t('core', 'Password is average') + case PasswordStrength.Strong: + return t('core', 'Password is strong') + case PasswordStrength.VeryStrong: + return t('core', 'Password is very strong') + case PasswordStrength.ExtremelyStrong: + return t('core', 'Password is extremely strong') + } + + return t('core', 'Unknown password strength') + }, + passwordHelperType() { + if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) { + return 'error' + } + if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) { + return 'warning' + } + return 'success' + }, + + firstAndOnlyDatabase(): string|null { + const dbNames = Object.values(this.config?.databases || {}) + if (dbNames.length === 1) { + return dbNames[0] + } + + return null + }, + + DBTypeGroupDirection() { + const databases = Object.keys(this.config?.databases || {}) + // If we have more than 3 databases, we want to display them vertically + if (databases.length > 3) { + return 'vertical' + } + return 'horizontal' + }, + + htaccessWarning(): string { + // We use v-html, let's make sure we're safe + const message = [ + t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'), + t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', { + linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">', + linkEnd: '</a>', + }, { escape: false }), + ].join('<br>') + return DomPurify.sanitize(message) + }, + + errors() { + return (this.config?.errors || []).map(error => { + if (typeof error === 'string') { + return { + heading: '', + message: error, + } + } + + // f no hint is set, we don't want to show a heading + if (error.hint === '') { + return { + heading: '', + message: error.error, + } + } + + return { + heading: error.error, + message: error.hint, + } + }) + }, + }, + + beforeMount() { + // Needs to only read the state once we're mounted + // for Cypress to be properly initialized. + this.config = loadState<SetupConfig>('core', 'config') + this.links = loadState<SetupLinks>('core', 'links') + + }, + + mounted() { + // Set the first database type as default if none is set + if (this.config.dbtype === '') { + this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType + } + + // Validate the legitimacy of the autoconfig + if (this.config.hasAutoconfig) { + const form = this.$refs.form as HTMLFormElement + + // Check the form without the administration account fields + form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => { + input.removeAttribute('required') + }) + + if (form.checkValidity() && this.config.errors.length === 0) { + this.isValidAutoconfig = true + } else { + this.isValidAutoconfig = false + } + + // Restore the required attribute + // Check the form without the administration account fields + form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => { + input.setAttribute('required', 'true') + }) + } + }, + + methods: { + async onSubmit() { + this.loading = true + }, + }, +}) +</script> +<style lang="scss"> +form { + padding: calc(3 * var(--default-grid-baseline)); + color: var(--color-main-text); + border-radius: var(--border-radius-container); + background-color: var(--color-main-background-blur); + box-shadow: 0 0 10px var(--color-box-shadow); + -webkit-backdrop-filter: var(--filter-background-blur); + backdrop-filter: var(--filter-background-blur); + + max-width: 300px; + margin-bottom: 30px; + + > fieldset:first-child, + > .notecard:first-child { + margin-top: 0; + } + + > .notecard:last-child { + margin-bottom: 0; + } + + fieldset, + details { + margin-block: 1rem; + } + + .setup-form__button:not(.setup-form__button--loading) { + .material-design-icon { + transition: all linear var(--animation-quick); + } + + &:hover .material-design-icon { + transform: translateX(0.2em); + } + } + + // Db select required styling + .setup-form__database-type-select { + display: flex; + &--vertical { + flex-direction: column; + } + } + +} + +code { + background-color: var(--color-background-dark); + margin-top: 1rem; + padding: 0 0.3em; + border-radius: var(--border-radius); +} + +// Various overrides +.input-field { + margin-block-start: 1rem !important; +} + +.notecard__heading { + font-size: inherit !important; +} +</style> diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue index 45e373ade71..103e47b0425 100644 --- a/core/src/views/UnifiedSearch.vue +++ b/core/src/views/UnifiedSearch.vue @@ -1,754 +1,182 @@ - <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <HeaderMenu id="unified-search" - class="unified-search" - exclude-click-outside-classes="popover" - :open.sync="open" - @open="onOpen" - @close="onClose"> - <!-- Header icon --> - <template #trigger> - <Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" /> - </template> - - <!-- Search form & filters wrapper --> - <div class="unified-search__input-wrapper"> - <form class="unified-search__form" - role="search" - :class="{'icon-loading-small': isLoading}" - @submit.prevent.stop="onInputEnter" - @reset.prevent.stop="onReset"> - <!-- Search input --> - <input ref="input" - v-model="query" - class="unified-search__form-input" - type="search" - :class="{'unified-search__form-input--with-reset': !!query}" - :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })" - @input="onInputDebounced" - @keypress.enter.prevent.stop="onInputEnter"> - - <!-- Reset search button --> - <input v-if="!!query && !isLoading" - type="reset" - class="unified-search__form-reset icon-close" - :aria-label="t('core','Reset search')" - value=""> - </form> - - <!-- Search filters --> - <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom"> - <ActionButton v-for="type in availableFilters" - :key="type" - icon="icon-filter" - :title="t('core', 'Search for {name} only', { name: typesMap[type] })" - @click="onClickFilter(`in:${type}`)"> - {{ `in:${type}` }} - </ActionButton> - </Actions> - </div> - - <template v-if="!hasResults"> - <!-- Loading placeholders --> - <SearchResultPlaceholders v-if="isLoading" /> - - <EmptyContent v-else-if="isValidQuery" icon="icon-search"> - {{ t('core', 'No results for {query}', {query}) }} - </EmptyContent> - - <EmptyContent v-else-if="!isLoading || isShortQuery" icon="icon-search"> - {{ t('core', 'Start typing to search') }} - <template v-if="isShortQuery" #desc> - {{ n('core', - 'Please enter {minSearchLength} character or more to search', - 'Please enter {minSearchLength} characters or more to search', - minSearchLength, - {minSearchLength}) }} - </template> - </EmptyContent> - </template> - - <!-- Grouped search results --> - <template v-else> - <ul v-for="({list, type}, typesIndex) in orderedResults" - :key="type" - class="unified-search__results" - :class="`unified-search__results-${type}`" - :aria-label="typesMap[type]"> - <!-- Search results --> - <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl"> - <SearchResult v-bind="result" - :query="query" - :focused="focused === 0 && typesIndex === 0 && index === 0" - @focus="setFocusedIndex" /> - </li> - - <!-- Load more button --> - <li> - <SearchResult v-if="!reached[type]" - class="unified-search__result-more" - :title="loading[type] - ? t('core', 'Loading more results …') - : t('core', 'Load more results')" - :icon-class="loading[type] ? 'icon-loading-small' : ''" - @click.prevent="loadMore(type)" - @focus="setFocusedIndex" /> - </li> - </ul> - </template> - </HeaderMenu> + <div class="unified-search-menu"> + <NcHeaderButton v-show="!showLocalSearch" + :aria-label="t('core', 'Unified search')" + @click="toggleUnifiedSearch"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + </NcHeaderButton> + <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch" + :open.sync="showLocalSearch" + :query.sync="queryText" + @global-search="openModal" /> + <UnifiedSearchModal :local-search="supportsLocalSearch" + :query.sync="queryText" + :open.sync="showUnifiedSearch" /> + </div> </template> -<script> -import { emit } from '@nextcloud/event-bus' -import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService' -import { showError } from '@nextcloud/dialogs' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import Actions from '@nextcloud/vue/dist/Components/Actions' +<script lang="ts"> +import { mdiMagnify } from '@mdi/js' +import { emit, subscribe } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' import debounce from 'debounce' -import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' -import Magnify from 'vue-material-design-icons/Magnify' - -import HeaderMenu from '../components/HeaderMenu' -import SearchResult from '../components/UnifiedSearch/SearchResult' -import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders' - -const REQUEST_FAILED = 0 -const REQUEST_OK = 1 -const REQUEST_CANCELED = 2 - -export default { +import { defineComponent } from 'vue' +import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue' +import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue' +import logger from '../logger.js' + +export default defineComponent({ name: 'UnifiedSearch', components: { - ActionButton, - Actions, - EmptyContent, - HeaderMenu, - Magnify, - SearchResult, - SearchResultPlaceholders, + NcHeaderButton, + NcIconSvgWrapper, + UnifiedSearchModal, + UnifiedSearchLocalSearchBar, }, - data() { - return { - types: [], - - // Cursors per types - cursors: {}, - // Various search limits per types - limits: {}, - // Loading types - loading: {}, - // Reached search types - reached: {}, - // Pending cancellable requests - requests: [], - // List of all results - results: {}, + setup() { + const currentLocation = useBrowserLocation() - query: '', - focused: null, + return { + currentLocation, - defaultLimit, - minSearchLength, + mdiMagnify, + t, + } + }, - open: false, + data() { + return { + /** The current search query */ + queryText: '', + /** Open state of the modal */ + showUnifiedSearch: false, + /** Open state of the local search bar */ + showLocalSearch: false, } }, computed: { - typesIDs() { - return this.types.map(type => type.id) - }, - typesNames() { - return this.types.map(type => type.name) - }, - typesMap() { - return this.types.reduce((prev, curr) => { - prev[curr.id] = curr.name - return prev - }, {}) - }, - - /** - * Is there any result to display - * @returns {boolean} - */ - hasResults() { - return Object.keys(this.results).length !== 0 - }, - - /** - * Return ordered results - * @returns {Array} - */ - orderedResults() { - return this.typesIDs - .filter(type => type in this.results) - .map(type => ({ - type, - list: this.results[type], - })) - }, - - /** - * Available filters - * We only show filters that are available on the results - * @returns {string[]} - */ - availableFilters() { - return Object.keys(this.results) - }, - - /** - * Applied filters - * @returns {string[]} - */ - usedFiltersIn() { - let match - const filters = [] - while ((match = regexFilterIn.exec(this.query)) !== null) { - filters.push(match[1]) - } - return filters - }, - - /** - * Applied anti filters - * @returns {string[]} - */ - usedFiltersNot() { - let match - const filters = [] - while ((match = regexFilterNot.exec(this.query)) !== null) { - filters.push(match[1]) - } - return filters - }, - - /** - * Is the current search too short - * @returns {boolean} - */ - isShortQuery() { - return this.query && this.query.trim().length < minSearchLength - }, - /** - * Is the current search valid - * @returns {boolean} + * Debounce emitting the search query by 250ms */ - isValidQuery() { - return this.query && this.query.trim() !== '' && !this.isShortQuery + debouncedQueryUpdate() { + return debounce(this.emitUpdatedQuery, 250) }, /** - * Have we reached the end of all types searches - * @returns {boolean} + * Current page (app) supports local in-app search */ - isDoneSearching() { - return Object.values(this.reached).every(state => state === false) + supportsLocalSearch() { + // TODO: Make this an API + const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps'] + return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) }, + }, + watch: { /** - * Is there any search in progress - * @returns {boolean} + * Emit the updated query as eventbus events + * (This is debounced) */ - isLoading() { - return Object.values(this.loading).some(state => state === true) + queryText() { + this.debouncedQueryUpdate() }, }, - async created() { - this.types = await getTypes() - this.logger.debug('Unified Search initialized with the following providers', this.types) - }, - mounted() { - document.addEventListener('keydown', (event) => { - // if not already opened, allows us to trigger default browser on second keydown - if (event.ctrlKey && event.key === 'f' && !this.open) { - event.preventDefault() - this.open = true - this.focusInput() - } - - // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus - if (this.open) { - // If arrow down, focus next result - if (event.key === 'ArrowDown') { - this.focusNext(event) - } + // register keyboard listener for search shortcut + if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) { + window.addEventListener('keydown', this.onKeyDown) + } - // If arrow up, focus prev result - if (event.key === 'ArrowUp') { - this.focusPrev(event) - } - } + // Allow external reset of the search / close local search + subscribe('nextcloud:unified-search:reset', () => { + this.showLocalSearch = false + this.queryText = '' }) - }, - - methods: { - async onOpen() { - this.focusInput() - // Update types list in the background - this.types = await getTypes() - }, - onClose() { - emit('nextcloud:unified-search.close') - }, - - /** - * Reset the search state - */ - onReset() { - emit('nextcloud:unified-search.reset') - this.logger.debug('Search reset') - this.query = '' - this.resetState() - this.focusInput() - }, - async resetState() { - this.cursors = {} - this.limits = {} - this.reached = {} - this.results = {} - this.focused = null - await this.cancelPendingRequests() - }, - - /** - * Cancel any ongoing searches - */ - async cancelPendingRequests() { - // Cloning so we can keep processing other requests - const requests = this.requests.slice(0) - this.requests = [] - - // Cancel all pending requests - await Promise.all(requests.map(cancel => cancel())) - }, - - /** - * Focus the search input on next tick - */ - focusInput() { - this.$nextTick(() => { - this.$refs.input.focus() - this.$refs.input.select() - }) - }, - - /** - * If we have results already, open first one - * If not, trigger the search again - */ - onInputEnter() { - if (this.hasResults) { - const results = this.getResultsList() - results[0].click() - return - } - this.onInput() - }, - - /** - * Start searching on input - */ - async onInput() { - // emit the search query - emit('nextcloud:unified-search.search', { query: this.query }) - - // Do not search if not long enough - if (this.query.trim() === '' || this.isShortQuery) { - return - } - - let types = this.typesIDs - let query = this.query - // Filter out types - if (this.usedFiltersNot.length > 0) { - types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1) - } - - // Only use those filters if any and check if they are valid - if (this.usedFiltersIn.length > 0) { - types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1) - } - - // Remove any filters from the query - query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') - - // Reset search if the query changed - await this.resetState() - this.$set(this.loading, 'all', true) - this.logger.debug(`Searching ${query} in`, types) - - Promise.all(types.map(async type => { - try { - // Init cancellable request - const { request, cancel } = search({ type, query }) - this.requests.push(cancel) - - // Fetch results - const { data } = await request() - - // Process results - if (data.ocs.data.entries.length > 0) { - this.$set(this.results, type, data.ocs.data.entries) - } else { - this.$delete(this.results, type) - } - - // Save cursor if any - if (data.ocs.data.cursor) { - this.$set(this.cursors, type, data.ocs.data.cursor) - } else if (!data.ocs.data.isPaginated) { - // If no cursor and no pagination, we save the default amount - // provided by server's initial state `defaultLimit` - this.$set(this.limits, type, this.defaultLimit) - } - - // Check if we reached end of pagination - if (data.ocs.data.entries.length < this.defaultLimit) { - this.$set(this.reached, type, true) - } - - // If none already focused, focus the first rendered result - if (this.focused === null) { - this.focused = 0 - } - return REQUEST_OK - } catch (error) { - this.$delete(this.results, type) - - // If this is not a cancelled throw - if (error.response && error.response.status) { - this.logger.error(`Error searching for ${this.typesMap[type]}`, error) - showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] })) - return REQUEST_FAILED - } - return REQUEST_CANCELED - } - })).then(results => { - // Do not declare loading finished if the request have been cancelled - // This means another search was triggered and we're therefore still loading - if (results.some(result => result === REQUEST_CANCELED)) { - return - } - // We finished all searches - this.loading = {} - }) - }, - onInputDebounced: debounce(function(e) { - this.onInput(e) - }, 200), - - /** - * Load more results for the provided type - * @param {String} type type - */ - async loadMore(type) { - // If already loading, ignore - if (this.loading[type]) { - return - } - - if (this.cursors[type]) { - // Init cancellable request - const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] }) - this.requests.push(cancel) - - // Fetch results - const { data } = await request() - - // Save cursor if any - if (data.ocs.data.cursor) { - this.$set(this.cursors, type, data.ocs.data.cursor) - } - - // Process results - if (data.ocs.data.entries.length > 0) { - this.results[type].push(...data.ocs.data.entries) - } - - // Check if we reached end of pagination - if (data.ocs.data.entries.length < this.defaultLimit) { - this.$set(this.reached, type, true) - } - } else - - // If no cursor, we might have all the results already, - // let's fake pagination and show the next xxx entries - if (this.limits[type] && this.limits[type] >= 0) { - this.limits[type] += this.defaultLimit - - // Check if we reached end of pagination - if (this.limits[type] >= this.results[type].length) { - this.$set(this.reached, type, true) - } - } - - // Focus result after render - if (this.focused !== null) { - this.$nextTick(() => { - this.focusIndex(this.focused) - }) - } - }, + // Deprecated events to be removed + subscribe('nextcloud:unified-search:reset', () => { + emit('nextcloud:unified-search.reset', { query: '' }) + }) + subscribe('nextcloud:unified-search:search', ({ query }) => { + emit('nextcloud:unified-search.search', { query }) + }) - /** - * Return a subset of the array if the search provider - * doesn't supports pagination - * - * @param {Array} list the results - * @param {string} type the type - * @returns {Array} - */ - limitIfAny(list, type) { - if (type in this.limits) { - return list.slice(0, this.limits[type]) - } - return list - }, + // all done + logger.debug('Unified search initialized!') + }, - getResultsList() { - return this.$el.querySelectorAll('.unified-search__results .unified-search__result') - }, + beforeDestroy() { + // keep in mind to remove the event listener + window.removeEventListener('keydown', this.onKeyDown) + }, + methods: { /** - * Focus the first result if any - * @param {Event} event the keydown event + * Handle the key down event to open search on `ctrl + F` + * @param event The keyboard event */ - focusFirst(event) { - const results = this.getResultsList() - if (results && results.length > 0) { - if (event) { + onKeyDown(event: KeyboardEvent) { + if (event.ctrlKey && event.key === 'f') { + // only handle search if not already open - in this case the browser native search should be used + if (!this.showLocalSearch && !this.showUnifiedSearch) { event.preventDefault() } - this.focused = 0 - this.focusIndex(this.focused) + this.toggleUnifiedSearch() } }, /** - * Focus the next result if any - * @param {Event} event the keydown event + * Toggle the local search if available - otherwise open the unified search modal */ - focusNext(event) { - if (this.focused === null) { - this.focusFirst(event) - return - } - - const results = this.getResultsList() - // If we're not focusing the last, focus the next one - if (results && results.length > 0 && this.focused + 1 < results.length) { - event.preventDefault() - this.focused++ - this.focusIndex(this.focused) + toggleUnifiedSearch() { + if (this.supportsLocalSearch) { + this.showLocalSearch = !this.showLocalSearch + } else { + this.showUnifiedSearch = !this.showUnifiedSearch + this.showLocalSearch = false } }, /** - * Focus the previous result if any - * @param {Event} event the keydown event + * Open the unified search modal */ - focusPrev(event) { - if (this.focused === null) { - this.focusFirst(event) - return - } - - const results = this.getResultsList() - // If we're not focusing the first, focus the previous one - if (results && results.length > 0 && this.focused > 0) { - event.preventDefault() - this.focused-- - this.focusIndex(this.focused) - } - - }, - - /** - * Focus the specified result index if it exists - * @param {number} index the result index - */ - focusIndex(index) { - const results = this.getResultsList() - if (results && results[index]) { - results[index].focus() - } + openModal() { + this.showUnifiedSearch = true + this.showLocalSearch = false }, /** - * Set the current focused element based on the target - * @param {Event} event the focus event + * Emit the updated search query as eventbus events */ - setFocusedIndex(event) { - const entry = event.target - const results = this.getResultsList() - const index = [...results].findIndex(search => search === entry) - if (index > -1) { - // let's not use focusIndex as the entry is already focused - this.focused = index + emitUpdatedQuery() { + if (this.queryText === '') { + emit('nextcloud:unified-search:reset') + } else { + emit('nextcloud:unified-search:search', { query: this.queryText }) } }, - - onClickFilter(filter) { - this.query = `${this.query} ${filter}` - .replace(/ {2}/g, ' ') - .trim() - this.onInput() - }, }, -} +}) </script> <style lang="scss" scoped> -$margin: 10px; -$input-height: 34px; -$input-padding: 6px; - -.unified-search { - &__trigger { - width: 20px; - height: 20px; - } - - &__input-wrapper { - position: sticky; - // above search results - z-index: 2; - top: 0; - display: inline-flex; - align-items: center; - width: 100%; - background-color: var(--color-main-background); - } - - &__filters { - margin: $margin / 2 $margin; - ul { - display: inline-flex; - justify-content: space-between; - } - } - - &__form { - position: relative; - width: 100%; - margin: $margin; - - // Loading spinner - &::after { - right: $input-padding; - left: auto; - } - - &-input, - &-reset { - margin: $input-padding / 2; - } - - &-input { - width: 100%; - height: $input-height; - padding: $input-padding; - - &, - &[placeholder], - &::placeholder { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - // Hide webkit clear search - &::-webkit-search-decoration, - &::-webkit-search-cancel-button, - &::-webkit-search-results-button, - &::-webkit-search-results-decoration { - -webkit-appearance: none; - } - - // Ellipsis earlier if reset button is here - .icon-loading-small &, - &--with-reset { - padding-right: $input-height; - } - } - - &-reset { - position: absolute; - top: 0; - right: 0; - width: $input-height - $input-padding; - height: $input-height - $input-padding; - padding: 0; - opacity: .5; - border: none; - background-color: transparent; - margin-right: 0; - - &:hover, - &:focus, - &:active { - opacity: 1; - } - } - } - - &__filters { - margin-right: $margin / 2; - } - - &__results { - &::before { - display: block; - margin: $margin; - margin-left: $margin + $input-padding; - content: attr(aria-label); - color: var(--color-primary-element); - } - } - - .unified-search__result-more::v-deep { - color: var(--color-text-maxcontrast); - } - - .empty-content { - margin: 10vh 0; - - ::v-deep .empty-content__title { - font-weight: normal; - font-size: var(--default-font-size); - padding: 0 15px; - text-align: center; - } - } +// this is needed to allow us overriding component styles (focus-visible) +.unified-search-menu { + display: flex; + align-items: center; + justify-content: center; } - </style> diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue new file mode 100644 index 00000000000..408cccf61e9 --- /dev/null +++ b/core/src/views/UnsupportedBrowser.vue @@ -0,0 +1,187 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="content-unsupported-browser guest-box"> + <NcEmptyContent> + {{ t('core', 'This browser is not supported') }} + <template #icon> + <Web /> + </template> + <template #action> + <div> + <h2> + {{ t('core', 'Your browser is not supported. Please upgrade to a newer version or a supported one.') }} + </h2> + <NcButton class="content-unsupported-browser__continue" type="primary" @click="forceBrowsing"> + {{ t('core', 'Continue with this unsupported browser') }} + </NcButton> + </div> + + <ul class="content-unsupported-browser__list"> + <h3>{{ t('core', 'Supported versions') }}</h3> + <li v-for="browser in formattedBrowsersList" :key="browser"> + {{ browser }} + </li> + </ul> + </template> + </NcEmptyContent> + </div> +</template> + +<script> +// eslint-disable-next-line n/no-extraneous-import +import { agents } from 'caniuse-lite/dist/unpacker/agents.js' +import { generateUrl, getRootUrl } from '@nextcloud/router' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import Web from 'vue-material-design-icons/Web.vue' + +import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js' +import { supportedBrowsers } from '../services/BrowsersListService.js' +import browserStorage from '../services/BrowserStorageService.js' +import logger from '../logger.js' + +logger.debug('Supported browsers', { supportedBrowsers }) + +export default { + name: 'UnsupportedBrowser', + components: { + Web, + NcButton, + NcEmptyContent, + }, + + computed: { + isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + }, + + /** + * Filter out or include mobile/desktop browsers depending + * on the current user platform/device + */ + filteredSupportedBrowsers() { + return supportedBrowsers.filter(browser => { + if (!browser) { + return false + } + + if (this.isMobile) { + return this.isMobileBrowser(browser) + } + return !this.isMobileBrowser(browser) + }) + }, + + formattedBrowsersList() { + const list = {} + + // supportedBrowsers is generated by webpack at compilation time + this.filteredSupportedBrowsers.forEach(browser => { + const [id, version] = browser.split(' ') + if (!list[id] || list[id] < parseFloat(version, 10)) { + list[id] = parseFloat(version, 10) + } + }) + + return Object.keys(list).map(id => { + if (!agents[id]?.browser) { + return null + } + + const version = list[id] + const name = agents[id]?.browser + return this.t('core', '{name} version {version} and above', { + name, version, + }) + }).filter(entry => entry !== null) + }, + }, + + methods: { + t, + n, + + // Set the flag allowing this browser and redirect to home + forceBrowsing() { + browserStorage.setItem(browserStorageKey, true) + + // Redirect if there is the data + const urlParams = new URLSearchParams(window.location.search) + if (urlParams.has('redirect_url')) { + let redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/' + + // remove index.php and double slashes + redirectPath = redirectPath + .replace('index.php', '') + .replace(getRootUrl(), '') + .replace(/\/\//g, '/') + + // if we have a valid redirect url, use it + if (redirectPath.startsWith('/')) { + window.location = generateUrl(redirectPath) + return + } + } + + // else redirect to root + window.location = generateUrl('/') + }, + + /** + * Detect if the browserslist browser is a mobile one + * https://github.com/browserslist/browserslist#query-composition + * + * @param {string} browser a valid browserlist browser. e.g `and_chr 90` + */ + isMobileBrowser(browser) { + browser = browser.toLowerCase() + return browser.includes('and_') + || browser.includes('android') + || browser.includes('ios_') + || browser.includes('mobile') + || browser.includes('_mob') + || browser.includes('samsung') + }, + }, +} +</script> + +<style lang="scss" scoped> +$spacing: 30px; + +.content-unsupported-browser { + display: flex; + justify-content: center; + width: 400px; + max-width: calc(90vw - 2 * $spacing); + margin: auto; + padding: $spacing; + + .empty-content { + margin: 0; + + :deep(.empty-content__icon) { + opacity: 1; + } + } + + &__continue { + display: block; + margin: $spacing auto; + } + + &__list { + margin-top: 2 * $spacing; + margin-bottom: $spacing; + li { + text-align: start; + } + } +} + +</style> |