diff options
Diffstat (limited to 'core/src')
163 files changed, 7952 insertions, 7264 deletions
diff --git a/core/src/OC/admin.js b/core/src/OC/admin.js index 5c939415266..d29e4cf676b 100644 --- a/core/src/OC/admin.js +++ b/core/src/OC/admin.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ const isAdmin = !!window._oc_isadmin diff --git a/core/src/OC/appconfig.js b/core/src/OC/appconfig.js index 4b03f8db5fb..350ffc3f21c 100644 --- a/core/src/OC/appconfig.js +++ b/core/src/OC/appconfig.js @@ -1,32 +1,11 @@ /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ - import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig' + 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 bbda177409e..dec2b94bfbb 100644 --- a/core/src/OC/apps.js +++ b/core/src/OC/apps.js @@ -1,24 +1,7 @@ /** - * @copyright Bernhard Posselt 2014 - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -90,6 +73,7 @@ export const registerAppsSlideToggle = () => { }) area.removeClass('opened') $(button).removeClass('opened') + $(button).attr('aria-expanded', 'false') } /** @@ -101,6 +85,7 @@ export const registerAppsSlideToggle = () => { }) 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 a81708ca461..00000000000 --- a/core/src/OC/appsettings.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -/* eslint-disable */ -import $ from 'jquery' -import { filePath } from './routing' -import { generateFilePath } from "@nextcloud/router" - -/** - * 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(generateFilePath(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(generateFilePath(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 ec2420eeed5..debbd2084bf 100644 --- a/core/src/OC/appswebroots.js +++ b/core/src/OC/appswebroots.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 ab234e22005..318c50e8ee5 100644 --- a/core/src/OC/backbone-webdav.js +++ b/core/src/OC/backbone-webdav.js @@ -1,25 +1,6 @@ /** - * Copyright (c) 2015 - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ diff --git a/core/src/OC/backbone.js b/core/src/OC/backbone.js index 17ef1c87109..08520e278f6 100644 --- a/core/src/OC/backbone.js +++ b/core/src/OC/backbone.js @@ -1,28 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 c7df9b4f3d1..10623229625 100644 --- a/core/src/OC/capabilities.js +++ b/core/src/OC/capabilities.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities' @@ -32,6 +13,6 @@ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities' * @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 702105a4836..c47df61f6e6 100644 --- a/core/src/OC/config.js +++ b/core/src/OC/config.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 f2ba7bf7a97..5298107e94d 100644 --- a/core/src/OC/constants.js +++ b/core/src/OC/constants.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 b9f4b0fc064..00000000000 --- a/core/src/OC/contactsmenu.js +++ /dev/null @@ -1,468 +0,0 @@ -/** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -/* eslint-disable */ -import _ from 'underscore' -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 {string} options.el - * @class ContactsMenu - * @memberOf OC - */ -const ContactsMenu = function(options) { - this.initialize(options) -} - -ContactsMenu.prototype = { - /** @type {string} */ - $el: undefined, - - /** @type {ContactsMenuView} */ - _view: undefined, - - /** @type {Promise} */ - _contactsPromise: undefined, - - /** - * @param {Object} options - * @param {string} options.el - the selector of the element to render the menu in - * @returns {undefined} - */ - initialize: function(options) { - this.$el = $(options.el) - - this._view = new ContactsMenuView({ - el: this.$el, - }) - - this._view.on('search', function(searchTerm) { - this.loadContacts(searchTerm) - }, this) - }, - - /** - * @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 afb2f627663..00000000000 --- a/core/src/OC/contactsmenu/contact.handlebars +++ /dev/null @@ -1,70 +0,0 @@ -{{#if contact.avatar}} - {{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="profile-link--avatar" href="{{contact.profileUrl}}"> - <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=""> - </a> - {{/if}} - {{else}} - <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=""> - {{/if}} -{{else}} - {{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="profile-link--avatar" href="{{contact.profileUrl}}"> - <div class="avatar"></div> - </a> - {{/if}} - {{else}} - <div class="avatar"></div> - {{/if}} -{{/if}} -{{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="body profile-link--full-name" href="{{contact.profileUrl}}"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </a> - {{/if}} - {{#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}} -{{else if contact.topAction}} - <a class="body" href="{{contact.topAction.hyperlink}}"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </a> - <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}"> - <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}"> - </a> -{{else}} - <div class="body"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </div> -{{/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}} - <button class="other-actions icon-more"></button> - <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 c6e8a8ee62c..a022698eab0 100644 --- a/core/src/OC/currentuser.js +++ b/core/src/OC/currentuser.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 25a6eae9597..52a9ef28145 100644 --- a/core/src/OC/debug.js +++ b/core/src/OC/debug.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 286f9848290..5c6934e67a2 100644 --- a/core/src/OC/dialogs.js +++ b/core/src/OC/dialogs.js @@ -1,79 +1,51 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - * - * @author Bartek Przybylski <bart.p.pl@gmail.com> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Florian Schunk <florian.schunk@rwth-aachen.de> - * @author Gary Kim <gary@garykim.dev> - * @author Hendrik Leppelsack <hendrik@leppelsack.de> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Loïc Hermann <loic.hermann@sciam.fr> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Olivier Paroz <github@oparoz.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sujith Haridasan <Sujith_Haridasan@mentor.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Thomas Tanghus <thomas@tanghus.net> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 { isA11yActivation } from '../Util/a11y' +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( @@ -85,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) @@ -103,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( @@ -122,16 +99,34 @@ 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 */ - 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 @@ -140,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 @@ -161,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. @@ -237,441 +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 - } + filepicker(title, callback, multiselect = false, mimetype = undefined, _modal = undefined, type = FilePickerType.Choose, path = undefined, options = undefined) { - 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() - - 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 (typeof(options.filter) === 'function') { - self.$filePicker.data('filter', options.filter) + /** + * 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 } - if (modal === undefined) { - modal = false - } - if (multiselect === undefined) { - multiselect = false + if (multiselect) { + return (nodes) => fn(nodes.map(getPath), type) + } else { + return (nodes) => fn(getPath(nodes[0]), type) } + } - $('body').prepend(self.$filePicker) - - self.$showGridView = $('button#picker-showgridview') - self.$showGridView.on('click keydown', function(event) { - if (isA11yActivation(event)) { - self._onGridviewChange() - } - }) - 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) { - self.$filePicker.find('.actions.creatable').hide() - } - newButton.on('focus', function() { - self.$filePicker.ocdialog('setEnterCallback', function(event) { - 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.tooltip('hide') - $input.focus() - self.$filePicker.ocdialog('setEnterCallback', function(event) { - 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\']') - $input.on('keydown', function(event) { - if (isA11yActivation(event)) { - event.stopImmediatePropagation() - event.preventDefault() - $form.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}) + } 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', + }) } - - 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' + 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, }) - $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.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, }) - OC.hideMenus() - self.$filePicker.ocdialog('unsetEnterCallback') - self.$filePicker.click() - $input.val(newText) } + return buttons }) - $input.on('input', function(event) { - $input.tooltip('hide') - }) - - 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 keydown', 'div:not(:last-child)', self, function(event) { - if (isA11yActivation(event)) { - self._handleTreeListSelect(event, type) - } - }) - self.$filelist.on('click keydown', 'tr', function(event) { - if (isA11yActivation(event)) { - self._handlePickerClick(event, $(this), type) - } - }) - self.$fileListHeader.on('click keydown', 'a', function(event) { - if (isA11yActivation(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 @@ -680,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 @@ -1038,73 +767,12 @@ const Dialogs = { // } return dialogDeferred.promise() }, - // get the gridview setting and set the input accordingly - _getGridSettings: function() { - const self = this - $.get(OC.generateUrl('/apps/files/api/v1/showgridview'), function(response) { - self.$showGridView - .removeClass('icon-toggle-filelist icon-toggle-pictures') - .addClass(response.gridview ? 'icon-toggle-filelist' : 'icon-toggle-pictures') - self.$showGridView.attr( - 'aria-label', - response.gridview ? t('files', 'Show list view') : t('files', 'Show grid view'), - ) - $('.list-container').toggleClass('view-grid', response.gridview) - }) - }, - _onGridviewChange: function() { - const isGridView = this.$showGridView.hasClass('icon-toggle-filelist') - // only save state if user is logged in - if (OC.currentUser) { - $.post(OC.generateUrl('/apps/files/api/v1/showgridview'), { show: !isGridView }) - } - this.$showGridView - .removeClass('icon-toggle-filelist icon-toggle-pictures') - .addClass(isGridView ? 'icon-toggle-pictures' : 'icon-toggle-filelist') - this.$showGridView.attr( - 'aria-label', - isGridView ? t('files', 'Show grid view') : t('files', 'Show list view'), - ) - this.$filePicker.find('.list-container').toggleClass('view-grid', !isGridView) - }, - _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) }) @@ -1116,270 +784,6 @@ const Dialogs = { } return defer.promise() }, - - /** - * fills the filepicker with files - */ - _fillFilePicker: async 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') - var advancedFilter = this.$filePicker.data('filter') - 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') - } - - // Wrap within a method because a promise cannot return multiple values - // But the client impleemntation still does it... - var getFolderContents = async function(dir) { - return self.filepicker.filesClient.getFolderContents(dir) - .then((status, files) => { - return files - }) - } - - try { - var files = await getFolderContents(dir) - } catch (error) { - // fallback to root if requested dir is non-existent - console.error('Requested path does not exists, falling back to root') - var files = await getFolderContents('/') - this.$filePicker.data('path', '/') - } - - 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 - }) - } - - if (advancedFilter) { - files = files.filter(advancedFilter) - } - - // Check if the showHidden input field exist and if it exist follow it - // Otherwise just show the hidden files - const showHiddenInput = document.getElementById('showHiddenFiles') - const showHidden = showHiddenInput === null || showHiddenInput.value === "1" - if (!showHidden) { - files = files.filter(function(file) { - return !file.name.startsWith('.') - }) - } - - 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) { - if (entry.isEncrypted && entry.mimetype === 'httpd/unix-directory') { - entry.icon = OC.MimeType.getIconUrl('dir-encrypted') - } else { - 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}" tabindex="0"><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 537d68cb434..090c351c057 100644 --- a/core/src/OC/eventsource.js +++ b/core/src/OC/eventsource.js @@ -1,35 +1,13 @@ /** - * @copyright 2012 Robin Appelman icewind1991@gmail.com - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ import $ from 'jquery' -import { getToken } from './requesttoken' +import { getRequestToken } from './requesttoken.ts' /** * Create a new event source @@ -50,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) { diff --git a/core/src/OC/get_set.js b/core/src/OC/get_set.js index 32f202ad35b..0c909ad04fd 100644 --- a/core/src/OC/get_set.js +++ b/core/src/OC/get_set.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const get = context => name => { diff --git a/core/src/OC/host.js b/core/src/OC/host.js index 31f13d01a7f..75c7d63804b 100644 --- a/core/src/OC/host.js +++ b/core/src/OC/host.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const getProtocol = () => window.location.protocol.split(':')[0] diff --git a/core/src/OC/index.js b/core/src/OC/index.js index 5267e2491f4..5afc941b396 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -1,40 +1,19 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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, @@ -45,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, @@ -58,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 { @@ -101,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 { @@ -139,16 +114,11 @@ export default { * @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 @@ -231,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 @@ -251,6 +218,9 @@ export default { msg, Notification, + /** + * @deprecated 28.0.0 use methods from '@nextcloud/password-confirmation' + */ PasswordConfirmation, Plugins, theme, diff --git a/core/src/OC/l10n-registry.js b/core/src/OC/l10n-registry.js deleted file mode 100644 index 9e542b1aa8c..00000000000 --- a/core/src/OC/l10n-registry.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -// 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 - * @return {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 - * @return {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 2a4569ee272..02f912d6a99 100644 --- a/core/src/OC/l10n.js +++ b/core/src/OC/l10n.js @@ -1,106 +1,60 @@ /** - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) - * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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. * + * @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 * @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. * + * @deprecated 26.0.0 use `register` from https://www.npmjs.com/package/@nextcloud/l10 + * * @param {string} appName name of the app - * @param {Object<string, string>} bundle bundle + * @param {Record<string, string>} bundle bundle */ - register(appName, bundle) { - registerAppTranslations(appName, bundle, this._getPlural) - }, + register, /** * @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 @@ -110,49 +64,13 @@ const L10n = { * @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 @@ -162,203 +80,11 @@ const L10n = { * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) * @return {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 - * @return {number} - * @private - */ - _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 - * - * @return {string} locale string - */ -export const getLocale = () => $('html').data('locale') ?? 'en' - -/** - * Returns the user's language - * - * @return {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 e5bbac8f3a0..00000000000 --- a/core/src/OC/legacy-loader.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -/** @typedef {import('jquery')} jQuery */ -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 - * @return {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 7d4d2f91a6c..4b4eb658592 100644 --- a/core/src/OC/menu.js +++ b/core/src/OC/menu.js @@ -1,32 +1,13 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 @@ -123,7 +104,7 @@ export const hideMenus = function(complete) { /** * Shows a given element as menu * - * @param {object} [$toggle=null] menu toggle + * @param {object} [$toggle] menu toggle * @param {object} $menuEl menu element * @param {Function} complete callback when the showing animation is done */ diff --git a/core/src/OC/msg.js b/core/src/OC/msg.js index ef6f9ec0f09..655631a03ff 100644 --- a/core/src/OC/msg.js +++ b/core/src/OC/msg.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author rakekniven <mark.ziegler@rakekniven.de> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' diff --git a/core/src/OC/navigation.js b/core/src/OC/navigation.js index 2102c37b3f5..b279b9a60f3 100644 --- a/core/src/OC/navigation.js +++ b/core/src/OC/navigation.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const redirect = targetURL => { window.location = targetURL } diff --git a/core/src/OC/notification.js b/core/src/OC/notification.js index 949df6a519c..b658f4163bb 100644 --- a/core/src/OC/notification.js +++ b/core/src/OC/notification.js @@ -1,28 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author npmbuildbot[bot] "npmbuildbot[bot]@users.noreply.github.com" - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' @@ -98,7 +76,7 @@ export default { * @param {string} html Message to display * @param {object} [options] options * @param {string} [options.type] notification type - * @param {number} [options.timeout=0] timeout value, defaults to 0 (permanent) + * @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 */ @@ -117,7 +95,7 @@ export default { * @param {string} text Message to display * @param {object} [options] options * @param {string} [options.type] notification type - * @param {number} [options.timeout=0] timeout value, defaults to 0 (permanent) + * @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 */ @@ -142,7 +120,7 @@ export default { * Updates (replaces) a sanitized notification. * * @param {string} text Message to display - * @return {jQuery} JQuery element for notificaiton row + * @return {jQuery} JQuery element for notification row * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ showUpdate(text) { @@ -160,10 +138,10 @@ export default { * * @param {string} text Message to show * @param {Array} [options] options array - * @param {number} [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 {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 - * @return {JQuery} the toast element + * @return {jQuery} the toast element * @deprecated 17.0.0 use the `@nextcloud/dialogs` package */ showTemporary(text, options) { diff --git a/core/src/OC/password-confirmation.js b/core/src/OC/password-confirmation.js index 4aa643c19fb..621f7a0695f 100644 --- a/core/src/OC/password-confirmation.js +++ b/core/src/OC/password-confirmation.js @@ -1,130 +1,26 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 4425c118589..8212fc0b4ee 100644 --- a/core/src/OC/plugins.js +++ b/core/src/OC/plugins.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default { diff --git a/core/src/OC/query-string.js b/core/src/OC/query-string.js index 56bf85186fb..df0f366133a 100644 --- a/core/src/OC/query-string.js +++ b/core/src/OC/query-string.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -28,7 +9,7 @@ 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 - * @return {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 @@ -77,7 +58,7 @@ 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 + * @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 => { diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js deleted file mode 100644 index eba15e88e08..00000000000 --- a/core/src/OC/requesttoken.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -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 - * @return {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) - -/** - * @return {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 8752aa3883e..4b81714d6f0 100644 --- a/core/src/OC/routing.js +++ b/core/src/OC/routing.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { diff --git a/core/src/OC/theme.js b/core/src/OC/theme.js index b7fcfd8ce4d..af45c37de7e 100644 --- a/core/src/OC/theme.js +++ b/core/src/OC/theme.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 a7398b4a2fc..7ecd0e098c6 100644 --- a/core/src/OC/util-history.js +++ b/core/src/OC/util-history.js @@ -1,28 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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, @@ -45,7 +27,7 @@ export default { * 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 diff --git a/core/src/OC/util.js b/core/src/OC/util.js index e1a2f8f0687..c46d9a141b1 100644 --- a/core/src/OC/util.js +++ b/core/src/OC/util.js @@ -1,30 +1,12 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ 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' /** @@ -64,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, @@ -73,7 +55,7 @@ export default { * Makes 2kB to 2048. * Inspired by computerFileSize in helper.php * - * @param {string} string file size in human readable format + * @param {string} string file size in human-readable format * @return {number} or null if string could not be parsed * * @@ -124,7 +106,7 @@ export default { */ 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) @@ -136,7 +118,7 @@ export default { */ 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) { diff --git a/core/src/OC/webroot.js b/core/src/OC/webroot.js index f5d063d6b50..cbe5a6190e1 100644 --- a/core/src/OC/webroot.js +++ b/core/src/OC/webroot.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 990340a1fda..233aaf60350 100644 --- a/core/src/OC/xhr-error.js +++ b/core/src/OC/xhr-error.js @@ -1,41 +1,25 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 }) /** @@ -46,13 +30,13 @@ export const ajaxConnectionLostHandler = _.throttle(() => { */ 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) { @@ -65,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 1fde0dc5918..cf5c29ce60a 100644 --- a/core/src/OCA/index.js +++ b/core/src/OCA/index.js @@ -1,35 +1,11 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 10b629a0cf3..00000000000 --- a/core/src/OCA/search.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -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 index 3839509228f..4a1399f3f96 100644 --- a/core/src/OCP/accessibility.js +++ b/core/src/OCP/accessibility.js @@ -1,27 +1,22 @@ /** - * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 @@ -29,4 +24,5 @@ export default { disableKeyboardShortcuts() { return loadState('theming', 'shortcutsDisabled', false) }, + setPageHeading, } diff --git a/core/src/OCP/appconfig.js b/core/src/OCP/appconfig.js index 57e13048fe0..78f94922d53 100644 --- a/core/src/OCP/appconfig.js +++ b/core/src/OCP/appconfig.js @@ -1,30 +1,13 @@ /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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' diff --git a/core/src/OCP/collaboration.js b/core/src/OCP/collaboration.js index 2e49cebe9f1..82ff34392cf 100644 --- a/core/src/OCP/collaboration.js +++ b/core/src/OCP/collaboration.js @@ -1,31 +1,13 @@ /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import escapeHTML from 'escape-html' /** * @typedef TypeDefinition - * @function {Function} 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 * @function Object() { [native code] } */ diff --git a/core/src/OCP/comments.js b/core/src/OCP/comments.js index cd1e8a8fa77..34699a477d1 100644 --- a/core/src/OCP/comments.js +++ b/core/src/OCP/comments.js @@ -1,24 +1,6 @@ /** - * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -34,7 +16,7 @@ import $ from 'jquery' * * 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 - diff --git a/core/src/OCP/index.js b/core/src/OCP/index.js index 12766ad4977..94f4e8e5eb3 100644 --- a/core/src/OCP/index.js +++ b/core/src/OCP/index.js @@ -1,37 +1,18 @@ /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { loadState } from '@nextcloud/initial-state' -import * as AppConfig from './appconfig' -import * as Comments from './comments' -import * as WhatsNew from './whatsnew' +import * as AppConfig from './appconfig.js' +import * as Comments from './comments.js' +import * as WhatsNew from './whatsnew.js' -import Accessibility from './accessibility' -import Collaboration from './collaboration' -import Loader from './loader' -import Toast from './toast' +import Accessibility from './accessibility.js' +import Collaboration from './collaboration.js' +import Loader from './loader.js' +import Toast from './toast.js' /** @namespace OCP */ export default { diff --git a/core/src/OCP/loader.js b/core/src/OCP/loader.js index 6e58208a849..d307eb27996 100644 --- a/core/src/OCP/loader.js +++ b/core/src/OCP/loader.js @@ -1,27 +1,10 @@ /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { generateFilePath } from '@nextcloud/router' + const loadedScripts = {} const loadedStylesheets = {} /** @@ -44,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)) @@ -68,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 40c46aa8f51..f93344bbc8e 100644 --- a/core/src/OCP/toast.js +++ b/core/src/OCP/toast.js @@ -1,24 +1,6 @@ /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { @@ -28,6 +10,8 @@ import { showWarning, } from '@nextcloud/dialogs' +/** @typedef {import('toastify-js')} Toast */ + export default { /** * @deprecated 19.0.0 use `showSuccess` from the `@nextcloud/dialogs` package instead diff --git a/core/src/OCP/whatsnew.js b/core/src/OCP/whatsnew.js index aafe1740e0d..acada6a8383 100644 --- a/core/src/OCP/whatsnew.js +++ b/core/src/OCP/whatsnew.js @@ -1,24 +1,6 @@ /** - * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import _ from 'underscore' diff --git a/core/src/Polyfill/index.js b/core/src/Polyfill/index.js deleted file mode 100644 index 610619217d2..00000000000 --- a/core/src/Polyfill/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import 'focus-visible' diff --git a/core/src/Polyfill/tooltip.js b/core/src/Polyfill/tooltip.js deleted file mode 100644 index 452cb30305b..00000000000 --- a/core/src/Polyfill/tooltip.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @copyright 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import $ from 'jquery' - -$.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/Util/a11y.js b/core/src/Util/a11y.js index 550ae375e02..2eb753b3faf 100644 --- a/core/src/Util/a11y.js +++ b/core/src/Util/a11y.js @@ -1,23 +1,6 @@ /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /** diff --git a/core/src/Util/get-url-parameter.js b/core/src/Util/get-url-parameter.js index 00fa66a9eb3..6df264f009f 100644 --- a/core/src/Util/get-url-parameter.js +++ b/core/src/Util/get-url-parameter.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /** @@ -27,6 +9,6 @@ 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 index ed3a6293c57..88f626ff569 100644 --- a/core/src/components/AppMenu.vue +++ b/core/src/components/AppMenu.vue @@ -1,298 +1,161 @@ <!-- - - @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <nav class="app-menu"> - <ul class="app-menu-main"> - <li v-for="app in mainAppList" + <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" - :data-app-id="app.id" - class="app-menu-entry" - :class="{ 'app-menu-entry__active': app.active }"> - <a :href="app.href" - :class="{ 'has-unread': app.unread > 0 }" - :aria-label="appLabel(app)" - :title="app.name" - :aria-current="app.active ? 'page' : false" - :target="app.target ? '_blank' : undefined" - :rel="app.target ? 'noopener noreferrer' : undefined"> - <img :src="app.icon" alt=""> - <div class="app-menu-entry--label"> - {{ app.name }} - <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span> - </div> - </a> - </li> + :app="app" /> </ul> - <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')"> + <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')"> <NcActionLink v-for="app in popoverAppList" :key="app.id" - :aria-label="appLabel(app)" :aria-current="app.active ? 'page' : false" :href="app.href" - class="app-menu-popover-entry"> - <template #icon> - <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }"> - <img :src="app.icon" alt=""> - </div> - </template> + :icon="app.icon" + class="app-menu__overflow-entry"> {{ app.name }} - <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span> </NcActionLink> </NcActions> </nav> </template> -<script> -import { loadState } from '@nextcloud/initial-state' +<script lang="ts"> +import type { INavigationEntry } from '../types/navigation' + import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import NcActions from '@nextcloud/vue/dist/Components/NcActions' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' +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 { +export default defineComponent({ name: 'AppMenu', + components: { - NcActions, NcActionLink, + 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 { - apps: loadState('core', 'apps', {}), - appLimit: 0, - observer: null, + appList, } }, + computed: { - appList() { - return Object.values(this.apps) + 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) }, - appLabel() { - return (app) => app.name - + (app.active ? ' (' + t('core', 'Currently open') + ')' : '') - + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '') - }, }, + mounted() { - this.observer = new ResizeObserver(this.resize) - this.observer.observe(this.$el) - this.resize() subscribe('nextcloud:app-menu.refresh', this.setApps) }, + beforeDestroy() { - this.observer.disconnect() unsubscribe('nextcloud:app-menu.refresh', this.setApps) }, + methods: { - setNavigationCounter(id, counter) { - this.$set(this.apps[id], 'unread', counter) - }, - setApps({ apps }) { - this.apps = apps - }, - resize() { - const availableWidth = this.$el.offsetWidth - let appCount = Math.floor(availableWidth / 50) - 1 - const popoverAppCount = this.appList.length - appCount - if (popoverAppCount === 1) { - appCount-- + 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`) } - if (appCount < 1) { - appCount = 0 - } - this.appLimit = appCount + }, + + setApps({ apps }: { apps: INavigationEntry[]}) { + this.appList = apps }, }, -} +}) </script> -<style lang="scss" scoped> -$header-icon-size: 20px; - +<style scoped lang="scss"> .app-menu { - width: 100%; - display: flex; - flex-shrink: 1; - flex-wrap: wrap; -} -.app-menu-main { + // 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-wrap: nowrap; + flex: 1 1; + width: 0; - .app-menu-entry { - width: 50px; - height: 50px; - position: relative; + &__list { display: flex; - opacity: .7; - filter: var(--background-image-invert-if-bright); - - &.app-menu-entry__active { - opacity: 1; - - &::before { - content: " "; - position: absolute; - pointer-events: none; - border-bottom-color: var(--color-main-background); - transform: translateX(-50%); - width: 12px; - height: 5px; - border-radius: 3px; - background-color: var(--color-primary-text); - left: 50%; - bottom: 6px; - display: block; - transition: all 0.1s ease-in-out; - opacity: 1; - } + flex-wrap: nowrap; + margin-inline: calc(var(--app-menu-entry-growth) / 2); + } - .app-menu-entry--label { - font-weight: bold; - } - } + &__overflow { + margin-block: auto; - a { - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - color: var(--color-primary-text); - position: relative; - } + // 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); - img { - transition: margin 0.1s ease-in-out; - width: $header-icon-size; - height: $header-icon-size; - padding: calc((100% - $header-icon-size) / 2); - box-sizing: content-box; - } + /* Remove all background and align text color if not expanded */ + &:not([aria-expanded="true"]) { + color: var(--color-background-plain-text); - .app-menu-entry--label { - opacity: 0; - position: absolute; - font-size: 12px; - color: var(--color-primary-text); - text-align: center; - bottom: -5px; - left: 50%; - top: 45%; - display: block; - min-width: 100%; - transform: translateX(-50%); - transition: all 0.1s ease-in-out; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - letter-spacing: -0.5px; - } + &:hover { + opacity: 1; + background-color: transparent !important; + } + } - &:hover, - &:focus-within { - opacity: 1; - .app-menu-entry--label { + &:focus-visible { opacity: 1; - font-weight: bolder; - bottom: 0; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; + outline: none !important; } } - - } - - // Show labels - &:hover, - &:focus-within, - .app-menu-entry:hover, - .app-menu-entry:focus { - opacity: 1; - - img { - margin-top: -8px; - } - - .app-menu-entry--label { - opacity: 1; - bottom: 0; - } - - &::before, .app-menu-entry::before { - opacity: 0; - } - } -} - -::v-deep .app-menu-more .button-vue--vue-tertiary { - color: var(--color-primary-text); - opacity: .7; - margin: 3px; - filter: var(--background-image-invert-if-bright); - - &:hover { - opacity: 1; - background-color: transparent !important; - } - - &:focus-visible { - opacity: 1; - outline: none !important; } -} -.app-menu-popover-entry { - .app-icon { - position: relative; - height: 44px; - - &.has-unread::after { - background-color: var(--color-main-text); - } - - img { - width: $header-icon-size; - height: $header-icon-size; - padding: calc((50px - $header-icon-size) / 2); + &__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; } } } - -.has-unread::after { - content: ""; - width: 8px; - height: 8px; - background-color: var(--color-primary-text); - border-radius: 50%; - position: absolute; - display: block; - top: 10px; - right: 10px; -} - -.unread-counter { - display: none; -} </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 1b7b25873d0..e07a699ab9f 100644 --- a/core/src/components/ContactsMenu.js +++ b/core/src/components/ContactsMenu.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Christopher Ng <chrng8@gmail.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' @@ -34,6 +15,7 @@ export const setUp = () => { 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/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 46e0e5c510b..21a0b6a772f 100644 --- a/core/src/components/MainMenu.js +++ b/core/src/components/MainMenu.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { translate as t, translatePlural as n } from '@nextcloud/l10n' @@ -36,7 +17,7 @@ export const setUp = () => { }, }) - const container = document.getElementById('header-left__appmenu') + const container = document.getElementById('header-start__appmenu') if (!container) { // no container, possibly we're on a public page return diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue index 7a1f031b60c..dbc446b3d90 100644 --- a/core/src/components/Profile/PrimaryActionButton.vue +++ b/core/src/components/Profile/PrimaryActionButton.vue @@ -1,43 +1,36 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.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: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <a class="profile__primary-action-button" - :class="{ 'disabled': disabled }" + <NcButton type="primary" :href="href" + alignment="center" :target="target" - rel="noopener noreferrer nofollow" - v-on="$listeners"> - <img class="icon" - :class="[icon, { 'icon-invert': colorPrimaryText === '#ffffff' }]" - :src="icon"> + :disabled="disabled"> + <template #icon> + <img class="icon" + aria-hidden="true" + :src="icon" + alt=""> + </template> <slot /> - </a> + </NcButton> </template> <script> -export default { +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, @@ -58,46 +51,14 @@ export default { }, }, - computed: { - colorPrimaryText() { - // For some reason the returned string has prepended whitespace - return getComputedStyle(document.body).getPropertyValue('--color-primary-text').trim() - }, + methods: { + t, }, -} +}) </script> <style lang="scss" scoped> - .profile__primary-action-button { - font-size: var(--default-font-size); - font-weight: bold; - width: 188px; - height: 44px; - padding: 0 16px; - line-height: 44px; - text-align: center; - border-radius: var(--border-radius-pill); - color: var(--color-primary-text); - background-color: var(--color-primary-element); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - .icon { - display: inline-block; - vertical-align: middle; - margin-bottom: 2px; - margin-right: 4px; - - &.icon-invert { - filter: invert(1); - } - } - - &:hover, - &:focus, - &:active { - background-color: var(--color-primary-element-light); - } + .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 0b8b6c8b33e..4f33fbd54cc 100644 --- a/core/src/components/UnifiedSearch/SearchResult.vue +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -1,73 +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})` : '', - }"> - - <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> + <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 NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight' +import NcListItem from '@nextcloud/vue/components/NcListItem' export default { name: 'SearchResult', - components: { - NcHighlight, + NcListItem, }, - props: { thumbnailUrl: { type: String, @@ -108,111 +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> -@use "sass:math"; - -$clickable-area: 44px; -$margin: 10px; - -.unified-search__result { - display: flex; - align-items: center; - height: $clickable-area; - padding: $margin; - border-bottom: 1px solid var(--color-border); - border-radius: var(--border-radius-large) !important; - - // 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: math.div($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 { @@ -224,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 d2a297a0a37..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 --> 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 f82a303d1fd..5c488f2341e 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -1,60 +1,20 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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') - // Using page terminoogy as below - const $excludedPageClasses = [ - 'user-status-menu-item__header', - ] - - // 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) { - if (!$excludedPageClasses.includes($page.attr('class'))) { - $page.find('img').remove() - $page.find('div').remove() // prevent odd double-clicks - $page.prepend($('<div></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 3d3ac25de6d..da387df0ff6 100644 --- a/core/src/components/login/LoginButton.vue +++ b/core/src/components/login/LoginButton.vue @@ -1,28 +1,13 @@ <!-- - - @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> <NcButton type="primary" native-type="submit" :wide="true" + :disabled="loading" @click="$emit('click')"> {{ !loading ? value : valueLoading }} <template #icon> @@ -33,7 +18,9 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton' +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 { 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 c7b0a9259f9..8cbe55f1f68 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -1,23 +1,7 @@ <!-- - - @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" @@ -32,6 +16,11 @@ 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"> @@ -52,25 +41,28 @@ <!-- the following div ensures that the spinner is always inside the #message div --> <div style="clear: both;" /> </div> - <h2 class="login-form__headline" data-login-form-headline v-html="headline" /> + <h2 class="login-form__headline" data-login-form-headline> + {{ headlineText }} + </h2> <NcTextField id="user" ref="user" - :label="t('core', 'Account name or email')" - :label-visible="true" + :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" - :label-visible="true" :class="{shake: invalidPassword}" :value.sync="password" :spellchecking="false" @@ -96,7 +88,7 @@ :value="timezoneOffset"> <input type="hidden" name="requesttoken" - :value="OC.requestToken"> + :value="requestToken"> <input v-if="directLogin" type="hidden" name="direct" @@ -106,12 +98,16 @@ </template> <script> +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/dist/Components/NcPasswordField.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +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 { @@ -124,6 +120,8 @@ export default { NcNoteCard, }, + mixins: [AuthMixin], + props: { username: { type: String, @@ -153,30 +151,61 @@ export default { type: Boolean, default: false, }, + emailStates: { + type: Array, + default() { + return [] + }, + }, }, - data() { + setup() { + // non reactive props return { - loading: false, + 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), - headline: t('core', 'Log in to {productName}', { productName: OC.theme.name }), + } + }, + + data() { + return { + loading: false, user: '', 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 username or password.') + return t('core', 'Wrong login or password.') } if (this.userDisabled) { - return t('core', 'User disabled') + 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.') @@ -186,6 +215,9 @@ export default { apacheAuthFailed() { return this.errors.indexOf('apacheAuthFailed') !== -1 }, + csrfCheckFailed() { + return this.errors.indexOf('csrfCheckFailed') !== -1 + }, internalException() { return this.errors.indexOf('internalexception') !== -1 }, @@ -201,6 +233,24 @@ export default { 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() { @@ -213,10 +263,24 @@ export default { }, methods: { + /** + * 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') }, @@ -226,8 +290,9 @@ export default { <style lang="scss" scoped> .login-form { - text-align: left; + text-align: start; font-size: 1rem; + margin: 0; &__fieldset { width: 100%; @@ -238,6 +303,12 @@ export default { &__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 455017b8683..bc4d25bf70f 100644 --- a/core/src/components/login/PasswordLessLoginForm.vue +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -1,68 +1,75 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <form v-if="(isHttps || isLocalhost) && 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"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - <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)"> - </p> - - <div v-if="!validCredentials" class="body-login-container update form__message-box"> - {{ t('core', 'Your account is not setup for passwordless login.') }} - </div> - - <LoginButton v-if="validCredentials" - :loading="loading" - @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" class="body-login-container update"> - <InformationIcon size="70" /> - <h2>{{ t('core', 'Browser not supported') }}</h2> - <p class="infogroup"> - {{ t('core', 'Passwordless authentication is not supported in your browser.') }} - </p> - </div> - <div v-else-if="!isHttps && !isLocalhost" class="body-login-container update"> - <LockOpenIcon size="70" /> - <h2>{{ t('core', 'Your connection is not secure') }}</h2> - <p class="infogroup"> - {{ t('core', 'Passwordless authentication is only available over a secure connection.') }} - </p> - </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' -import InformationIcon from 'vue-material-design-icons/Information' -import LockOpenIcon from 'vue-material-design-icons/LockOpen' +} 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: { @@ -85,11 +92,14 @@ export default { type: Boolean, default: false, }, - hasPublicKeyCredential: { - type: Boolean, - default: false, - }, }, + + setup() { + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + data() { return { user: this.username, @@ -98,111 +108,33 @@ 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 redirectUrl = this.redirectUrl - return finishAuthentication(JSON.stringify(challenge)) + return finishAuthentication(challenge) .then(({ defaultRedirectUrl }) => { console.debug('Logged in redirecting') // Redirect url might be false so || should be used instead of ??. @@ -217,16 +149,14 @@ export default { // noop }, }, -} +}) </script> <style lang="scss" scoped> - .body-login-container.update { - margin: 15px 0; - - &.form__message-box { - width: 240px; - margin: 5px; - } - } +.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 ad86281b301..fee1deacc36 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -1,75 +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 class="login-form" @submit.prevent="submit"> - <fieldset class="login-form__fieldset"> - <NcTextField id="user" - :value.sync="user" - name="user" - autocapitalize="off" - :label="t('core', 'Account name or email')" - :label-visible="true" - required - @change="updateUsername" /> - <LoginButton :value="t('core', 'Reset password')" /> - - <NcNoteCard v-if="message === 'send-success'" - type="success"> - {{ t('core', 'A password reset message has been sent to the email 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.') }} - </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> - - <a class="login-form__link" - 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> -import axios from '@nextcloud/axios' +<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 AuthMixin from '../../mixins/auth.js' import LoginButton from './LoginButton.vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import logger from '../../logger.js' -export default { +export default defineComponent({ name: 'ResetPassword', components: { LoginButton, + NcButton, NcNoteCard, NcTextField, }, + + mixins: [AuthMixin], + props: { username: { type: String, @@ -80,11 +73,12 @@ export default { required: true, }, }, + data() { return { error: false, loading: false, - message: undefined, + message: '', user: this.username, } }, @@ -97,57 +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}`) + } + + this.message = 'send-success' + } catch (error) { + logger.error('could not send reset email request', { error }) - 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 email request', e) - - this.error = true - this.message = 'send-error' - }) - .then(() => { this.loading = false }) + this.error = true + this.message = 'send-error' + } finally { + this.loading = false + } }, }, -} +}) </script> <style lang="scss" scoped> -.login-form { - text-align: left; - font-size: 1rem; - - &__fieldset { - width: 100%; - display: flex; - flex-direction: column; - gap: .5rem; - } - - &__link { - display: block; - font-weight: normal !important; - padding-bottom: 1rem; - cursor: pointer; - font-size: var(--default-font-size); - text-align: center; - padding: .5rem 1rem 1rem 1rem; - } +.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 36a63a6254a..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"> @@ -31,7 +14,7 @@ name="password" autocomplete="new-password" autocapitalize="none" - autocorrect="off" + spellcheck="false" required :placeholder="t('core', 'New password')"> </p> diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 6b81106ff72..f2120c28402 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -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 +--> <template> - <div class="guest-box"> + <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 …') }} @@ -28,20 +12,13 @@ <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-if="installingApps" class="text-center"> - {{ t('core', 'Installing apps …') }} - </p> <div v-for="app in recommendedApps" :key="app.id" class="app"> <template v-if="!isHidden(app.id)"> <img :src="customIcon(app.id)" alt=""> <div class="info"> - <h3> - {{ customName(app) }} - <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)" /> + <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> @@ -52,37 +29,42 @@ <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" - type="tertiary" - role="link" - href="defaultPageUrl" - @click="goTo(defaultPageUrl)"> + <NcButton v-if="showInstallButton && !installingApps" + data-cy-setup-recommended-apps-skip + :href="defaultPageUrl" + variant="tertiary"> {{ t('core', 'Skip') }} </NcButton> <NcButton v-if="showInstallButton" - type="primary" + data-cy-setup-recommended-apps-install + :disabled="installingApps || !isAnyAppSelected" + variant="primary" @click.stop.prevent="installApps"> - {{ t('core', 'Install recommended apps') }} + {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }} </NcButton> </div> </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 NcButton from '@nextcloud/vue/dist/Components/NcButton' - -import logger from '../../logger' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' const recommended = { calendar: { @@ -98,7 +80,7 @@ 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: { @@ -106,16 +88,20 @@ const recommended = { 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: { hidden: true, }, } const recommendedIds = Object.keys(recommended) -const defaultPageUrl = loadState('core', 'defaultPageUrl') export default { name: 'RecommendedApps', components: { + NcCheckboxRadioSwitch, NcButton, }, data() { @@ -125,20 +111,23 @@ export default { 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) + }, }, 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 })) + 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 @@ -152,23 +141,24 @@ export default { }, methods: { installApps() { - this.showInstallButton = false 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`) @@ -176,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 })) }, @@ -206,8 +196,13 @@ export default { } return !!recommended[appId].hidden }, - goTo(href) { - window.location.href = href + 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) }, }, } @@ -251,16 +246,17 @@ p { .info { h3, p { - text-align: left; + text-align: start; } h3 { 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 2c71fbe46e1..7c69a65161b 100644 --- a/core/src/files/client.js +++ b/core/src/files/client.js @@ -1,33 +1,7 @@ /** - * Copyright (c) 2015 - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Tomasz Grobelny <tomasz@grobelny.net> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ @@ -758,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"' } @@ -769,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)) { @@ -790,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 3fe90f82ac9..7ebe06a8349 100644 --- a/core/src/files/fileinfo.js +++ b/core/src/files/fileinfo.js @@ -1,26 +1,7 @@ /** - * Copyright (c) 2015 - * - * @author Julius Härtl <jus@bitgrid.net> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ @@ -166,7 +147,7 @@ for (const i in this.shareAttributes) { const attr = this.shareAttributes[i] if (attr.scope === 'permissions' && attr.key === 'download') { - return attr.enabled + return attr.value === true } } diff --git a/core/src/globals.js b/core/src/globals.js index 98bb6f64db8..4b07cc17c3e 100644 --- a/core/src/globals.js +++ b/core/src/globals.js @@ -1,43 +1,20 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable @nextcloud/no-deprecations */ -import { initCore } from './init' +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 './Polyfill/tooltip' import ClipboardJS from 'clipboard' import { dav } from 'davclient.js' import Handlebars from 'handlebars' @@ -45,18 +22,18 @@ 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) } } @@ -100,11 +77,11 @@ const setDeprecatedProp = (global, cb, msg) => { window._ = _ setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be removed in a later versions without another warning. Please ship your own.') -setDeprecatedProp('autosize', () => autosize, 'please ship your own, this will be removed in Nextcloud 20') 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') +// 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') diff --git a/core/src/icons.js b/core/src/icons.js index 3cd685dce8c..5845b01fea1 100644 --- a/core/src/icons.js +++ b/core/src/icons.js @@ -1,5 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ /* eslint-disable quote-props */ -/* eslint-disable node/no-unpublished-import */ +/* eslint-disable n/no-unpublished-import */ import path from 'path' import fs from 'fs' import sass from 'sass' @@ -7,7 +11,8 @@ import sass from 'sass' const colors = { dark: '000', white: 'fff', - yellow: 'FC0', + // gold but for backwards compatibility called yellow + yellow: 'a08b00', red: 'e9322d', orange: 'eca700', green: '46ba61', @@ -94,9 +99,11 @@ const icons = { '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'), @@ -122,6 +129,10 @@ const icons = { } 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', @@ -164,6 +175,14 @@ const iconsColor = { // 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 @@ -207,7 +226,6 @@ const iconsAliases = { 'icon-category-security': 'icon-password-dark', 'icon-category-search': 'icon-search-dark', 'icon-category-tools': 'icon-settings-dark', - 'icon-filetype-text': 'icon-file-grey', 'nav-icon-systemtagsfilter': 'icon-tag-dark', } @@ -219,7 +237,7 @@ const colorSvg = function(svg = '', color = '000') { } // add fill (fill is not present on black elements) - const fillRe = /<((circle|rect|path)((?!fill)[a-z0-9 =".\-#():;,])+)\/>/gmi + const fillRe = /<((circle|rect|path)((?!fill=)[a-z0-9 =".\-#():;,])+)\/>/gmi svg = svg.replace(fillRe, '<$1 fill="#' + color + '"/>') // replace any fill or stroke colors diff --git a/core/src/init.js b/core/src/init.js index 867ba94483f..1bcd8218702 100644 --- a/core/src/init.js +++ b/core/src/init.js @@ -1,27 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author nacho <nacho@ownyourbits.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* globals Snap */ @@ -29,12 +8,13 @@ import _ from 'underscore' import $ from 'jquery' import moment from 'moment' -import { initSessionHeartBeat } from './session-heartbeat.js' 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 PasswordConfirmation from './OC/password-confirmation.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 @@ -78,6 +58,9 @@ moment.locale(locale) * Initializes core */ export const initCore = () => { + interceptRequests() + initFallbackClipboardAPI() + $(window).on('unload.main', () => { OC._unloadCalled = true }) $(window).on('beforeunload.main', () => { // super-trick thanks to http://stackoverflow.com/a/4651049 @@ -160,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 @@ -213,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 @@ -264,6 +254,7 @@ export const initCore = () => { const toggleSnapperOnSize = () => { if ($(window).width() > breakpointMobileWidth) { + $appNavigation.attr('aria-hidden', 'false') snapper.close() snapper.disable() @@ -287,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 9892e45f8b6..00000000000 --- a/core/src/install.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -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 './Polyfill/tooltip' - -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 - $('input[type="submit"]').attr('disabled', 'disabled').val($('input[type="submit"]').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('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() - $('.toggle-password').click(function(event) { - event.preventDefault() - const currentValue = $(this).parent().children('input').attr('type') - if (currentValue === 'password') { - $(this).parent().children('input').attr('type', 'text') - } else { - $(this).parent().children('input').attr('type', 'password') - } - }) -}) 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 12fcc7264ae..3851a26ce31 100644 --- a/core/src/jquery/avatar.js +++ b/core/src/jquery/avatar.js @@ -1,30 +1,12 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 @@ -108,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, @@ -117,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 2cf2daec570..fba014c364e 100644 --- a/core/src/jquery/contactsmenu.js +++ b/core/src/jquery/contactsmenu.js @@ -1,30 +1,11 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 = '' @@ -70,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 cb52ecdb2d7..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,9 @@ } .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 diff --git a/core/src/jquery/css/jquery.ocdialog.scss b/core/src/jquery/css/jquery.ocdialog.scss index 8755e1d7a74..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: 24px; - z-index: 10000; + 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,8 +56,10 @@ .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-dark) no-repeat center; opacity: .5; @@ -67,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 0f545cc8107..8a8efdb5a63 100644 --- a/core/src/jquery/exists.js +++ b/core/src/jquery/exists.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' diff --git a/core/src/jquery/filterattr.js b/core/src/jquery/filterattr.js index 44aad5b8ea2..f577e55e4e0 100644 --- a/core/src/jquery/filterattr.js +++ b/core/src/jquery/filterattr.js @@ -1,24 +1,6 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' diff --git a/core/src/jquery/index.js b/core/src/jquery/index.js index 305b038ce37..f285ba19449 100644 --- a/core/src/jquery/index.js +++ b/core/src/jquery/index.js @@ -1,40 +1,21 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 c8ea065d3c0..a5f588ec659 100644 --- a/core/src/jquery/ocdialog.js +++ b/core/src/jquery/ocdialog.js @@ -1,30 +1,11 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' -import { isA11yActivation } from '../Util/a11y' +import { createFocusTrap } from 'focus-trap' +import { isA11yActivation } from '../Util/a11y.js' $.widget('oc.ocdialog', { options: { @@ -52,6 +33,7 @@ $.widget('oc.ocdialog', { // Setting tabIndex makes the div focusable tabIndex: -1, role: 'dialog', + 'aria-modal': true, }) .insertBefore(this.element) this.$dialog.append(this.element.detach()) @@ -114,9 +96,9 @@ $.widget('oc.ocdialog', { this._setOptions(this.options) this._createOverlay() + this._useFocusTrap() }, _init() { - this.$dialog.focus() this._trigger('open') }, _setOption(key, value) { @@ -177,7 +159,8 @@ $.widget('oc.ocdialog', { break case 'closeButton': if (value) { - const $closeButton = $('<a class="oc-dialog-close" tabindex="0"></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 keydown', function(event) { if (isA11yActivation(event)) { @@ -231,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() @@ -251,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 }, @@ -261,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 7bf9f8dcb19..cecbe880aa6 100644 --- a/core/src/jquery/octemplate.js +++ b/core/src/jquery/octemplate.js @@ -1,25 +1,6 @@ /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' @@ -108,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 03647059131..e57951af5e4 100644 --- a/core/src/jquery/placeholder.js +++ b/core/src/jquery/placeholder.js @@ -1,27 +1,7 @@ /** - * @copyright 2016-2018 John Molakvoæ <skjnldsv@protonmail.com> - * @copyright 2013 Morris Jobke <morris.jobke@gmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Sergey Shliakhov <husband.sergey@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2013-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ @@ -140,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 07524966994..1e9e06515a6 100644 --- a/core/src/jquery/requesttoken.js +++ b/core/src/jquery/requesttoken.js @@ -1,33 +1,15 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 713fd1edf8d..a4d8f49ce43 100644 --- a/core/src/jquery/selectrange.js +++ b/core/src/jquery/selectrange.js @@ -1,24 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' diff --git a/core/src/jquery/showpassword.js b/core/src/jquery/showpassword.js index a00a57cc867..8d938d7853b 100644 --- a/core/src/jquery/showpassword.js +++ b/core/src/jquery/showpassword.js @@ -1,33 +1,16 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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.0 - * @requires Jquery 1.5 + * @requires jQuery 1.5 * * @author Jan Jarfalk <jan.jarfalk@unwrongest.com> * author-website http://www.unwrongest.com diff --git a/core/src/jquery/ui-fixes.js b/core/src/jquery/ui-fixes.js index ab4235d9b53..e23464b2f9d 100644 --- a/core/src/jquery/ui-fixes.js +++ b/core/src/jquery/ui-fixes.js @@ -1,24 +1,6 @@ /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import $ from 'jquery' 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 593cc071850..78d51a798e4 100644 --- a/core/src/logger.js +++ b/core/src/logger.js @@ -1,23 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCurrentUser } from '@nextcloud/auth' @@ -36,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 6757c5e7bdd..29affcda762 100644 --- a/core/src/login.js +++ b/core/src/login.js @@ -1,34 +1,14 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ 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' +import Nextcloud from './mixins/Nextcloud.js' Vue.mixin(Nextcloud) diff --git a/core/src/main.js b/core/src/main.js index ec23171b6ea..2d88f15562b 100644 --- a/core/src/main.js +++ b/core/src/main.js @@ -1,32 +1,10 @@ /** - * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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.js' +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 @@ -36,6 +14,12 @@ 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() @@ -45,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 3c13a45894c..e66b14a88f5 100644 --- a/core/src/maintenance.js +++ b/core/src/maintenance.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 7fda65d3d9d..3a94f85d2c6 100644 --- a/core/src/mixins/Nextcloud.js +++ b/core/src/mixins/Nextcloud.js @@ -1,27 +1,10 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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/profile.js b/core/src/profile.js deleted file mode 100644 index 79465c6a28d..00000000000 --- a/core/src/profile.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @copyright 2021, Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import Vue from 'vue' -import { getRequestToken } from '@nextcloud/auth' -import { translate as t } from '@nextcloud/l10n' -import VTooltip from 'v-tooltip' - -import logger from './logger.js' - -import Profile from './views/Profile.vue' -import ProfileSections from './profile/ProfileSections.js' - -__webpack_nonce__ = btoa(getRequestToken()) - -if (!window.OCA) { - window.OCA = {} -} - -if (!window.OCA.Core) { - window.OCA.Core = {} -} -Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() }) - -Vue.use(VTooltip) - -Vue.mixin({ - props: { - logger, - }, - methods: { - t, - }, -}) - -const View = Vue.extend(Profile) - -window.addEventListener('DOMContentLoaded', () => { - new View().$mount('#vue-profile') -}) diff --git a/core/src/profile/ProfileSections.js b/core/src/profile/ProfileSections.js deleted file mode 100644 index 4091c8332d6..00000000000 --- a/core/src/profile/ProfileSections.js +++ /dev/null @@ -1,43 +0,0 @@ - -/** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -export default class ProfileSections { - - _sections - - constructor() { - this._sections = [] - } - - /** - * @param {registerSectionCallback} section To be called to mount the section to the profile page - */ - registerSection(section) { - this._sections.push(section) - } - - getSections() { - return this._sections - } - -} 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 89e8b930827..13f16436ed3 100644 --- a/core/src/recommendedapps.js +++ b/core/src/recommendedapps.js @@ -1,34 +1,17 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getRequestToken } from '@nextcloud/auth' +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()) +__webpack_nonce__ = getCSPNonce() Vue.mixin({ methods: { diff --git a/core/src/services/BrowserStorageService.js b/core/src/services/BrowserStorageService.js index 3ecf52f423d..b7d34bf1716 100644 --- a/core/src/services/BrowserStorageService.js +++ b/core/src/services/BrowserStorageService.js @@ -1,23 +1,6 @@ /** - * @copyright 2021 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getBuilder } from '@nextcloud/browser-storage' diff --git a/core/src/services/BrowsersListService.js b/core/src/services/BrowsersListService.js index 5027489e8e9..77f217a86ac 100644 --- a/core/src/services/BrowsersListService.js +++ b/core/src/services/BrowsersListService.js @@ -1,30 +1,13 @@ /** - * @copyright 2021 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getUserAgentRegExp } from 'browserslist-useragent-regexp' -// eslint-disable-next-line node/no-extraneous-import +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 = getUserAgentRegExp({ allowHigherVersions: true, browsers: browserslistConfig }) +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 3c673479771..7067c994c90 100644 --- a/core/src/services/UnifiedSearchService.js +++ b/core/src/services/UnifiedSearchService.js @@ -1,38 +1,11 @@ /** - * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { 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 = 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 +import { getCurrentUser } from '@nextcloud/auth' /** * Create a cancel token @@ -46,7 +19,7 @@ const createCancelToken = () => axios.CancelToken.source() * * @return {Promise<Array>} */ -export async function getTypes() { +export async function getProviders() { try { const { data } = await axios.get(generateOcsUrl('search/providers'), { params: { @@ -71,9 +44,14 @@ export async function getTypes() { * @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 + * @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 */ @@ -84,8 +62,13 @@ export function search({ type, query, cursor }) { 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, }, }) @@ -94,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 3eabceef5e4..00000000000 --- a/core/src/services/WebAuthnAuthenticationService.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import Axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' - -/** - * @param {any} loginName - - */ -export function startAuthentication(loginName) { - const url = generateUrl('/login/webauthn/start') - - return Axios.post(url, { loginName }) - .then(resp => resp.data) -} - -/** - * @param {any} 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 4a15d7d2de9..00000000000 --- a/core/src/session-heartbeat.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -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) - * - * @return {boolean} - */ -const keepSessionAlive = () => { - return config.session_keepalive === undefined - || !!config.session_keepalive -} - -/** - * get interval in seconds - * - * @return {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 87b0a7da378..e4ccb1d3802 100644 --- a/core/src/systemtags/merged-systemtags.js +++ b/core/src/systemtags/merged-systemtags.js @@ -1,23 +1,7 @@ /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ import './systemtags.js' diff --git a/core/src/systemtags/systemtagmodel.js b/core/src/systemtags/systemtagmodel.js index 72f2d6f0915..1d2cd3ae57d 100644 --- a/core/src/systemtags/systemtagmodel.js +++ b/core/src/systemtags/systemtagmodel.js @@ -1,75 +1,58 @@ /** - * Copyright (c) 2015 - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 90b415d1557..ceb4652fe1c 100644 --- a/core/src/systemtags/systemtags.js +++ b/core/src/systemtags/systemtags.js @@ -1,27 +1,7 @@ /** - * Copyright (c) 2016 - * - * @author Gary Kim <gary@garykim.dev> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ diff --git a/core/src/systemtags/systemtagscollection.js b/core/src/systemtags/systemtagscollection.js index b123ef30fe4..960d26ed36e 100644 --- a/core/src/systemtags/systemtagscollection.js +++ b/core/src/systemtags/systemtagscollection.js @@ -1,24 +1,7 @@ /** - * Copyright (c) 2015 - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ diff --git a/core/src/systemtags/systemtagsinputfield.js b/core/src/systemtags/systemtagsinputfield.js index 5f298577386..b31d24dd0b5 100644 --- a/core/src/systemtags/systemtagsinputfield.js +++ b/core/src/systemtags/systemtagsinputfield.js @@ -1,26 +1,7 @@ /** - * Copyright (c) 2015 - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ @@ -177,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 diff --git a/core/src/systemtags/systemtagsmappingcollection.js b/core/src/systemtags/systemtagsmappingcollection.js index f7e9e9cfe40..78c23ff67f0 100644 --- a/core/src/systemtags/systemtagsmappingcollection.js +++ b/core/src/systemtags/systemtagsmappingcollection.js @@ -1,25 +1,7 @@ /** - * Copyright (c) 2015 - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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' diff --git a/core/src/tests/.eslintrc.js b/core/src/tests/.eslintrc.js index b44ea2c697d..598fc5c28b4 100644 --- a/core/src/tests/.eslintrc.js +++ b/core/src/tests/.eslintrc.js @@ -1,25 +1,7 @@ /** - * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - module.exports = { globals: { jsdom: true, diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js deleted file mode 100644 index 741dc65746b..00000000000 --- a/core/src/tests/OC/requesttoken.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author François Freitag <mail@franek.fr> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { subscribe, unsubscribe } from '@nextcloud/event-bus' - -import { manageToken, setToken } from '../../OC/requesttoken' - -describe('request token', () => { - - let emit - let manager - const token = 'abc123' - - beforeEach(() => { - emit = jest.fn() - const head = window.document.getElementsByTagName('head')[0] - head.setAttribute('data-requesttoken', token) - - manager = manageToken(window.document, emit) - }) - - test('reads the token from the document', () => { - expect(manager.getToken()).toBe('abc123') - }) - - test('remembers the updated token', () => { - manager.setToken('bca321') - - expect(manager.getToken()).toBe('bca321') - }) - - describe('@nextcloud/auth integration', () => { - let listener - - beforeEach(() => { - listener = jest.fn() - - subscribe('csrf-token-update', listener) - }) - - afterEach(() => { - unsubscribe('csrf-token-update', listener) - }) - - test('fires off an event for @nextcloud/auth', () => { - setToken('123') - - expect(listener).toHaveBeenCalledWith({ 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/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 cc390c0d6e7..00000000000 --- a/core/src/unified-search.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -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()) - -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 index 5ea64221a04..64620afa085 100644 --- a/core/src/unsupported-browser-redirect.js +++ b/core/src/unsupported-browser-redirect.js @@ -1,26 +1,16 @@ /** - * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { testSupportedBrowser } from './utils/RedirectUnsupportedBrowsers.js' +import { getCSPNonce } from '@nextcloud/auth' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() if (!window.TESTING && !OC?.config?.no_unsupported_browser_warning) { - testSupportedBrowser() + 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 index a9b44b666fc..d54b1c8fb24 100644 --- a/core/src/unsupported-browser.js +++ b/core/src/unsupported-browser.js @@ -1,22 +1,6 @@ /** - * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateUrl } from '@nextcloud/router' 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 index 16076a9afd3..2880d051ca2 100644 --- a/core/src/utils/RedirectUnsupportedBrowsers.js +++ b/core/src/utils/RedirectUnsupportedBrowsers.js @@ -1,22 +1,6 @@ /** - * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateUrl } from '@nextcloud/router' 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 index f03652bb477..924ddcea56b 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -1,198 +1,233 @@ <!-- - - @copyright 2023 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <NcHeaderMenu id="contactsmenu" + class="contactsmenu" :aria-label="t('core', 'Search contacts')" @open="handleOpen"> <template #trigger> - <Contacts :size="20" /> + <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" /> </template> - <div id="contactsmenu-menu" /> + <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 NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' - -import Contacts from 'vue-material-design-icons/Contacts.vue' - -import OC from '../OC/index.js' +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: { - Contacts, + Contact, + NcButton, + NcEmptyContent, NcHeaderMenu, + NcIconSvgWrapper, + NcLoadingIcon, + NcTextField, }, - data() { + mixins: [Nextcloud], + + setup() { return { - contactsMenu: null, + mdiContacts, + mdiMagnify, } }, - mounted() { - // eslint-disable-next-line no-new - this.contactsMenu = new OC.ContactsMenu({ - el: '#contactsmenu-menu', - }) + 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: { - handleOpen() { - this.contactsMenu?.loadContacts() + 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-menu { - /* show 2.5 to 4.5 entries depending on the screen height */ - height: calc(100vh - 50px * 3); - max-height: calc(50px * 6 + 2px); - min-height: calc(50px * 3.5); - width: 350px; - - &:deep { - .emptycontent { - margin-top: 5vh !important; - margin-bottom: 1.5vh; - .icon-loading, - .icon-search { - display: inline-block; - } +.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; } - #contactsmenu-search { - width: calc(100% - 16px); - margin: 8px; + &__input-wrapper { + padding: 10px; + z-index: 2; + top: 0; + } + + &__search { + width: 100%; height: 34px; + margin-top: 0!important; } - .content { - /* fixed max height of the parent container without the search input */ - height: calc(100vh - 50px * 3 - 50px); - max-height: calc(50px * 5); - min-height: calc(50px * 3.5 - 50px); + &__content { overflow-y: auto; + margin-top: 10px; + flex: 1 1 auto; - .footer { - text-align: center; - - a { - display: block; - width: 100%; - padding: 12px 0; - opacity: .5; - } + &__footer { + display: flex; + flex-direction: column; + align-items: center; } } a { - padding: 2px; - &: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 } } + } - .contact { - display: flex; - position: relative; - align-items: center; - padding: 3px 3px 3px 10px; - - .avatar { - height: 32px; - width: 32px; - display: inline-block; - } - - .body { - flex-grow: 1; - padding-left: 8px; - - div { - position: relative; - width: 100%; - } - - .full-name, .last-message { - /* TODO: don't use fixed width */ - max-width: 204px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - .last-message, .email-address { - color: var(--color-text-maxcontrast); - } - } - - .top-action, .second-action, .other-actions { - width: 16px; - height: 16px; - opacity: .5; - cursor: pointer; - - &:not(button) { - padding: 14px; - } - img { - filter: var(--background-invert-if-dark); - } - - &:hover, - &:active, - &:focus { - opacity: 1; - } - } - - 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-right: 13px; - } - .popovermenu::after { - right: 2px; - } - } + :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 4afa81d0dfa..a6fe8442779 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -1,29 +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 +--> <template> <div class="guest-box login-box"> - <div v-if="!hideLoginForm || directLogin"> + <template v-if="!hideLoginForm || directLogin"> <transition name="fade" mode="out-in"> - <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"> + <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper"> <LoginForm :username.sync="user" :redirect-url="redirectUrl" :direct-login="directLogin" @@ -31,54 +15,47 @@ :errors="errors" :throttle-delay="throttleDelay" :auto-complete-allowed="autoCompleteAllowed" + :email-states="emailStates" @submit="loading = true" /> - <a v-if="canResetPassword && resetPasswordLink !== ''" + <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" - class="login-box__link" - :href="resetPasswordLink"> + :href="resetPasswordLink" + type="tertiary-no-background" + wide> {{ t('core', 'Forgot password?') }} - </a> - <a v-else-if="canResetPassword && !resetPassword" + </NcButton> + <NcButton v-else-if="canResetPassword && !resetPassword" id="lost-password" - class="login-box__link" - :href="resetPasswordLink" + type="tertiary" + wide @click.prevent="resetPassword = true"> {{ t('core', 'Forgot password?') }} - </a> - <template v-if="hasPasswordless"> - <div v-if="countAlternativeLogins" - class="alternative-logins"> - <a v-if="hasPasswordless" - class="button" - :class="{ 'single-alt-login-option': countAlternativeLogins }" - href="#" - @click.prevent="passwordlessLogin = true"> - {{ t('core', 'Log in with a device') }} - </a> - </div> - <a v-else - href="#" - @click.prevent="passwordlessLogin = true"> - {{ t('core', 'Log in with a device') }} - </a> - </template> + </NcButton> </div> <div v-else-if="!loading && passwordlessLogin" - key="reset" - class="login-additional"> + 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" - :has-public-key-credential="hasPublicKeyCredential" @submit="loading = true" /> - <a href="#" class="login-box__link" @click.prevent="passwordlessLogin = false"> + <NcButton type="tertiary" + :aria-label="t('core', 'Back to login form')" + :wide="true" + @click="passwordlessLogin = false"> {{ t('core', 'Back') }} - </a> + </NcButton> </div> <div v-else-if="!loading && canResetPassword" - key="reset" + key="reset-can-reset" class="login-additional"> <div class="lost-password-container"> <ResetPassword v-if="resetPassword" @@ -93,16 +70,16 @@ @done="passwordResetFinished" /> </div> </transition> - </div> - <div v-else> + </template> + <template v-else> <transition name="fade" mode="out-in"> - <NcNoteCard type="warning" :title="t('core', 'Login form is disabled.')"> - {{ t('core', 'Please contact your administrator.') }} + <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> - </div> + </template> - <div id="alternative-logins" class="alternative-logins"> + <div id="alternative-logins" class="login-box__alternative-logins"> <NcButton v-for="(alternativeLogin, index) in alternativeLogins" :key="index" type="secondary" @@ -118,24 +95,21 @@ <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/dist/Components/NcButton.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +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') { - try { - window.localStorage.clear() - window.sessionStorage.clear() - console.debug('Browser storage cleared') - } catch (e) { - console.error('Could not clear browser storage', e) - } + wipeBrowserStorages() } export default { @@ -173,50 +147,43 @@ export default { alternativeLogins: loadState('core', 'alternativeLogins', []), isHttps: window.location.protocol === 'https:', isLocalhost: window.location.hostname === 'localhost', - hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined', hideLoginForm: loadState('core', 'hideLoginForm', false), + emailStates: loadState('core', 'emailStates', []), } }, methods: { passwordResetFinished() { - this.resetPasswordTarget = '' - this.directLogin = true + window.location.href = generateUrl('login') }, }, } </script> -<style lang="scss"> -body { - font-size: var(--default-font-size); -} - +<style scoped lang="scss"> .login-box { - width: 300px; - - &__link { - display: block; - padding: 1rem; - font-size: var(--default-font-size); - text-align: center; - font-weight: normal !important; + // Same size as dashboard panels + width: 320px; + box-sizing: border-box; + + &__wrapper { + display: flex; + flex-direction: column; + gap: calc(2 * var(--default-grid-baseline)); + } + + &__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; } - -.alternative-logins { - display: flex; - flex-direction: column; - gap: 0.75rem; - - .button-vue { - box-sizing: border-box; - } -} </style> diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue deleted file mode 100644 index c7571fff148..00000000000 --- a/core/src/views/Profile.vue +++ /dev/null @@ -1,604 +0,0 @@ -<!-- - - @copyright Copyright (c) 2021 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - @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/>. - - - --> - -<template> - <div class="profile"> - <div class="profile__header"> - <div class="profile__header__container"> - <div class="profile__header__container__placeholder" /> - <h2 class="profile__header__container__displayname"> - {{ displayname || userId }} - <a v-if="isCurrentUser" - class="primary profile__header__container__edit-button" - :href="settingsUrl"> - <PencilIcon class="pencil-icon" - :size="16" /> - {{ t('core', 'Edit Profile') }} - </a> - </h2> - <div v-if="status.icon || status.message" - class="profile__header__container__status-text" - :class="{ interactive: isCurrentUser }" - @click.prevent.stop="openStatusModal"> - {{ status.icon }} {{ status.message }} - </div> - </div> - </div> - - <div class="profile__wrapper"> - <div class="profile__content"> - <div class="profile__sidebar"> - <NcAvatar class="avatar" - :class="{ interactive: isCurrentUser }" - :user="userId" - :size="180" - :show-user-status="true" - :show-user-status-compact="false" - :disable-menu="true" - :disable-tooltip="true" - :is-no-user="!isUserAvatarVisible" - @click.native.prevent.stop="openStatusModal" /> - - <div class="user-actions"> - <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action --> - <PrimaryActionButton v-if="primaryAction" - class="user-actions__primary" - :href="primaryAction.target" - :icon="primaryAction.icon" - :target="primaryAction.id === 'phone' ? '_self' :'_blank'"> - {{ primaryAction.title }} - </PrimaryActionButton> - <div class="user-actions__other"> - <!-- FIXME Remove inline styles after https://github.com/nextcloud/nextcloud-vue/issues/2315 is fixed --> - <NcActions v-for="action in middleActions" - :key="action.id" - :default-icon="action.icon" - style=" - background-position: 14px center; - background-size: 16px; - background-repeat: no-repeat;" - :style="{ - backgroundImage: `url(${action.icon})`, - ...(colorMainBackground === '#181818' && { filter: 'invert(1)' }) - }"> - <NcActionLink :close-after-click="true" - :icon="action.icon" - :href="action.target" - :target="action.id === 'phone' ? '_self' :'_blank'"> - {{ action.title }} - </NcActionLink> - </NcActions> - <template v-if="otherActions"> - <NcActions :force-menu="true"> - <NcActionLink v-for="action in otherActions" - :key="action.id" - :class="{ 'icon-invert': colorMainBackground === '#181818' }" - :close-after-click="true" - :icon="action.icon" - :href="action.target" - :target="action.id === 'phone' ? '_self' :'_blank'"> - {{ action.title }} - </NcActionLink> - </NcActions> - </template> - </div> - </div> - </div> - - <div class="profile__blocks"> - <div v-if="organisation || role || address" class="profile__blocks-details"> - <div v-if="organisation || role" class="detail"> - <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p> - </div> - <div v-if="address" class="detail"> - <p> - <MapMarkerIcon class="map-icon" - :size="16" /> - {{ address }} - </p> - </div> - </div> - <template v-if="headline || biography || sections.length > 0"> - <div v-if="headline" class="profile__blocks-headline"> - <h3>{{ headline }}</h3> - </div> - <div v-if="biography" class="profile__blocks-biography"> - <p>{{ biography }}</p> - </div> - - <!-- additional entries, use it with cautious --> - <div v-for="(section, index) in sections" - :ref="'section-' + index" - :key="index" - class="profile__additionalContent"> - <component :is="section($refs['section-'+index], userId)" :userId="userId" /> - </div> - </template> - <template v-else> - <div class="profile__blocks-empty-info"> - <AccountIcon :size="60" - fill-color="var(--color-text-maxcontrast)" /> - <h3>{{ emptyProfileMessage }}</h3> - <p>{{ t('core', 'The headline and about sections will show up here') }}</p> - </div> - </template> - </div> - </div> - </div> - </div> -</template> - -<script> -import { getCurrentUser } from '@nextcloud/auth' -import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' -import { generateUrl } from '@nextcloud/router' -import { showError } from '@nextcloud/dialogs' - -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' -import NcActions from '@nextcloud/vue/dist/Components/NcActions' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' -import MapMarkerIcon from 'vue-material-design-icons/MapMarker' -import PencilIcon from 'vue-material-design-icons/Pencil' -import AccountIcon from 'vue-material-design-icons/Account' - -import PrimaryActionButton from '../components/Profile/PrimaryActionButton' - -const status = loadState('core', 'status', {}) -const { - userId, - displayname, - address, - organisation, - role, - headline, - biography, - actions, - isUserAvatarVisible, -} = loadState('core', 'profileParameters', { - userId: null, - displayname: null, - address: null, - organisation: null, - role: null, - headline: null, - biography: null, - actions: [], - isUserAvatarVisible: false, -}) - -export default { - name: 'Profile', - - components: { - AccountIcon, - NcActionLink, - NcActions, - NcAvatar, - MapMarkerIcon, - PencilIcon, - PrimaryActionButton, - }, - - data() { - return { - status, - userId, - displayname, - address, - organisation, - role, - headline, - biography, - actions, - isUserAvatarVisible, - sections: OCA.Core.ProfileSections.getSections(), - } - }, - - computed: { - isCurrentUser() { - return getCurrentUser()?.uid === this.userId - }, - - allActions() { - return this.actions - }, - - primaryAction() { - if (this.allActions.length) { - return this.allActions[0] - } - return null - }, - - middleActions() { - if (this.allActions.slice(1, 4).length) { - return this.allActions.slice(1, 4) - } - return null - }, - - otherActions() { - if (this.allActions.slice(4).length) { - return this.allActions.slice(4) - } - return null - }, - - settingsUrl() { - return generateUrl('/settings/user') - }, - - colorMainBackground() { - // For some reason the returned string has prepended whitespace - return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim() - }, - - emptyProfileMessage() { - return this.isCurrentUser - ? t('core', 'You have not added any info yet') - : t('core', '{user} has not added any info yet', { user: (this.displayname || this.userId) }) - }, - }, - - mounted() { - // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end - document.title = `${this.displayname || this.userId} - ${document.title}` - subscribe('user_status:status.updated', this.handleStatusUpdate) - }, - - beforeDestroy() { - unsubscribe('user_status:status.updated', this.handleStatusUpdate) - }, - - methods: { - handleStatusUpdate(status) { - if (this.isCurrentUser && status.userId === this.userId) { - this.status = status - } - }, - - openStatusModal() { - const statusMenuItem = document.querySelector('.user-status-menu-item__toggle') - // Changing the user status is only enabled if you are the current user - if (this.isCurrentUser) { - if (statusMenuItem) { - statusMenuItem.click() - } else { - showError(t('core', 'Error opening the user status modal, try hard refreshing the page')) - } - } - }, - }, -} -</script> - -<style lang="scss"> -// Override header styles -#header { - background-color: transparent !important; - background-image: none !important; -} - -#content { - padding-top: 0px; -} -</style> - -<style lang="scss" scoped> -$profile-max-width: 1024px; -$content-max-width: 640px; - -.profile { - width: 100%; - overflow-y: auto; - - &__header { - position: sticky; - height: 190px; - top: -40px; - background-color: var(--color-main-background-blur); - backdrop-filter: var(--filter-background-blur); - -webkit-backdrop-filter: var(--filter-background-blur); - - &__container { - align-self: flex-end; - width: 100%; - max-width: $profile-max-width; - margin: 0 auto; - display: grid; - grid-template-rows: max-content max-content; - grid-template-columns: 240px 1fr; - justify-content: center; - - &__placeholder { - grid-row: 1 / 3; - } - - &__displayname, &__status-text { - color: var(--color-main-text); - } - - &__displayname { - width: $content-max-width; - height: 45px; - margin-top: 128px; - // Override the global style declaration - margin-bottom: 0; - font-size: 30px; - display: flex; - align-items: center; - cursor: text; - - &:not(:last-child) { - margin-top: 100px; - margin-bottom: 4px; - } - } - - &__edit-button { - border: none; - margin-left: 18px; - margin-top: 2px; - color: var(--color-primary-element); - background-color: var(--color-primary-text); - box-shadow: 0 0 0 2px var(--color-primary-text); - border-radius: var(--border-radius-pill); - padding: 0 18px; - font-size: var(--default-font-size); - height: 44px; - line-height: 44px; - font-weight: bold; - - &:hover, - &:focus, - &:active { - color: var(--color-primary-element); - background-color: var(--color-primary-element-light); - } - - .pencil-icon { - display: inline-block; - vertical-align: middle; - margin-top: 2px; - } - } - - &__status-text { - width: max-content; - max-width: $content-max-width; - padding: 5px 10px; - margin-left: -12px; - margin-top: 2px; - - &.interactive { - cursor: pointer; - - &:hover, - &:focus, - &:active { - background-color: var(--color-main-background); - color: var(--color-main-text); - border-radius: var(--border-radius-pill); - font-weight: bold; - box-shadow: 0 3px 6px var(--color-box-shadow); - } - } - } - } - } - - &__sidebar { - position: sticky; - top: var(--header-height); - align-self: flex-start; - padding-top: 20px; - min-width: 220px; - margin: -150px 20px 0 0; - - // Specificity hack is needed to override Avatar component styles - &::v-deep .avatar.avatardiv, h2 { - text-align: center; - margin: auto; - display: block; - padding: 8px; - } - - &::v-deep .avatar.avatardiv:not(.avatardiv--unknown) { - background-color: var(--color-main-background) !important; - box-shadow: none; - } - - &::v-deep .avatar.avatardiv { - .avatardiv__user-status { - right: 14px; - bottom: 14px; - width: 34px; - height: 34px; - background-size: 28px; - border: none; - // Styles when custom status icon and status text are set - background-color: var(--color-main-background); - line-height: 34px; - font-size: 20px; - } - } - - &::v-deep .avatar.interactive.avatardiv { - .avatardiv__user-status { - cursor: pointer; - - &:hover, - &:focus, - &:active { - box-shadow: 0 3px 6px var(--color-box-shadow); - } - } - } - } - - &__wrapper { - background-color: var(--color-main-background); - min-height: 100%; - } - - &__content { - max-width: $profile-max-width; - margin: 0 auto; - display: flex; - width: 100%; - } - - &__blocks { - margin: 18px 0 80px 0; - display: grid; - gap: 16px 0; - width: $content-max-width; - - p, h3 { - overflow-wrap: anywhere; - } - - &-details { - display: flex; - flex-direction: column; - gap: 2px 0; - - .detail { - display: inline-block; - color: var(--color-text-maxcontrast); - - p .map-icon { - display: inline-block; - vertical-align: middle; - } - } - } - - &-headline { - margin-top: 10px; - - h3 { - font-weight: bold; - font-size: 20px; - margin: 0; - } - } - - &-biography { - white-space: pre-line; - } - - h3, p { - cursor: text; - } - - &-empty-info { - margin-top: 80px; - margin-right: 100px; - display: flex; - flex-direction: column; - text-align: center; - - h3 { - font-weight: bold; - font-size: 18px; - margin: 8px 0; - } - } - } -} - -@media only screen and (max-width: 1024px) { - .profile { - &__header { - height: 250px; - position: unset; - - &__container { - grid-template-columns: unset; - - &__displayname { - margin: 100px 20px 0px; - width: unset; - display: unset; - text-align: center; - } - - &__edit-button { - width: fit-content; - display: block; - margin: 30px auto; - } - } - } - - &__content { - display: block; - } - - &__blocks { - width: unset; - max-width: 600px; - margin: 0 auto; - padding: 20px 50px 50px 50px; - - &-empty-info { - margin: 0; - } - } - - &__sidebar { - margin: unset; - position: unset; - } - } -} - -.user-actions { - display: flex; - flex-direction: column; - gap: 8px 0; - margin-top: 20px; - - &__primary { - margin: 0 auto; - } - - &__other { - display: flex; - justify-content: center; - gap: 0 4px; - a { - filter: var(--background-invert-if-dark); - } - } -} - -.icon-invert { - &::v-deep .action-link__icon { - filter: invert(1); - } -} -</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 62b5d034038..103e47b0425 100644 --- a/core/src/views/UnifiedSearch.vue +++ b/core/src/views/UnifiedSearch.vue @@ -1,866 +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> - <NcHeaderMenu id="unified-search" - class="unified-search" - exclude-click-outside-classes="popover" - :open.sync="open" - :aria-label="ariaLabel" - @open="onOpen" - @close="onClose"> - <!-- Header icon --> - <template #trigger> - <Magnify class="unified-search__trigger" - :size="22/* fit better next to other 20px icons */" - fill-color="var(--color-primary-text)" /> - </template> - - <!-- Search form & filters wrapper --> - <div class="unified-search__input-wrapper"> - <label for="unified-search__input">{{ ariaLabel }}</label> - <div class="unified-search__input-row"> - <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" - id="unified-search__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(', ') })" - aria-describedby="unified-search-desc" - @input="onInputDebounced" - @keypress.enter.prevent.stop="onInputEnter"> - <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> - - <!-- Reset search button --> - <input v-if="!!query && !isLoading" - type="reset" - class="unified-search__form-reset icon-close" - :aria-label="t('core','Reset search')" - value=""> - - <input v-if="!!query && !isLoading && !enableLiveSearch" - type="submit" - class="unified-search__form-submit icon-confirm" - :aria-label="t('core','Start search')" - value=""> - </form> - - <!-- Search filters --> - <NcActions v-if="availableFilters.length > 1" - class="unified-search__filters" - placement="bottom" - container=".unified-search__input-wrapper"> - <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 --> - <NcActionButton v-for="type in availableFilters" - :key="type" - icon="icon-filter" - :title="t('core', 'Search for {name} only', { name: typesMap[type] })" - @click.stop="onClickFilter(`in:${type}`)"> - {{ `in:${type}` }} - </NcActionButton> - </NcActions> - </div> - </div> - - <template v-if="!hasResults"> - <!-- Loading placeholders --> - <SearchResultPlaceholders v-if="isLoading" /> - - <NcEmptyContent v-else-if="isValidQuery"> - <NcHighlight v-if="triggered" :text="t('core', 'No results for {query}', { query })" :search="query" /> - <div v-else> - {{ t('core', 'Press enter to start searching') }} - </div> - <template #icon> - <Magnify /> - </template> - </NcEmptyContent> - - <NcEmptyContent v-else-if="!isLoading || isShortQuery"> - {{ t('core', 'Start typing to search') }} - <template #icon> - <Magnify /> - </template> - <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> - </NcEmptyContent> - </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]"> - <h2 class="unified-search__results-header"> - {{ typesMap[type] }} - </h2> - - <!-- 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.stop="loadMore(type)" - @focus="setFocusedIndex" /> - </li> - </ul> - </template> - </NcHeaderMenu> + <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> +<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 { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { showError } from '@nextcloud/dialogs' - -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' -import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js' - -import Magnify from 'vue-material-design-icons/Magnify.vue' - -import SearchResult from '../components/UnifiedSearch/SearchResult.vue' -import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue' - -import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js' - -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: { - Magnify, - NcActionButton, - NcActions, - NcEmptyContent, - NcHeaderMenu, - NcHighlight, - 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, - triggered: false, + return { + currentLocation, - defaultLimit, - minSearchLength, - enableLiveSearch, + 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 - }, {}) - }, - - ariaLabel() { - return t('core', 'Search') - }, - /** - * Is there any result to display - * - * @return {boolean} + * Debounce emitting the search query by 250ms */ - hasResults() { - return Object.keys(this.results).length !== 0 + debouncedQueryUpdate() { + return debounce(this.emitUpdatedQuery, 250) }, /** - * Return ordered results - * - * @return {Array} + * Current page (app) supports local in-app search */ - 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 - }, - - /** - * 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) + 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 - * - * @return {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() { - subscribe('files:navigation:changed', this.resetForm) - this.types = await getTypes() - this.logger.debug('Unified Search initialized with the following providers', this.types) - }, - - beforeDestroy() { - unsubscribe('files:navigation:changed', this.resetForm) - }, - mounted() { - if (OCP.Accessibility.disableKeyboardShortcuts()) { - return + // register keyboard listener for search shortcut + if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) { + window.addEventListener('keydown', this.onKeyDown) } - 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 - } - - // 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) - } - } + // Allow external reset of the search / close local search + subscribe('nextcloud:unified-search:reset', () => { + this.showLocalSearch = false + this.queryText = '' }) - }, - - methods: { - async onOpen() { - // Update types list in the background - this.types = await getTypes() - }, - onClose() { - emit('nextcloud:unified-search.close') - }, - - resetForm() { - 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) - }) - } - }, + // 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 - * @return {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) - } - + openModal() { + this.showUnifiedSearch = true + this.showLocalSearch = false }, /** - * Focus the specified result index if it exists - * - * @param {number} index the result index + * Emit the updated search query as eventbus events */ - focusIndex(index) { - const results = this.getResultsList() - if (results && results[index]) { - results[index].focus() + emitUpdatedQuery() { + if (this.queryText === '') { + emit('nextcloud:unified-search:reset') + } else { + emit('nextcloud:unified-search:search', { query: this.queryText }) } }, - - /** - * 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: 6px; - -.unified-search { - &__trigger { - filter: var(--background-image-invert-if-bright); - } - - &__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-left: 13px; - } - } - - &__form-input { - margin: 0 !important; - } - - &__input-row { - display: flex; - width: 100%; - align-items: center; - } - - &__filters { - margin: $margin 0 $margin math.div($margin, 2); - ul { - display: inline-flex; - justify-content: space-between; - } - } - - &__form { - position: relative; - width: 100%; - margin: $margin 0; - - // Loading spinner - &::after { - right: $input-padding; - left: auto; - } - - &-input, - &-reset { - margin: math.div($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, &-submit { - position: absolute; - top: 0; - right: 4px; - width: $input-height - $input-padding; - height: $input-height - $input-padding; - min-height: 30px; - padding: 0; - opacity: .5; - border: none; - background-color: transparent; - margin-right: 0; - - &:hover, - &:focus, - &:active { - opacity: 1; - } - } - - &-submit { - right: 28px; - } - } - - &__results { - &-header { - display: block; - margin: $margin; - margin-bottom: $margin - 4px; - margin-left: 13px; - color: var(--color-primary-element); - font-size: 19px; - font-weight: bold; - } - display: flex; - flex-direction: column; - gap: 4px; - } - - .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 index e46c64da24c..408cccf61e9 100644 --- a/core/src/views/UnsupportedBrowser.vue +++ b/core/src/views/UnsupportedBrowser.vue @@ -1,24 +1,7 @@ - <!-- - - @copyright 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="content-unsupported-browser guest-box"> <NcEmptyContent> @@ -48,11 +31,14 @@ </template> <script> -import { generateUrl } from '@nextcloud/router' +// 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/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' -import Web from 'vue-material-design-icons/Web' + +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' @@ -69,12 +55,6 @@ export default { NcEmptyContent, }, - data() { - return { - agents: {}, - } - }, - computed: { isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) @@ -109,12 +89,12 @@ export default { }) return Object.keys(list).map(id => { - if (!this.agents[id]?.browser) { + if (!agents[id]?.browser) { return null } const version = list[id] - const name = this.agents[id]?.browser + const name = agents[id]?.browser return this.t('core', '{name} version {version} and above', { name, version, }) @@ -122,13 +102,6 @@ export default { }, }, - async beforeMount() { - // Dynamic load big list of user agents - // eslint-disable-next-line node/no-extraneous-import - const { agents } = await import('caniuse-lite') - this.agents = agents - }, - methods: { t, n, @@ -140,10 +113,22 @@ export default { // Redirect if there is the data const urlParams = new URLSearchParams(window.location.search) if (urlParams.has('redirect_url')) { - const redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/' - window.location = redirectPath - return + 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('/') }, @@ -179,7 +164,8 @@ $spacing: 30px; .empty-content { margin: 0; - &::v-deep .empty-content__icon { + + :deep(.empty-content__icon) { opacity: 1; } } @@ -193,7 +179,7 @@ $spacing: 30px; margin-top: 2 * $spacing; margin-bottom: $spacing; li { - text-align: left; + text-align: start; } } } |