diff options
Diffstat (limited to 'core/src')
157 files changed, 5515 insertions, 5513 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 551259bd46c..350ffc3f21c 100644 --- a/core/src/OC/appconfig.js +++ b/core/src/OC/appconfig.js @@ -1,28 +1,7 @@ /** - * @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 */ diff --git a/core/src/OC/apps.js b/core/src/OC/apps.js index 9f0ae217526..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' 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 5d93ce21428..08520e278f6 100644 --- a/core/src/OC/backbone.js +++ b/core/src/OC/backbone.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 VendorBackbone from 'backbone' diff --git a/core/src/OC/capabilities.js b/core/src/OC/capabilities.js index 72a71d7c743..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' 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/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 6ce960ee41c..5c6934e67a2 100644 --- a/core/src/OC/dialogs.js +++ b/core/src/OC/dialogs.js @@ -1,46 +1,7 @@ /** - * @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 */ @@ -48,18 +9,22 @@ import _ from 'underscore' import $ from 'jquery' import IconMove from '@mdi/svg/svg/folder-move.svg?raw' -import IconCopy from '@mdi/svg/svg/folder-multiple.svg?raw' +import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw' import OC from './index.js' -import { FilePickerType, getFilePickerBuilder } from '@nextcloud/dialogs' +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` */ @@ -73,15 +38,14 @@ const Dialogs = { /** @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( @@ -93,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) @@ -111,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( @@ -130,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 @@ -148,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 @@ -169,69 +174,25 @@ 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() + }, + ) }) }, @@ -317,13 +278,13 @@ const Dialogs = { } else { builder.setButtonFactory((nodes, path) => { const buttons = [] - const node = nodes?.[0]?.attributes?.displayName || nodes?.[0]?.basename - const target = node || basename(path) + 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: node }) : t('core', 'Choose'), + label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'), type: 'primary', }) } @@ -363,105 +324,81 @@ const Dialogs = { /** * 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) { - 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 + 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 + } + + const dialog = builder.build() + + if (allowHtml) { + dialog.setHTML(content) + } + + return dialog.show().then(() => { + if(!callback._clicked) { + callback(false) } - $('body').append($dlg) - var buttonlist = [] - switch (buttons) { + }) + }, + + /** + * Helper for legacy API + * @deprecated + */ + _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 @@ -470,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 @@ -829,27 +768,11 @@ const Dialogs = { return dialogDeferred.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) }) diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js index 940b25655e4..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.js' +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 33dd45a17ee..5afc941b396 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.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 { subscribe } from '@nextcloud/event-bus' @@ -68,9 +49,7 @@ import { getPort, getProtocol, } from './host.js' -import { - getToken as getRequestToken, -} from './requesttoken.js' +import { getRequestToken } from './requesttoken.ts' import { hideMenus, registerMenu, diff --git a/core/src/OC/l10n.js b/core/src/OC/l10n.js index b04d4bf9fba..02f912d6a99 100644 --- a/core/src/OC/l10n.js +++ b/core/src/OC/l10n.js @@ -1,30 +1,8 @@ /** - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.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 Handlebars from 'handlebars' @@ -62,7 +40,7 @@ const L10n = { * @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, diff --git a/core/src/OC/menu.js b/core/src/OC/menu.js index efa917decd2..4b4eb658592 100644 --- a/core/src/OC/menu.js +++ b/core/src/OC/menu.js @@ -1,25 +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 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' @@ -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 210d6b1e5c1..621f7a0695f 100644 --- a/core/src/OC/password-confirmation.js +++ b/core/src/OC/password-confirmation.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 { confirmPassword, isPasswordConfirmationRequired } from '@nextcloud/password-confirmation' 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 d18b8743936..7ecd0e098c6 100644 --- a/core/src/OC/util-history.js +++ b/core/src/OC/util-history.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 'underscore' @@ -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 f0dd7e0ac14..c46d9a141b1 100644 --- a/core/src/OC/util.js +++ b/core/src/OC/util.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 moment from 'moment' 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 a346daa6984..233aaf60350 100644 --- a/core/src/OC/xhr-error.js +++ b/core/src/OC/xhr-error.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 'underscore' @@ -26,16 +8,18 @@ import $ from 'jquery' 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 f49f0319a9c..cf5c29ce60a 100644 --- a/core/src/OCA/index.js +++ b/core/src/OCA/index.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 */ /** diff --git a/core/src/OCP/accessibility.js b/core/src/OCP/accessibility.js index d318d3497c4..4a1399f3f96 100644 --- a/core/src/OCP/accessibility.js +++ b/core/src/OCP/accessibility.js @@ -1,23 +1,6 @@ /** - * @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' diff --git a/core/src/OCP/appconfig.js b/core/src/OCP/appconfig.js index 5bd8ca9efae..78f94922d53 100644 --- a/core/src/OCP/appconfig.js +++ b/core/src/OCP/appconfig.js @@ -1,24 +1,7 @@ /** - * @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' 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 aee37d17030..94f4e8e5eb3 100644 --- a/core/src/OCP/index.js +++ b/core/src/OCP/index.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 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' 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 273d608870c..00000000000 --- a/core/src/Polyfill/index.js +++ /dev/null @@ -1,22 +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/>. - * - */ diff --git a/core/src/Polyfill/tooltip.js b/core/src/Polyfill/tooltip.js deleted file mode 100644 index 925b67c3e8b..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) { - OC.debug && console.warn('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) { - OC.debug && console.warn('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 2213840a7c0..88f626ff569 100644 --- a/core/src/components/AppMenu.vue +++ b/core/src/components/AppMenu.vue @@ -1,309 +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" + <nav ref="appMenu" + class="app-menu" :aria-label="t('core', 'Applications menu')"> - <ul class="app-menu-main"> - <li v-for="app in mainAppList" + <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.js' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js' +import { loadState } from '@nextcloud/initial-state' +import { n, t } from '@nextcloud/l10n' +import { useElementSize } from '@vueuse/core' +import { defineComponent, ref } from 'vue' -export default { +import AppMenuEntry from './AppMenuEntry.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import logger from '../logger' + +export default defineComponent({ name: 'AppMenu', + components: { - 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%; + // 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-shrink: 1; - flex-wrap: wrap; -} -.app-menu-main { - display: flex; - flex-wrap: nowrap; + flex: 1 1; + width: 0; - .app-menu-entry { - width: 50px; - height: 50px; - position: relative; + &__list { display: flex; + flex-wrap: nowrap; + margin-inline: calc(var(--app-menu-entry-growth) / 2); + } - &.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; - } - - .app-menu-entry--label { - font-weight: bold; - } - } - - a { - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - // this is shown directly on the background which has `color-primary`, so we need `color-primary-text` - color: var(--color-primary-text); - position: relative; - } + &__overflow { + margin-block: auto; - 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; + // 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); - } - .app-menu-entry--label { - opacity: 0; - position: absolute; - font-size: 12px; - // this is shown directly on the background which has `color-primary`, so we need `color-primary-text` - color: var(--color-primary-text); - text-align: center; - 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; - filter: var(--background-image-invert-if-bright); - } + /* Remove all background and align text color if not expanded */ + &:not([aria-expanded="true"]) { + color: var(--color-background-plain-text); - &:hover, - &:focus-within { - opacity: 1; - .app-menu-entry--label { - opacity: 1; - font-weight: bolder; - bottom: 0; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; + &:hover { + opacity: 1; + background-color: transparent !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 { - opacity: .7; - margin: 3px; - filter: var(--background-image-invert-if-bright); - - /* Remove all background and align text color if not expanded */ - &:not([aria-expanded="true"]) { - color: var(--color-primary-element-text); - - &:hover { - opacity: 1; - background-color: transparent !important; + &:focus-visible { + opacity: 1; + outline: none !important; + } } } - &:focus-visible { - opacity: 1; - outline: none !important; - } -} - -.app-menu-popover-entry { - .app-icon { - position: relative; - height: 44px; - width: 48px; - display: flex; - align-items: center; - justify-content: center; - /* Icons are bright so invert them if bright color theme == bright background is used */ - filter: var(--background-invert-if-bright); - - &.has-unread::after { - background-color: var(--color-main-text); - } - - img { - width: $header-icon-size; - height: $header-icon-size; + &__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-element-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 index a450127b937..322f53647b1 100644 --- a/core/src/components/ContactsMenu/Contact.vue +++ b/core/src/components/ContactsMenu/Contact.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="contact"> @@ -39,7 +23,7 @@ :inline="contact.topAction ? 1 : 0"> <template v-for="(action, idx) in actions"> <NcActionLink v-if="action.hyperlink !== '#'" - :key="idx" + :key="`${idx}-link`" :href="action.hyperlink" class="other-actions"> <template #icon> @@ -47,30 +31,46 @@ </template> {{ action.title }} </NcActionLink> - <NcActionText v-else :key="idx" class="other-actions"> + <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/dist/Components/NcActionLink.js' -import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +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: { @@ -85,6 +85,9 @@ export default { } return this.contact.actions }, + jsActions() { + return getEnabledContactsMenuActions(this.contact) + }, preloadedUserStatus() { if (this.contact.status) { return { @@ -94,7 +97,7 @@ export default { } } return undefined - } + }, }, } </script> @@ -104,7 +107,8 @@ export default { display: flex; position: relative; align-items: center; - padding: 3px 3px 3px 10px; + padding: 3px; + padding-inline-start: 10px; &__action { &__icon { @@ -113,9 +117,10 @@ export default { padding: 12px; filter: var(--background-invert-if-dark); } - } - &__avatar-wrapper { + &__icon-svg { + padding: 5px; + } } &__avatar { @@ -124,8 +129,8 @@ export default { &__body { flex-grow: 1; - padding-left: 10px; - margin-left: 10px; + padding-inline-start: 10px; + margin-inline-start: 10px; min-width: 0; div { @@ -178,11 +183,11 @@ export default { /* actions menu */ .menu { top: 47px; - margin-right: 13px; + margin-inline-end: 13px; } .popovermenu::after { - right: 2px; + 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 d09b348c62b..dbc446b3d90 100644 --- a/core/src/components/Profile/PrimaryActionButton.vue +++ b/core/src/components/Profile/PrimaryActionButton.vue @@ -1,23 +1,6 @@ <!-- - - @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> @@ -38,8 +21,8 @@ <script> import { defineComponent } from 'vue' -import { NcButton } from '@nextcloud/vue' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' export default defineComponent({ name: 'PrimaryActionButton', 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 index ec592732a8d..d86192d156e 100644 --- a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue +++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcModal v-if="isModalOpen" id="unified-search" @@ -33,9 +37,9 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' +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 { diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue index 01f48a36709..4592adf08c9 100644 --- a/core/src/components/UnifiedSearch/LegacySearchResult.vue +++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue @@ -1,24 +1,7 @@ - <!-- - - @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" @@ -59,7 +42,7 @@ </template> <script> -import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js' +import NcHighlight from '@nextcloud/vue/components/NcHighlight' export default { name: 'LegacySearchResult', @@ -236,7 +219,7 @@ $margin: 10px; flex-wrap: wrap; // Set to minimum and gro from it min-width: 0; - padding-left: $margin; + padding-inline-start: $margin; } &-line-one, diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue index 8342e9e256d..e08ddd58a4b 100644 --- a/core/src/components/UnifiedSearch/SearchFilterChip.vue +++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="chip"> <span class="icon"> @@ -50,7 +54,7 @@ export default { .icon { display: flex; align-items: center; - padding-right: 5px; + padding-inline-end: 5px; img { width: 20px; diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue index a746a5751b7..4f33fbd54cc 100644 --- a/core/src/components/UnifiedSearch/SearchResult.vue +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -1,16 +1,20 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcListItem class="result-items__item" + <NcListItem class="result-item" :name="title" :bold="false" :href="resourceUrl" target="_self"> <template #icon> <div aria-hidden="true" - class="result-items__item-icon" + class="result-item__icon" :class="{ - 'result-items__item-icon--rounded': rounded, - 'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), - 'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--rounded': rounded, + 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), [icon]: !isValidIconOrPreviewUrl(icon), }" :style="{ @@ -28,7 +32,7 @@ </template> <script> -import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' +import NcListItem from '@nextcloud/vue/components/NcListItem' export default { name: 'SearchResult', @@ -97,73 +101,59 @@ export default { </script> <style lang="scss" scoped> -@use "sass:math"; -$clickable-area: 44px; -$margin: 10px; - -.result-items { - &__item { - - ::v-deep a { - border-radius: 12px; - 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); - } +.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); + } - &--no-preview { - background-size: 32px; - } + * { + cursor: pointer; + } + } + + &__icon { + overflow: hidden; + 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: calc(var(--default-clickable-area) / 2); + } - &--with-thumbnail { - background-size: cover; - } + &--no-preview { + background-size: 32px; + } - &--with-thumbnail:not(&--rounded) { - // compensate for border - max-width: $clickable-area - 2px; - max-height: $clickable-area - 2px; - border: 1px solid var(--color-border); - } + &--with-thumbnail { + background-size: cover; + } - img { - // Make sure to keep ratio - width: 100%; - height: 100%; + &--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); + } - object-fit: cover; - object-position: center; - } - } + img { + // Make sure to keep ratio + width: 100%; + height: 100%; - } + object-fit: cover; + object-position: center; + } + } } </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 index 33f45d06266..d7abb6ffdbb 100644 --- a/core/src/components/UnifiedSearch/SearchableList.vue +++ b/core/src/components/UnifiedSearch/SearchableList.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me> - - - - @author Marco Ambrosini <marcoambrosini@proton.me> - - - - @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> @@ -34,7 +17,7 @@ :show-trailing-button="searchTerm !== ''" @update:value="searchTermChanged" @trailing-button-click="clearSearch"> - <Magnify :size="20" /> + <IconMagnify :size="20" /> </NcTextField> <ul v-if="filteredList.length > 0" class="searchable-list__list"> <li v-for="element in filteredList" @@ -46,7 +29,11 @@ :wide="true" @click="itemSelected(element)"> <template #icon> - <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" /> + <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> @@ -55,7 +42,7 @@ <div v-else class="searchable-list__empty-content"> <NcEmptyContent :name="emptyContentText"> <template #icon> - <AlertCircleOutline /> + <IconAlertCircleOutline /> </template> </NcEmptyContent> </div> @@ -64,22 +51,26 @@ </template> <script> -import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue' +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 AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' -import Magnify from 'vue-material-design-icons/Magnify.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' export default { name: 'SearchableList', components: { - NcPopover, - NcTextField, - Magnify, - AlertCircleOutline, + IconMagnify, + IconAlertCircleOutline, NcAvatar, - NcEmptyContent, NcButton, + NcEmptyContent, + NcPopover, + NcTextField, }, props: { 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..b21c65301c4 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -0,0 +1,795 @@ +<!-- + - 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> + </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 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, + 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, + } + }, + + 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) + }, + }, + + 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) + }, + }, + }, + + 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 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; + } + + &__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 e165e784422..5c488f2341e 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -1,37 +1,20 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' -import UserMenu from '../views/UserMenu.vue' +import AccountMenu from '../views/AccountMenu.vue' export const setUp = () => { const mountPoint = document.getElementById('user-menu') if (mountPoint) { // eslint-disable-next-line no-new new Vue({ + name: 'AccountMenuRoot', el: mountPoint, - render: h => h(UserMenu), + render: h => h(AccountMenu), }) } } diff --git a/core/src/components/UserMenu/ProfileUserMenuEntry.vue b/core/src/components/UserMenu/ProfileUserMenuEntry.vue deleted file mode 100644 index 61357f09ac6..00000000000 --- a/core/src/components/UserMenu/ProfileUserMenuEntry.vue +++ /dev/null @@ -1,140 +0,0 @@ -<!-- - - @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/>. - - ---> - -<template> - <li :id="id" - class="menu-entry"> - <component :is="profileEnabled ? 'a' : 'span'" - class="menu-entry__wrapper" - :class="{ - active, - 'menu-entry__wrapper--link': profileEnabled, - }" - :href="profileEnabled ? href : undefined" - @click.exact="handleClick"> - <span class="menu-entry__content"> - <span class="menu-entry__displayname">{{ displayName }}</span> - <NcLoadingIcon v-if="loading" :size="18" /> - </span> - <span v-if="profileEnabled">{{ name }}</span> - </component> - </li> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' -import { getCurrentUser } from '@nextcloud/auth' -import { subscribe, unsubscribe } from '@nextcloud/event-bus' - -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' - -const { profileEnabled } = loadState('user_status', 'profileEnabled', false) - -export default { - name: 'ProfileUserMenuEntry', - - components: { - NcLoadingIcon, - }, - - props: { - id: { - type: String, - required: true, - }, - name: { - type: String, - required: true, - }, - href: { - type: String, - required: true, - }, - active: { - type: Boolean, - required: true, - }, - }, - - data() { - return { - profileEnabled, - displayName: getCurrentUser().displayName, - 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) { - this.profileEnabled = profileEnabled - }, - - handleDisplayNameUpdate(displayName) { - this.displayName = displayName - }, - }, -} -</script> - -<style lang="scss" scoped> -.menu-entry { - &__wrapper { - box-sizing: border-box; - display: inline-flex; - flex-direction: column; - align-items: flex-start !important; - padding: 10px 12px 5px 12px !important; - height: var(--header-menu-item-height); - color: var(--color-text-maxcontrast); - - &--link { - height: calc(var(--header-menu-item-height) * 1.5) !important; - color: var(--color-main-text); - } - } - - &__content { - display: inline-flex; - gap: 0 10px; - } - - &__displayname { - font-weight: bold; - } -} -</style> diff --git a/core/src/components/UserMenu/UserMenuEntry.vue b/core/src/components/UserMenu/UserMenuEntry.vue deleted file mode 100644 index 8f09137256c..00000000000 --- a/core/src/components/UserMenu/UserMenuEntry.vue +++ /dev/null @@ -1,106 +0,0 @@ -<!-- - - @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/>. - - ---> - -<template> - <li :id="id" - class="menu-entry"> - <a v-if="href" - :href="href" - :class="{ active }" - @click.exact="handleClick"> - <NcLoadingIcon v-if="loading" - class="menu-entry__loading-icon" - :size="18" /> - <img v-else :src="cachedIcon" alt=""> - {{ name }} - </a> - <button v-else> - <img :src="cachedIcon" alt=""> - {{ name }} - </button> - </li> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' - -const versionHash = loadState('core', 'versionHash', '') - -export default { - name: 'UserMenuEntry', - - components: { - NcLoadingIcon, - }, - - props: { - id: { - type: String, - required: true, - }, - name: { - type: String, - required: true, - }, - href: { - type: String, - required: true, - }, - active: { - type: Boolean, - required: true, - }, - icon: { - type: String, - required: true, - }, - }, - - data() { - return { - loading: false, - } - }, - - computed: { - cachedIcon() { - return `${this.icon}?v=${versionHash}` - }, - }, - - methods: { - handleClick() { - this.loading = true - }, - }, -} -</script> - -<style lang="scss" scoped> -.menu-entry { - &__loading-icon { - margin-right: 8px; - } -} -</style> diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue index 54c29245469..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.js' +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 9a8689dc9cc..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" @@ -33,9 +17,9 @@ {{ t('core', 'Please contact your administrator.') }} </NcNoteCard> <NcNoteCard v-if="csrfCheckFailed" - :heading="t('core', 'Temporary error')" + :heading="t('core', 'Session error')" type="error"> - {{ t('core', 'Please try again.') }} + {{ 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" @@ -57,17 +41,22 @@ <!-- 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="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" /> @@ -99,7 +88,7 @@ :value="timezoneOffset"> <input type="hidden" name="requesttoken" - :value="OC.requestToken"> + :value="requestToken"> <input v-if="directLogin" type="hidden" name="direct" @@ -109,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 { @@ -127,6 +120,8 @@ export default { NcNoteCard, }, + mixins: [AuthMixin], + props: { username: { type: String, @@ -156,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.') @@ -207,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() { @@ -219,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') }, @@ -232,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%; @@ -246,5 +305,10 @@ export default { 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 dbc4fdae695..bc4d25bf70f 100644 --- a/core/src/components/login/PasswordLessLoginForm.vue +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -1,61 +1,74 @@ +<!-- + - 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"> - <h2>{{ t('core', 'Log in with a device') }}</h2> - <fieldset> - <NcTextField required - :value="user" - :autocomplete="autoCompleteAllowed ? 'on' : 'off'" - :error="!validCredentials" - :label="t('core', 'Username or email')" - :placeholder="t('core', 'Username 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" /> - </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="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="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.js' -import LoginButton from './LoginButton.vue' -import InformationIcon from 'vue-material-design-icons/Information.vue' -import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +} 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: { @@ -79,11 +92,14 @@ export default { type: Boolean, default: false, }, - hasPublicKeyCredential: { - type: Boolean, - default: false, - }, }, + + setup() { + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + data() { return { user: this.username, @@ -92,7 +108,7 @@ export default { } }, methods: { - authenticate() { + async authenticate() { // check required fields if (!this.$refs.loginForm.checkValidity()) { return @@ -100,112 +116,25 @@ export default { console.debug('passwordless login initiated') - 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) - }) + try { + const params = await startAuthentication(this.user) + await this.completeAuthentication(params) + } catch (error) { + if (error instanceof NoValidCredentials) { + this.validCredentials = false + return + } + logger.debug(error) + } }, changeUsername(username) { this.user = username this.$emit('update:username', this.user) }, - getAuthenticationData(uid) { - const base64urlDecode = function(input) { - // Replace non-url compatible chars with base64 standard chars - input = input - .replace(/-/g, '+') - .replace(/_/g, '/') - - // 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('=') - } - - return window.atob(input) - } - - 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... - }) - }, 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 ??. @@ -220,21 +149,14 @@ export default { // noop }, }, -} +}) </script> <style lang="scss" scoped> - fieldset { - display: flex; - flex-direction: column; - gap: 0.5rem; - - :deep(label) { - text-align: initial; - } - } - - .update { - margin: 0 auto; - } +.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 e1d66daa4aa..fee1deacc36 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -1,72 +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')" - required - @change="updateUsername" /> - <LoginButton :value="t('core', 'Reset password')" /> - - <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 account name, 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> - - <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, @@ -77,11 +73,12 @@ export default { required: true, }, }, + data() { return { error: false, loading: false, - message: undefined, + message: '', user: this.username, } }, @@ -94,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 e281ad7ffc7..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"> diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 472c011c762..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,43 @@ <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"> + <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 NcButton from '@nextcloud/vue/dist/Components/NcButton.js' - import logger from '../../logger.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + const recommended = { calendar: { description: t('core', 'Schedule work & meetings, synced with all your devices.'), @@ -97,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: { @@ -118,6 +101,7 @@ const recommendedIds = Object.keys(recommended) export default { name: 'RecommendedApps', components: { + NcCheckboxRadioSwitch, NcButton, }, data() { @@ -127,20 +111,23 @@ export default { loadingApps: true, loadingAppsError: false, apps: [], - defaultPageUrl: loadState('core', '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 @@ -154,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`) @@ -208,6 +196,14 @@ export default { } return !!recommended[appId].hidden }, + toggleSelect(appId) { + // disable toggle when installButton is disabled + if (!(appId in recommended) || !this.showInstallButton) { + return + } + const index = this.apps.findIndex(app => app.id === appId) + this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected) + }, }, } </script> @@ -250,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 9d32fefdfc4..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 */ 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 1a4c0582768..4b07cc17c3e 100644 --- a/core/src/globals.js +++ b/core/src/globals.js @@ -1,26 +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> - * @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 */ @@ -28,16 +8,13 @@ import { initCore } from './init.js' import _ from 'underscore' import $ from 'jquery' -import 'jquery-migrate/dist/jquery-migrate.min.js' // 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.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.js' import ClipboardJS from 'clipboard' import { dav } from 'davclient.js' import Handlebars from 'handlebars' @@ -52,7 +29,7 @@ import 'strengthify/strengthify.css' import OC from './OC/index.js' import OCP from './OCP/index.js' import OCA from './OCA/index.js' -import { getToken as getRequestToken } from './OC/requesttoken.js' +import { getRequestToken } from './OC/requesttoken.ts' const warnIfNotTesting = function() { if (window.TESTING === undefined) { @@ -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 624ba54be88..5845b01fea1 100644 --- a/core/src/icons.js +++ b/core/src/icons.js @@ -1,3 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ /* eslint-disable quote-props */ /* eslint-disable n/no-unpublished-import */ import path from 'path' @@ -99,6 +103,7 @@ const icons = { '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'), @@ -124,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', @@ -166,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 @@ -209,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', } @@ -221,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 6114c612705..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 { interceptRequests } from './utils/xhr-request.js' +import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts' // keep in sync with core/css/variables.scss const breakpointMobileWidth = 1024 @@ -79,6 +59,7 @@ moment.locale(locale) */ export const initCore = () => { interceptRequests() + initFallbackClipboardAPI() $(window).on('unload.main', () => { OC._unloadCalled = true }) $(window).on('beforeunload.main', () => { diff --git a/core/src/install.js b/core/src/install.js deleted file mode 100644 index e8432de42b0..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.js' -import getURLParameter from './Util/get-url-parameter.js' - -import './jquery/showpassword.js' - -import 'jquery-ui/ui/widgets/button.js' -import 'jquery-ui/themes/base/theme.css' -import 'jquery-ui/themes/base/button.css' - -import './Polyfill/tooltip.js' - -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 1d86e54e9d3..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/index.js' - /** * 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 4026f48068d..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/index.js' +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 4c2241d8d5d..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,12 +52,14 @@ 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 { @@ -57,6 +68,7 @@ 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, diff --git a/core/src/jquery/css/jquery.ocdialog.scss b/core/src/jquery/css/jquery.ocdialog.scss index 1911dd29dc4..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; @@ -53,7 +59,7 @@ width: 44px !important; height: 44px !important; top: 4px; - right: 4px; + inset-inline-end: 4px; padding: 25px; background: var(--icon-close-dark) no-repeat center; opacity: .5; @@ -69,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 c0716890cb3..f285ba19449 100644 --- a/core/src/jquery/index.js +++ b/core/src/jquery/index.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' diff --git a/core/src/jquery/ocdialog.js b/core/src/jquery/ocdialog.js index 2d547443be9..a5f588ec659 100644 --- a/core/src/jquery/ocdialog.js +++ b/core/src/jquery/ocdialog.js @@ -1,26 +1,6 @@ /** - * @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' @@ -234,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() 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 9e24216a1b5..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 */ diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js index 68858aa1534..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.js' +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 index 943081f3d23..59ee462fbf5 100644 --- a/core/src/legacy-unified-search.js +++ b/core/src/legacy-unified-search.js @@ -1,34 +1,17 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getLoggerBuilder } from '@nextcloud/logger' -import { getRequestToken } from '@nextcloud/auth' +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__ = btoa(getRequestToken()) +__webpack_nonce__ = getCSPNonce() const logger = getLoggerBuilder() .setApp('unified-search') 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 964b26520ec..29affcda762 100644 --- a/core/src/login.js +++ b/core/src/login.js @@ -1,26 +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> - * @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' diff --git a/core/src/main.js b/core/src/main.js index 46fe5fa1718..2d88f15562b 100644 --- a/core/src/main.js +++ b/core/src/main.js @@ -1,31 +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 'core-js/stable/index.js' import 'regenerator-runtime/runtime.js' -import './Polyfill/index.js' // If you remove the line below, tests won't pass // eslint-disable-next-line no-unused-vars @@ -35,10 +14,12 @@ import './globals.js' import './jquery/index.js' import { initCore } from './init.js' import { registerAppsSlideToggle } from './OC/apps.js' -import { getRequestToken } from '@nextcloud/auth' +import { getCSPNonce } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' +import Axios from '@nextcloud/axios' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) +__webpack_nonce__ = getCSPNonce() window.addEventListener('DOMContentLoaded', function() { initCore() @@ -51,3 +32,20 @@ window.addEventListener('DOMContentLoaded', function() { 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 4cef2b9b09e..3a94f85d2c6 100644 --- a/core/src/mixins/Nextcloud.js +++ b/core/src/mixins/Nextcloud.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 L10n from '../OC/l10n.js' 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.ts b/core/src/profile.ts deleted file mode 100644 index ee1593e8705..00000000000 --- a/core/src/profile.ts +++ /dev/null @@ -1,45 +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 { getRequestToken } from '@nextcloud/auth' -import Vue from 'vue' - -import Profile from './views/Profile.vue' -import ProfileSections from './profile/ProfileSections.js' - -// @ts-expect-error Script nonce required for webpack loading additional scripts -__webpack_nonce__ = btoa(getRequestToken() ?? '') - -if (!window.OCA) { - window.OCA = {} -} - -if (!window.OCA.Core) { - window.OCA.Core = {} -} -Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() }) - -const View = Vue.extend(Profile) - -window.addEventListener('DOMContentLoaded', () => { - new View().$mount('#content') -}) diff --git a/core/src/profile/ProfileSections.js b/core/src/profile/ProfileSections.js deleted file mode 100644 index ac8e8644eeb..00000000000 --- a/core/src/profile/ProfileSections.js +++ /dev/null @@ -1,42 +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 31179a5a4ad..13f16436ed3 100644 --- a/core/src/recommendedapps.js +++ b/core/src/recommendedapps.js @@ -1,26 +1,9 @@ /** - * @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' @@ -28,7 +11,7 @@ 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 daf4e947dbb..77f217a86ac 100644 --- a/core/src/services/BrowsersListService.js +++ b/core/src/services/BrowsersListService.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 { getUserAgentRegex } from 'browserslist-useragent-regexp' diff --git a/core/src/services/LegacyUnifiedSearchService.js b/core/src/services/LegacyUnifiedSearchService.js index 3c673479771..5b79c09b8b2 100644 --- a/core/src/services/LegacyUnifiedSearchService.js +++ b/core/src/services/LegacyUnifiedSearchService.js @@ -1,26 +1,6 @@ /** - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateOcsUrl } from '@nextcloud/router' diff --git a/core/src/services/UnifiedSearchService.js b/core/src/services/UnifiedSearchService.js index 9e16fe1880c..7067c994c90 100644 --- a/core/src/services/UnifiedSearchService.js +++ b/core/src/services/UnifiedSearchService.js @@ -1,23 +1,6 @@ /** - * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com> - * - * @author Fon E. Noel NFEBE <fenn25.fn@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 */ import { generateOcsUrl, generateUrl } from '@nextcloud/router' @@ -65,9 +48,10 @@ export async function getProviders() { * @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, since, until, limit, person }) { +export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) { /** * Generate an axios cancel token */ @@ -84,6 +68,7 @@ export function search({ type, query, cursor, since, until, limit, person }) { person, // Sending which location we're currently at from: window.location.pathname.replace('/index.php', '') + window.location.search, + ...extraQueries, }, }) @@ -97,7 +82,7 @@ export function search({ type, query, cursor, since, until, limit, person }) { * Get the list of active contacts * * @param {object} filter filter contacts by string - * @param filter.searchTerm + * @param {string} filter.searchTerm the query * @return {object} {request: Promise} */ export async function getContacts({ searchTerm }) { 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 153408b767b..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/index.js' -import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken.js' - -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 261ba02c905..1d2cd3ae57d 100644 --- a/core/src/systemtags/systemtagmodel.js +++ b/core/src/systemtags/systemtagmodel.js @@ -1,26 +1,8 @@ /** - * 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) { 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 57a3d4de3c1..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.js' - -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 index bdf0238e5f9..e83f75bfd15 100644 --- a/core/src/tests/components/ContactsMenu/Contact.spec.js +++ b/core/src/tests/components/ContactsMenu/Contact.spec.js @@ -1,24 +1,9 @@ /** - * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2023 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: 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' @@ -33,19 +18,19 @@ describe('Contact', function() { topAction: { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:deboraoliver%40centrexin.com' + hyperlink: 'mailto:deboraoliver%40centrexin.com', }, emailAddresses: [], actions: [ { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:mathisholland%40virxo.com' + hyperlink: 'mailto:mathisholland%40virxo.com', }, { title: 'Details', icon: 'icon-info', - hyperlink: 'https://localhost/index.php/apps/contacts' + hyperlink: 'https://localhost/index.php/apps/contacts', }, ], lastMessage: '', diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js index 6b438a4998e..084c3215e47 100644 --- a/core/src/tests/views/ContactsMenu.spec.js +++ b/core/src/tests/views/ContactsMenu.spec.js @@ -1,31 +1,20 @@ /** - * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import axios from '@nextcloud/axios' import { mount, shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' import ContactsMenu from '../../views/ContactsMenu.vue' -jest.mock('@nextcloud/axios', () => ({ - post: jest.fn(), +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() { @@ -55,7 +44,7 @@ describe('ContactsMenu', function() { it('shows error view when contacts can not be loaded', async () => { const view = mount(ContactsMenu) axios.post.mockResolvedValue({}) - jest.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) try { await view.vm.handleOpen() @@ -72,7 +61,7 @@ describe('ContactsMenu', function() { it('shows text when there are no contacts', async () => { const view = mount(ContactsMenu) - axios.post.mockResolvedValue({ + axios.post.mockResolvedValueOnce({ data: { contacts: [], contactsAppEnabled: false, @@ -98,19 +87,19 @@ describe('ContactsMenu', function() { topAction: { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:deboraoliver%40centrexin.com' + hyperlink: 'mailto:deboraoliver%40centrexin.com', }, actions: [ { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:mathisholland%40virxo.com' + hyperlink: 'mailto:mathisholland%40virxo.com', }, { title: 'Details', icon: 'icon-info', - hyperlink: 'https://localhost/index.php/apps/contacts' - } + hyperlink: 'https://localhost/index.php/apps/contacts', + }, ], lastMessage: '', emailAddresses: [], @@ -121,23 +110,23 @@ describe('ContactsMenu', function() { topAction: { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:ceciliasoto%40essensia.com' + hyperlink: 'mailto:ceciliasoto%40essensia.com', }, actions: [ { title: 'Mail', icon: 'icon-mail', - hyperlink: 'mailto:pearliesellers%40inventure.com' + hyperlink: 'mailto:pearliesellers%40inventure.com', }, { title: 'Details', icon: 'icon-info', - hyperlink: 'https://localhost/index.php/apps/contacts' - } + hyperlink: 'https://localhost/index.php/apps/contacts', + }, ], lastMessage: 'cu', emailAddresses: [], - } + }, ], contactsAppEnabled: true, }, 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 f9bddff4c68..00000000000 --- a/core/src/unified-search.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> - * - * @author Fon E. Noel NFEBE <fenn25.fn@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 { 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 dd116b3a6ee..64620afa085 100644 --- a/core/src/unsupported-browser-redirect.js +++ b/core/src/unsupported-browser-redirect.js @@ -1,28 +1,12 @@ /** - * @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 { getRequestToken } from '@nextcloud/auth' +import { getCSPNonce } from '@nextcloud/auth' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) +__webpack_nonce__ = getCSPNonce() if (!window.TESTING && !OC?.config?.no_unsupported_browser_warning) { window.addEventListener('DOMContentLoaded', async function() { 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 index ff8b7641b07..7f074a857a6 100644 --- a/core/src/utils/xhr-request.js +++ b/core/src/utils/xhr-request.js @@ -1,30 +1,16 @@ -/* - * @copyright Copyright (c) 2023 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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getRootUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl, getRootUrl } from '@nextcloud/router' +import logger from '../logger.js' /** * * @param {string} url the URL to check - * @returns {boolean} + * @return {boolean} */ const isRelativeUrl = (url) => { return !url.startsWith('https://') && !url.startsWith('http://') @@ -43,6 +29,60 @@ const isNextcloudUrl = (url) => { } /** + * 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 @@ -51,17 +91,24 @@ export const interceptRequests = () => { XMLHttpRequest.prototype.open = (function(open) { return function(method, url, async) { open.apply(this, arguments) - if (isNextcloudUrl(url) && !this.getResponseHeader('X-Requested-With')) { - this.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + 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 (resource, options) => { + 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 fetch(resource, options) + return await fetch(resource, options) } if (!options) { options = {} @@ -76,7 +123,11 @@ export const interceptRequests = () => { options.headers['X-Requested-With'] = 'XMLHttpRequest' } - return fetch(resource, options) + 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 443d0c4eb17..924ddcea56b 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -1,23 +1,6 @@ <!-- - - @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> @@ -26,25 +9,25 @@ :aria-label="t('core', 'Search contacts')" @open="handleOpen"> <template #trigger> - <Contacts :size="20" /> + <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" /> </template> <div class="contactsmenu__menu"> <div class="contactsmenu__menu__input-wrapper"> - <NcTextField :value.sync="searchTerm" - trailing-button-icon="close" + <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 …')" - id="contactsmenu__menu__search" 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> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <NcEmptyContent v-else-if="loadingText" :name="loadingText"> @@ -54,7 +37,7 @@ </NcEmptyContent> <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')"> <template #icon> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <div v-else class="contactsmenu__menu__content"> @@ -79,39 +62,46 @@ </template> <script> +import { mdiContacts, mdiMagnify } from '@mdi/js' +import { generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import Contacts from 'vue-material-design-icons/Contacts.vue' import debounce from 'debounce' -import { getCurrentUser } from '@nextcloud/auth' -import { generateUrl } from '@nextcloud/router' -import Magnify from 'vue-material-design-icons/Magnify.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import { translate as t } from '@nextcloud/l10n' + +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' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' export default { name: 'ContactsMenu', components: { Contact, - Contacts, - Magnify, NcButton, NcEmptyContent, NcHeaderMenu, + NcIconSvgWrapper, NcLoadingIcon, NcTextField, }, mixins: [Nextcloud], + setup() { + return { + mdiContacts, + mdiMagnify, + } + }, + data() { const user = getCurrentUser() return { @@ -188,6 +178,10 @@ export default { .contactsmenu { overflow-y: hidden; + &__trigger-icon { + color: var(--color-background-plain-text) !important; + } + &__menu { display: flex; flex-direction: column; @@ -198,7 +192,7 @@ export default { label[for="contactsmenu__menu__search"] { font-weight: bold; font-size: 19px; - margin-left: 13px; + margin-inline-start: 13px; } &__input-wrapper { @@ -231,5 +225,9 @@ export default { } } } + + :deep(.empty-content) { + margin: 0 !important; + } } </style> diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue index 04e4c77fe39..1277970ba0e 100644 --- a/core/src/views/LegacyUnifiedSearch.vue +++ b/core/src/views/LegacyUnifiedSearch.vue @@ -1,24 +1,7 @@ - <!-- - - @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" @@ -29,8 +12,7 @@ @close="onClose"> <!-- Header icon --> <template #trigger> - <Magnify class="unified-search__trigger" - :size="22/* fit better next to other 20px icons */" /> + <Magnify class="unified-search__trigger-icon" :size="20" /> </template> <!-- Search form & filters wrapper --> @@ -126,11 +108,11 @@ 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 NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +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' @@ -288,7 +270,7 @@ export default { return n('core', 'Please enter {minSearchLength} character or more to search', - 'Please enter {minSearchLength} characters or more to search', + 'Please enter {minSearchLength} characters or more to search', this.minSearchLength, { minSearchLength: this.minSearchLength }) }, @@ -723,6 +705,10 @@ $input-height: 34px; $input-padding: 10px; .unified-search { + &__trigger-icon { + color: var(--color-background-plain-text) !important; + } + &__input-wrapper { position: sticky; // above search results @@ -738,17 +724,7 @@ $input-padding: 10px; align-self: flex-start; font-weight: bold; font-size: 19px; - margin-left: 13px; - } - } - - &__form-input { - margin: 0 !important; - &:focus, - &:focus-visible, - &:active { - border-color: 2px solid var(--color-main-text) !important; - box-shadow: 0 0 0 2px var(--color-main-background) !important; + margin-inline-start: 13px; } } @@ -759,7 +735,8 @@ $input-padding: 10px; } &__filters { - margin: $margin 0 $margin math.div($margin, 2); + margin-block: $margin; + margin-inline: math.div($margin, 2) 0; padding-top: 5px; ul { display: inline-flex; @@ -774,8 +751,7 @@ $input-padding: 10px; // Loading spinner &::after { - right: $input-padding; - left: auto; + inset-inline-start: auto $input-padding; } &-input, @@ -788,6 +764,13 @@ $input-padding: 10px; 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 { @@ -805,10 +788,11 @@ $input-padding: 10px; } } - &-reset, &-submit { + &-reset, + &-submit { position: absolute; top: 0; - right: 4px; + inset-inline-end: 4px; width: $input-height - $input-padding; height: $input-height - $input-padding; min-height: 30px; @@ -816,7 +800,7 @@ $input-padding: 10px; opacity: .5; border: none; background-color: transparent; - margin-right: 0; + margin-inline-end: 0; &:hover, &:focus, @@ -826,35 +810,36 @@ $input-padding: 10px; } &-submit { - right: 28px; + inset-inline-end: 28px; } } &__results { + display: flex; + flex-direction: column; + gap: 4px; + &-header { display: block; margin: $margin; margin-bottom: $margin - 4px; - margin-left: 13px; + margin-inline-start: 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 { + :deep(.unified-search__result-more) { color: var(--color-text-maxcontrast); } .empty-content { margin: 10vh 0; - ::v-deep .empty-content__title { + :deep(.empty-content__title) { font-weight: normal; - font-size: var(--default-font-size); + font-size: var(--default-font-size); text-align: center; } } diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index 643cf66c07b..a6fe8442779 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -1,30 +1,13 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - @author Richard Steinmetz <richard@steinmetz.cloud> - - - - @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"> <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" @@ -32,47 +15,37 @@ :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 login-passwordless"> + 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" /> <NcButton type="tertiary" :aria-label="t('core', 'Back to login form')" @@ -82,7 +55,7 @@ </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" @@ -100,13 +73,13 @@ </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> </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" @@ -122,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 { @@ -177,59 +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 { // Same size as dashboard panels width: 320px; box-sizing: border-box; - &__link { - display: block; - padding: 1rem; - font-size: var(--default-font-size); - text-align: center; - font-weight: normal !important; + &__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; - } -} - -.login-passwordless { - .button-vue { - margin-top: 0.5rem; - } -} </style> diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue deleted file mode 100644 index b7f593df8d2..00000000000 --- a/core/src/views/Profile.vue +++ /dev/null @@ -1,491 +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> - <NcContent app-name="profile"> - <NcAppContent> - <div class="profile__header"> - <div class="profile__header__container"> - <div class="profile__header__container__placeholder" /> - <div class="profile__header__container__displayname"> - <h2>{{ displayname || userId }}</h2> - <NcButton v-if="isCurrentUser" - type="primary" - :href="settingsUrl"> - <template #icon> - <PencilIcon :size="20" /> - </template> - {{ t('core', 'Edit Profile') }} - </NcButton> - </div> - <NcButton v-if="status.icon || status.message" - :disabled="!isCurrentUser" - :type="isCurrentUser ? 'tertiary' : 'tertiary-no-background'" - @click="openStatusModal"> - {{ status.icon }} {{ status.message }} - </NcButton> - </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 --> - <NcButton v-if="primaryAction" - type="primary" - class="user-actions__primary" - :href="primaryAction.target" - :icon="primaryAction.icon" - :target="primaryAction.id === 'phone' ? '_self' :'_blank'"> - <template #icon> - <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 --> - <img :src="primaryAction.icon" alt="" class="user-actions__primary__icon"> - </template> - {{ primaryAction.title }} - </NcButton> - <NcActions class="user-actions__other" :inline="4"> - <NcActionLink v-for="action in otherActions" - :key="action.id" - :close-after-click="true" - :href="action.target" - :target="action.id === 'phone' ? '_self' :'_blank'"> - <template #icon> - <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 --> - <img :src="action.icon" alt="" class="user-actions__other__icon"> - </template> - {{ action.title }} - </NcActionLink> - </NcActions> - </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"> - <h3 v-if="headline" class="profile__blocks-headline"> - {{ headline }} - </h3> - <p v-if="biography" class="profile__blocks-biography"> - {{ biography }} - </p> - - <!-- 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)" :user-id="userId" /> - </div> - </template> - <NcEmptyContent v-else - class="profile__blocks-empty-info" - :name="emptyProfileMessage" - :description="t('core', 'The headline and about sections will show up here')"> - <template #icon> - <AccountIcon :size="60" /> - </template> - </NcEmptyContent> - </div> - </div> - </div> - </NcAppContent> - </NcContent> -</template> - -<script lang="ts"> -import { getCurrentUser } from '@nextcloud/auth' -import { showError } from '@nextcloud/dialogs' -import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' -import { translate as t } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' -import { defineComponent } from 'vue' - -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js' -import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcContent from '@nextcloud/vue/dist/Components/NcContent.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import AccountIcon from 'vue-material-design-icons/Account.vue' -import MapMarkerIcon from 'vue-material-design-icons/MapMarker.vue' -import PencilIcon from 'vue-material-design-icons/Pencil.vue' - -interface IProfileAction { - target: string - icon: string - id: string - title: string -} - -interface IStatus { - icon: string, - message: string, - userId: string, -} - -export default defineComponent({ - name: 'Profile', - - components: { - AccountIcon, - MapMarkerIcon, - NcActionLink, - NcActions, - NcAppContent, - NcAvatar, - NcButton, - NcContent, - NcEmptyContent, - PencilIcon, - }, - - data() { - const profileParameters = loadState('core', 'profileParameters', { - userId: null as string|null, - displayname: null as string|null, - address: null as string|null, - organisation: null as string|null, - role: null as string|null, - headline: null as string|null, - biography: null as string|null, - actions: [] as IProfileAction[], - isUserAvatarVisible: false, - }) - - return { - ...profileParameters, - status: loadState<Partial<IStatus>>('core', 'status', {}), - sections: window.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 - }, - - otherActions() { - console.warn(this.allActions) - if (this.allActions.length > 1) { - return this.allActions.slice(1) - } - return [] - }, - - settingsUrl() { - return generateUrl('/settings/user') - }, - - 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: { - t, - - handleStatusUpdate(status: IStatus) { - if (this.isCurrentUser && status.userId === this.userId) { - this.status = status - } - }, - - openStatusModal() { - const statusMenuItem = document.querySelector<HTMLButtonElement>('.user-status-menu-item') - // 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" scoped> -$profile-max-width: 1024px; -$content-max-width: 640px; - -:deep(#app-content-vue) { - background-color: unset; -} - -.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 { - padding-inline: 16px; // same as the status text button, see NcButton - width: $content-max-width; - height: 45px; - margin-block: 100px 0; - display: flex; - align-items: center; - gap: 18px; - - h2 { - font-size: 30px; - } - } - } - } - - &__sidebar { - position: sticky; - top: 0; - align-self: flex-start; - padding-top: 20px; - min-width: 220px; - margin: -150px 20px 0 0; - - // Specificity hack is needed to override Avatar component styles - :deep(.avatar.avatardiv) { - text-align: center; - margin: auto; - display: block; - padding: 8px; - - &.interactive { - .avatardiv__user-status { - // Show that the status is interactive - cursor: pointer; - } - } - - .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; - } - } - } - - &__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 { - cursor: text; - 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-inline: 0; - margin-block: 10px 0; - font-weight: bold; - font-size: 20px; - } - - &-biography { - white-space: pre-line; - } - } -} - -@media only screen and (max-width: 1024px) { - .profile { - &__header { - height: 250px; - position: unset; - - &__container { - grid-template-columns: unset; - - &__displayname { - margin: 80px 20px 0px!important; - height: 1em; - width: unset; - display: unset; - text-align: center; - } - - &__edit-button { - width: fit-content; - display: block; - margin: 60px auto; - } - - &__status-text { - margin: 4px auto; - } - } - } - - &__content { - display: block; - } - - &__blocks { - width: unset; - max-width: 600px; - margin: 0 auto; - padding: 20px 50px 50px 50px; - } - - &__sidebar { - margin: unset; - position: unset; - } - } -} - -.user-actions { - display: flex; - flex-direction: column; - gap: 8px 0; - margin-top: 20px; - - &__primary { - margin: 0 auto; - - &__icon { - filter: var(--primary-invert-if-dark); - } - } - - &__other { - display: flex; - justify-content: center; - gap: 0 4px; - - &__icon { - height: 20px; - width: 20px; - object-fit: contain; - filter: var(--background-invert-if-dark); - align-self: center; - margin: 12px; // so we get 44px x 44px - } - } -} -</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 419c0b47c41..103e47b0425 100644 --- a/core/src/views/UnifiedSearch.vue +++ b/core/src/views/UnifiedSearch.vue @@ -1,96 +1,182 @@ - <!-- - - @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com> - - - - @author Fon E. Noel NFEBE <fenn25.fn@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: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="header-menu"> - <NcButton class="unified-search__button" :aria-label="t('core', 'Unified search')" @click="toggleUnifiedSearch"> + <div class="unified-search-menu"> + <NcHeaderButton v-show="!showLocalSearch" + :aria-label="t('core', 'Unified search')" + @click="toggleUnifiedSearch"> <template #icon> - <Magnify class="unified-search__trigger" :size="22" /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> - </NcButton> - <UnifiedSearchModal :class="'unified-search-modal'" :is-visible="showUnifiedSearch" @update:isVisible="handleModalVisibilityChange" /> + </NcHeaderButton> + <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch" + :open.sync="showLocalSearch" + :query.sync="queryText" + @global-search="openModal" /> + <UnifiedSearchModal :local-search="supportsLocalSearch" + :query.sync="queryText" + :open.sync="showUnifiedSearch" /> </div> </template> -<script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import Magnify from 'vue-material-design-icons/Magnify.vue' -import UnifiedSearchModal from './UnifiedSearchModal.vue' +<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 { 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 { +export default defineComponent({ name: 'UnifiedSearch', + components: { - NcButton, - Magnify, + NcHeaderButton, + NcIconSvgWrapper, UnifiedSearchModal, + UnifiedSearchLocalSearchBar, }, + + setup() { + const currentLocation = useBrowserLocation() + + return { + currentLocation, + + mdiMagnify, + t, + } + }, + data() { return { + /** The current search query */ + queryText: '', + /** Open state of the modal */ showUnifiedSearch: false, + /** Open state of the local search bar */ + showLocalSearch: false, } }, + + computed: { + /** + * Debounce emitting the search query by 250ms + */ + debouncedQueryUpdate() { + return debounce(this.emitUpdatedQuery, 250) + }, + + /** + * Current page (app) supports local in-app search + */ + supportsLocalSearch() { + // TODO: Make this an API + const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps'] + return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) + }, + }, + + watch: { + /** + * Emit the updated query as eventbus events + * (This is debounced) + */ + queryText() { + this.debouncedQueryUpdate() + }, + }, + mounted() { - console.debug('Unified search initialized!') + // register keyboard listener for search shortcut + if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) { + window.addEventListener('keydown', this.onKeyDown) + } + + // Allow external reset of the search / close local search + subscribe('nextcloud:unified-search:reset', () => { + this.showLocalSearch = false + this.queryText = '' + }) + + // 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 }) + }) + + // all done + logger.debug('Unified search initialized!') }, + + beforeDestroy() { + // keep in mind to remove the event listener + window.removeEventListener('keydown', this.onKeyDown) + }, + methods: { + /** + * Handle the key down event to open search on `ctrl + F` + * @param event The keyboard 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.toggleUnifiedSearch() + } + }, + + /** + * Toggle the local search if available - otherwise open the unified search modal + */ toggleUnifiedSearch() { - this.showUnifiedSearch = !this.showUnifiedSearch + if (this.supportsLocalSearch) { + this.showLocalSearch = !this.showLocalSearch + } else { + this.showUnifiedSearch = !this.showUnifiedSearch + this.showLocalSearch = false + } }, - handleModalVisibilityChange(newVisibilityVal) { - this.showUnifiedSearch = newVisibilityVal + + /** + * Open the unified search modal + */ + openModal() { + this.showUnifiedSearch = true + this.showLocalSearch = false + }, + + /** + * Emit the updated search query as eventbus events + */ + emitUpdatedQuery() { + if (this.queryText === '') { + emit('nextcloud:unified-search:reset') + } else { + emit('nextcloud:unified-search:search', { query: this.queryText }) + } }, }, -} +}) </script> <style lang="scss" scoped> -.header-menu { +// this is needed to allow us overriding component styles (focus-visible) +.unified-search-menu { display: flex; align-items: center; justify-content: center; - - .unified-search__button { - display: flex; - align-items: center; - justify-content: center; - width: var(--header-height); - // height: var(--header-height); - margin: 0; - padding: 0; - cursor: pointer; - opacity: .85; - background-color: transparent; - border: none; - filter: none !important; - color: var(--color-primary-text) !important; - - &:hover { - background-color: transparent !important; - } - } -} - -.unified-search-modal { - ::v-deep .modal-container { - height: 80%; - } } </style> diff --git a/core/src/views/UnifiedSearchModal.vue b/core/src/views/UnifiedSearchModal.vue deleted file mode 100644 index 004005b57d9..00000000000 --- a/core/src/views/UnifiedSearchModal.vue +++ /dev/null @@ -1,623 +0,0 @@ -<template> - <NcModal id="unified-search" - ref="unifiedSearchModal" - :show.sync="internalIsVisible" - :clear-view-delay="0" - @close="closeModal"> - <CustomDateRangeModal :is-open="showDateRangeModal" - class="unified-search__date-range" - @set:custom-date-range="setCustomDateRange" - @update:is-open="showDateRangeModal = $event" /> - <!-- Unified search form --> - <div ref="unifiedSearch" class="unified-search-modal"> - <div class="unified-search-modal__header"> - <h2>{{ t('core', 'Unified search') }}</h2> - <NcInputField ref="searchInput" - :value.sync="searchQuery" - type="text" - :label="t('core', 'Search apps, files, tags, messages') + '...'" - @update:value="debouncedFind" /> - <div class="unified-search-modal__filters"> - <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen"> - <template #icon> - <ListBox :size="20" /> - </template> - <NcActionButton v-for="provider in providers" - :key="provider.id" - @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"> - <template #icon> - <CalendarRangeIcon :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')" - @search-term-change="debouncedFilterContacts" - @item-selected="applyPersonFilter"> - <template #trigger> - <NcButton> - <template #icon> - <AccountGroup :size="20" /> - </template> - {{ t('core', 'People') }} - </NcButton> - </template> - </SearchableList> - <NcButton v-if="supportFiltering" @click="closeModal"> - {{ t('core', 'Filter in current view') }} - <template #icon> - <FilterIcon :size="20" /> - </template> - </NcButton> - </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" /> - <CalendarRangeIcon v-else-if="filter.type === 'date'" /> - <img v-else :src="filter.icon" alt=""> - </template> - </FilterChip> - </div> - </div> - <div v-if="noContentInfo.show" class="unified-search-modal__no-content"> - <NcEmptyContent :name="noContentInfo.text"> - <template #icon> - <component :is="noContentInfo.icon" /> - </template> - </NcEmptyContent> - </div> - <div v-else class="unified-search-modal__results"> - <div v-for="providerResult in results" :key="providerResult.id" class="result"> - <div class="result-title"> - <span>{{ providerResult.provider }}</span> - </div> - <ul class="result-items"> - <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" /> - </ul> - <div class="result-footer"> - <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)"> - {{ t('core', 'Load more results') }} - <template #icon> - <DotsHorizontalIcon :size="20" /> - </template> - </NcButton> - <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> - {{ t('core', 'Search in') }} {{ providerResult.provider }} - <template #icon> - <ArrowRight :size="20" /> - </template> - </NcButton> - </div> - </div> - </div> - </div> - </NcModal> -</template> - -<script> -import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' -import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' -import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' -import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue' -import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' -import FilterIcon from 'vue-material-design-icons/Filter.vue' -import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue' -import ListBox from 'vue-material-design-icons/ListBox.vue' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' -import MagnifyIcon from 'vue-material-design-icons/Magnify.vue' -import SearchableList from '../components/UnifiedSearch/SearchableList.vue' -import SearchResult from '../components/UnifiedSearch/SearchResult.vue' - -import debounce from 'debounce' -import { emit } from '@nextcloud/event-bus' -import { useBrowserLocation } from '@vueuse/core' -import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js' - -export default { - name: 'UnifiedSearchModal', - components: { - ArrowRight, - AccountGroup, - CalendarRangeIcon, - CustomDateRangeModal, - DotsHorizontalIcon, - FilterIcon, - FilterChip, - ListBox, - NcActions, - NcActionButton, - NcAvatar, - NcButton, - NcEmptyContent, - NcModal, - NcInputField, - MagnifyIcon, - SearchableList, - SearchResult, - }, - props: { - isVisible: { - type: Boolean, - required: true, - }, - }, - setup() { - /** - * Reactive version of window.location - */ - const currentLocation = useBrowserLocation() - return { - currentLocation, - } - }, - 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: '' }, - dateFilterIsApplied: false, - personFilterIsApplied: false, - filteredProviders: [], - searching: false, - searchQuery: '', - placessearchTerm: '', - dateTimeFilter: null, - filters: [], - results: [], - contacts: [], - debouncedFind: debounce(this.find, 300), - debouncedFilterContacts: debounce(this.filterContacts, 300), - showDateRangeModal: false, - internalIsVisible: false, - } - }, - - computed: { - userContacts() { - return this.contacts - }, - noContentInfo() { - const isEmptySearch = this.searchQuery.length === 0 - const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0 - return { - show: isEmptySearch || hasNoResults, - text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing to search') : t('core', 'No matching results')), - icon: MagnifyIcon, - } - }, - supportFiltering() { - /* Hard coded apps for the moment this would be improved in coming updates. */ - const providerPaths = ['/settings/users', '/apps/files', '/apps/deck'] - return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) - }, - }, - watch: { - isVisible(value) { - this.internalIsVisible = value - }, - internalIsVisible(value) { - this.$emit('update:isVisible', value) - this.$nextTick(() => { - if (value) { - this.focusInput() - } - }) - }, - - }, - mounted() { - getProviders().then((providers) => { - this.providers = providers - console.debug('Search providers', this.providers) - }) - getContacts({ searchTerm: '' }).then((contacts) => { - this.contacts = this.mapContacts(contacts) - console.debug('Contacts', this.contacts) - }) - }, - methods: { - find(query) { - this.searching = true - if (query.length === 0) { - this.results = [] - this.searching = false - return - } - // Event should probably be refactored at some point to used nextcloud:unified-search.search - emit('nextcloud:unified-search.search', { query }) - const newResults = [] - const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers - const searchProvider = (provider, filters) => { - const params = { - type: provider.id, - query, - cursor: null, - } - - if (filters.dateFilterIsApplied) { - if (provider.filters.since && provider.filters.until) { - params.since = this.dateFilter.startFrom - params.until = this.dateFilter.endAt - } else { - // Date filter is applied but provider does not support it, no need to search provider - return - } - } - - if (filters.personFilterIsApplied) { - if (provider.filters.person) { - params.person = this.personFilter.user - } else { - // Person filter is applied but provider does not support it, no need to search provider - return - } - } - - if (this.providerResultLimit > 5) { - params.limit = this.providerResultLimit - } - - const request = unifiedSearch(params).request - - request().then((response) => { - newResults.push({ - id: provider.id, - provider: provider.name, - inAppSearch: provider.inAppSearch, - results: response.data.ocs.data.entries, - }) - - console.debug('New results', newResults) - console.debug('Unified search results:', this.results) - - this.updateResults(newResults) - this.searching = false - }) - } - providersToSearch.forEach(provider => { - const dateFilterIsApplied = this.dateFilterIsApplied - const personFilterIsApplied = this.personFilterIsApplied - searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied }) - }) - - }, - 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, - } - }) - }, - filterContacts(query) { - getContacts({ searchTerm: query }).then((contacts) => { - this.contacts = this.mapContacts(contacts) - console.debug(`Contacts filtered by ${query}`, this.contacts) - }) - }, - applyPersonFilter(person) { - this.personFilterIsApplied = true - 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.debouncedFind(this.searchQuery) - console.debug('Person filter applied', person) - }, - loadMoreResultsForProvider(providerId) { - this.providerResultLimit += 5 - this.filters = this.filters.filter(filter => filter.type !== 'provider') - const provider = this.providers.find(provider => provider.id === providerId) - this.addProviderFilter(provider, true) - }, - addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { - if (!providerFilter.id) return - this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5 - this.providerActionMenuIsOpen = false - const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id) - if (!existingFilter) { - this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters }) - } - this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (newly added)', 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) - console.debug('Search filters (recently removed)', this.filters) - - } else { - for (let i = 0; i < this.filters.length; i++) { - // Remove date and person filter - if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) { - this.dateFilterIsApplied = false - this.filters.splice(i, 1) - if (filter.type === 'person') { - this.personFilterIsApplied = false - } - 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.dateFilterIsApplied = true - 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) { - console.debug('Custom date 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() - }, - focusInput() { - this.$refs.searchInput.$el.children[0].children[0].focus() - }, - closeModal() { - this.internalIsVisible = false - this.searchQuery = '' - }, - }, -} -</script> - -<style lang="scss" scoped> -.unified-search-modal { - box-sizing: border-box; - height: 100%; - - display: flex; - flex-direction: column; - padding-block: 10px 0; - - // inline padding on direct children to make sure the scrollbar is on the modal container - > * { - padding-inline: 20px; - } - - &__header { - padding-block-end: 8px; - } - - &__heading { - font-size: 16px; - font-weight: bolder; - line-height: 2em; - margin-bottom: 0; - } - - &__filters { - display: flex; - flex-wrap: wrap; - gap: 4px; - justify-content: start; - padding-top: 4px; - } - - &__filters-applied { - padding-top: 4px; - display: flex; - flex-wrap: wrap; - } - - &__no-content { - display: flex; - align-items: center; - height: 100%; - } - - &__results { - overflow: hidden scroll; - padding-block: 0 10px; - - .result { - &-title { - span { - color: var(--color-primary-element); - font-weight: bolder; - font-size: 16px; - } - } - - &-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/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue index f9125fa9958..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,10 +31,13 @@ </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.js' + +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' @@ -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 n/no-extraneous-import - const { agents } = await import('caniuse-lite') - this.agents = agents - }, - methods: { t, n, @@ -140,12 +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() || '/' + 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('/') }, @@ -181,7 +164,8 @@ $spacing: 30px; .empty-content { margin: 0; - &::v-deep .empty-content__icon { + + :deep(.empty-content__icon) { opacity: 1; } } @@ -195,7 +179,7 @@ $spacing: 30px; margin-top: 2 * $spacing; margin-bottom: $spacing; li { - text-align: left; + text-align: start; } } } diff --git a/core/src/views/UserMenu.vue b/core/src/views/UserMenu.vue deleted file mode 100644 index 0c5084842a1..00000000000 --- a/core/src/views/UserMenu.vue +++ /dev/null @@ -1,281 +0,0 @@ -<!-- - - @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/>. - - ---> - -<template> - <NcHeaderMenu id="user-menu" - class="user-menu" - is-nav - :aria-label="t('core', 'Settings menu')" - :description="avatarDescription"> - <template #trigger> - <NcAvatar v-if="!isLoadingUserStatus" - class="user-menu__avatar" - :disable-menu="true" - :disable-tooltip="true" - :user="userId" - :preloaded-user-status="userStatus" /> - </template> - <ul> - <ProfileUserMenuEntry :id="profileEntry.id" - :name="profileEntry.name" - :href="profileEntry.href" - :active="profileEntry.active" /> - <UserMenuEntry 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> -import axios from '@nextcloud/axios' -import { emit, subscribe } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' -import { generateOcsUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' -import { getCapabilities } from '@nextcloud/capabilities' - -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' - -import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js' -import ProfileUserMenuEntry from '../components/UserMenu/ProfileUserMenuEntry.vue' -import UserMenuEntry from '../components/UserMenu/UserMenuEntry.vue' - -import logger from '../logger.js' - -/** - * @typedef SettingNavEntry - * @property {string} id - id of the entry, used as HTML ID, for example, "settings" - * @property {string} name - Label of the entry, for example, "Personal Settings" - * @property {string} icon - Icon of the entry, for example, "/apps/settings/img/personal.svg" - * @property {'settings'|'link'|'guest'} type - Type of the entry - * @property {string} href - Link of the entry, for example, "/settings/user" - * @property {boolean} active - Whether the entry is active - * @property {number} order - Order of the entry - * @property {number} unread - Number of unread pf this items - * @property {string} classes - Classes for custom styling - */ - -/** @type {Record<string, SettingNavEntry>} */ -const settingsNavEntries = loadState('core', 'settingsNavEntries', []) -const { profile: profileEntry, ...otherEntries } = settingsNavEntries - -const translateStatus = (status) => { - const statusMap = Object.fromEntries( - getAllStatusOptions() - .map(({ type, label }) => [type, label]), - ) - if (statusMap[status]) { - return statusMap[status] - } - return status -} - -export default { - name: 'UserMenu', - - components: { - NcAvatar, - NcHeaderMenu, - ProfileUserMenuEntry, - UserMenuEntry, - }, - - data() { - return { - profileEntry, - otherEntries, - displayName: getCurrentUser()?.displayName, - userId: getCurrentUser()?.uid, - isLoadingUserStatus: true, - userStatus: { - status: null, - icon: null, - message: null, - }, - } - }, - - computed: { - translatedUserStatus() { - return { - ...this.userStatus, - status: translateStatus(this.userStatus.status), - } - }, - - avatarDescription() { - const description = [ - t('core', 'Avatar of {displayName}', { displayName: this.displayName }), - ...Object.values(this.translatedUserStatus).filter(Boolean), - ].join(' — ') - return description - }, - }, - - async created() { - if (!getCapabilities()?.user_status?.enabled) { - this.isLoadingUserStatus = false - 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.isLoadingUserStatus = false - }, - - mounted() { - subscribe('user_status:status.updated', this.handleUserStatusUpdated) - emit('core:user-menu:mounted') - }, - - methods: { - handleUserStatusUpdated(state) { - if (this.userId === state.userId) { - this.userStatus = { - status: state.status, - icon: state.icon, - message: state.message, - } - } - }, - }, -} -</script> - -<style lang="scss" scoped> -.user-menu { - margin-right: 12px; - - &:deep { - .header-menu { - &__trigger { - opacity: 1 !important; - &:focus-visible { - .user-menu__avatar { - border: 2px solid var(--color-primary-element); - } - } - } - - &__carret { - display: none !important; - } - - &__content { - width: fit-content !important; - } - } - } - - &__avatar { - &:active, - &:focus, - &:hover { - border: 2px solid var(--color-primary-element-text); - } - } - - ul { - display: flex; - flex-direction: column; - gap: 2px; - - &:deep { - li { - a, - button { - border-radius: 6px; - display: inline-flex; - align-items: center; - height: var(--header-menu-item-height); - color: var(--color-main-text); - padding: 10px 8px; - box-sizing: border-box; - white-space: nowrap; - position: relative; - width: 100%; - - &:hover { - background-color: var(--color-background-hover); - } - - &:focus-visible { - background-color: var(--color-background-hover) !important; - box-shadow: inset 0 0 0 2px var(--color-primary-element) !important; - outline: none !important; - } - - &:active:not(:focus-visible), - &.active:not(:focus-visible) { - background-color: var(--color-primary-element); - color: var(--color-primary-element-text); - - img, - svg { - filter: var(--primary-invert-if-dark); - } - } - - span { - padding-bottom: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 210px; - } - - img { - width: 16px; - height: 16px; - margin-right: 10px; - } - - img, - svg { - filter: var(--background-invert-if-dark); - } - } - - // Override global button styles - button { - background-color: transparent; - border: none; - font-weight: normal; - margin: 0; - } - } - } - } -} -</style> |