aboutsummaryrefslogtreecommitdiffstats
path: root/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'core/src')
-rw-r--r--core/src/OC/admin.js22
-rw-r--r--core/src/OC/appconfig.js29
-rw-r--r--core/src/OC/apps.js25
-rw-r--r--core/src/OC/appsettings.js97
-rw-r--r--core/src/OC/appswebroots.js21
-rw-r--r--core/src/OC/backbone-webdav.js23
-rw-r--r--core/src/OC/backbone.js24
-rw-r--r--core/src/OC/capabilities.js25
-rw-r--r--core/src/OC/config.js21
-rw-r--r--core/src/OC/constants.js22
-rw-r--r--core/src/OC/contactsmenu.js468
-rw-r--r--core/src/OC/contactsmenu/contact.handlebars70
-rw-r--r--core/src/OC/contactsmenu/error.handlebars4
-rw-r--r--core/src/OC/contactsmenu/list.handlebars12
-rw-r--r--core/src/OC/contactsmenu/loading.handlebars4
-rw-r--r--core/src/OC/contactsmenu/menu.handlebars4
-rw-r--r--core/src/OC/currentuser.js22
-rw-r--r--core/src/OC/debug.js21
-rw-r--r--core/src/OC/dialogs.js1110
-rw-r--r--core/src/OC/eventsource.js32
-rw-r--r--core/src/OC/get_set.js22
-rw-r--r--core/src/OC/host.js22
-rw-r--r--core/src/OC/index.js110
-rw-r--r--core/src/OC/l10n-registry.js90
-rw-r--r--core/src/OC/l10n.js328
-rw-r--r--core/src/OC/legacy-loader.js79
-rw-r--r--core/src/OC/menu.js27
-rw-r--r--core/src/OC/msg.js23
-rw-r--r--core/src/OC/navigation.js22
-rw-r--r--core/src/OC/notification.js38
-rw-r--r--core/src/OC/password-confirmation.js118
-rw-r--r--core/src/OC/plugins.js22
-rw-r--r--core/src/OC/query-string.js27
-rw-r--r--core/src/OC/requesttoken.js57
-rw-r--r--core/src/OC/requesttoken.ts49
-rw-r--r--core/src/OC/routing.js23
-rw-r--r--core/src/OC/theme.js21
-rw-r--r--core/src/OC/util-history.js26
-rw-r--r--core/src/OC/util.js34
-rw-r--r--core/src/OC/webroot.js21
-rw-r--r--core/src/OC/xhr-error.js40
-rw-r--r--core/src/OCA/index.js30
-rw-r--r--core/src/OCA/search.js32
-rw-r--r--core/src/OCP/accessibility.js34
-rw-r--r--core/src/OCP/appconfig.js25
-rw-r--r--core/src/OCP/collaboration.js24
-rw-r--r--core/src/OCP/comments.js24
-rw-r--r--core/src/OCP/index.js37
-rw-r--r--core/src/OCP/loader.js29
-rw-r--r--core/src/OCP/toast.js24
-rw-r--r--core/src/OCP/whatsnew.js22
-rw-r--r--core/src/Polyfill/index.js24
-rw-r--r--core/src/Polyfill/tooltip.js46
-rw-r--r--core/src/Util/a11y.js21
-rw-r--r--core/src/Util/get-url-parameter.js24
-rw-r--r--core/src/ajax-cron.ts18
-rw-r--r--core/src/components/AccountMenu/AccountMenuEntry.vue117
-rw-r--r--core/src/components/AccountMenu/AccountMenuProfileEntry.vue100
-rw-r--r--core/src/components/AppMenu.vue333
-rw-r--r--core/src/components/AppMenuEntry.vue189
-rw-r--r--core/src/components/AppMenuIcon.vue67
-rw-r--r--core/src/components/ContactsMenu.js24
-rw-r--r--core/src/components/ContactsMenu/Contact.vue193
-rw-r--r--core/src/components/LegacyDialogPrompt.vue111
-rw-r--r--core/src/components/MainMenu.js25
-rw-r--r--core/src/components/Profile/PrimaryActionButton.vue91
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuEntry.vue51
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue90
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue51
-rw-r--r--core/src/components/UnifiedSearch/CustomDateRangeModal.vue107
-rw-r--r--core/src/components/UnifiedSearch/LegacySearchResult.vue242
-rw-r--r--core/src/components/UnifiedSearch/SearchFilterChip.vue79
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue229
-rw-r--r--core/src/components/UnifiedSearch/SearchResultPlaceholders.vue4
-rw-r--r--core/src/components/UnifiedSearch/SearchableList.vue157
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue166
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchModal.vue838
-rw-r--r--core/src/components/UserMenu.js66
-rw-r--r--core/src/components/login/LoginButton.vue27
-rw-r--r--core/src/components/login/LoginForm.cy.ts76
-rw-r--r--core/src/components/login/LoginForm.vue139
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue240
-rw-r--r--core/src/components/login/ResetPassword.vue173
-rw-r--r--core/src/components/login/UpdatePassword.vue25
-rw-r--r--core/src/components/setup/RecommendedApps.vue108
-rw-r--r--core/src/eventbus.d.ts14
-rw-r--r--core/src/files/client.js41
-rw-r--r--core/src/files/fileinfo.js27
-rw-r--r--core/src/globals.js45
-rw-r--r--core/src/icons.js26
-rw-r--r--core/src/init.js42
-rw-r--r--core/src/install.js175
-rw-r--r--core/src/install.ts43
-rw-r--r--core/src/jquery/avatar.js32
-rw-r--r--core/src/jquery/contactsmenu.js27
-rw-r--r--core/src/jquery/css/jquery-ui-fixes.scss41
-rw-r--r--core/src/jquery/css/jquery.ocdialog.scss22
-rw-r--r--core/src/jquery/exists.js22
-rw-r--r--core/src/jquery/filterattr.js22
-rw-r--r--core/src/jquery/index.js45
-rw-r--r--core/src/jquery/ocdialog.js53
-rw-r--r--core/src/jquery/octemplate.js25
-rw-r--r--core/src/jquery/placeholder.js28
-rw-r--r--core/src/jquery/requesttoken.js26
-rw-r--r--core/src/jquery/selectrange.js22
-rw-r--r--core/src/jquery/showpassword.js25
-rw-r--r--core/src/jquery/ui-fixes.js22
-rw-r--r--core/src/legacy-unified-search.js38
-rw-r--r--core/src/logger.js26
-rw-r--r--core/src/login.js28
-rw-r--r--core/src/main.js55
-rw-r--r--core/src/maintenance.js23
-rw-r--r--core/src/mixins/Nextcloud.js25
-rw-r--r--core/src/mixins/auth.js19
-rw-r--r--core/src/profile.js59
-rw-r--r--core/src/profile/ProfileSections.js43
-rw-r--r--core/src/public-page-menu.ts15
-rw-r--r--core/src/public-page-user-menu.ts15
-rw-r--r--core/src/public.ts26
-rw-r--r--core/src/recommendedapps.js29
-rw-r--r--core/src/services/BrowserStorageService.js21
-rw-r--r--core/src/services/BrowsersListService.js27
-rw-r--r--core/src/services/LegacyUnifiedSearchService.js76
-rw-r--r--core/src/services/UnifiedSearchService.js78
-rw-r--r--core/src/services/WebAuthnAuthenticationService.js44
-rw-r--r--core/src/services/WebAuthnAuthenticationService.ts42
-rw-r--r--core/src/session-heartbeat.js185
-rw-r--r--core/src/session-heartbeat.ts158
-rw-r--r--core/src/store/unified-search-external-filters.js17
-rw-r--r--core/src/systemtags/merged-systemtags.js22
-rw-r--r--core/src/systemtags/systemtagmodel.js113
-rw-r--r--core/src/systemtags/systemtags.js26
-rw-r--r--core/src/systemtags/systemtagscollection.js23
-rw-r--r--core/src/systemtags/systemtagsinputfield.js27
-rw-r--r--core/src/systemtags/systemtagsmappingcollection.js24
-rw-r--r--core/src/tests/.eslintrc.js22
-rw-r--r--core/src/tests/OC/requesttoken.spec.js73
-rw-r--r--core/src/tests/OC/requesttoken.spec.ts147
-rw-r--r--core/src/tests/OC/session-heartbeat.spec.ts123
-rw-r--r--core/src/tests/components/ContactsMenu/Contact.spec.js44
-rw-r--r--core/src/tests/views/ContactsMenu.spec.js143
-rw-r--r--core/src/twofactor-request-token.ts25
-rw-r--r--core/src/types/navigation.d.ts30
-rw-r--r--core/src/unified-search.js55
-rw-r--r--core/src/unified-search.ts63
-rw-r--r--core/src/unsupported-browser-redirect.js30
-rw-r--r--core/src/unsupported-browser.js20
-rw-r--r--core/src/utils/ClipboardFallback.ts47
-rw-r--r--core/src/utils/RedirectUnsupportedBrowsers.js20
-rw-r--r--core/src/utils/xhr-request.js133
-rw-r--r--core/src/views/AccountMenu.vue247
-rw-r--r--core/src/views/ContactsMenu.vue325
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue848
-rw-r--r--core/src/views/Login.vue153
-rw-r--r--core/src/views/Profile.vue604
-rw-r--r--core/src/views/PublicPageMenu.vue131
-rw-r--r--core/src/views/PublicPageUserMenu.vue138
-rw-r--r--core/src/views/Setup.cy.ts369
-rw-r--r--core/src/views/Setup.vue460
-rw-r--r--core/src/views/UnifiedSearch.vue922
-rw-r--r--core/src/views/UnsupportedBrowser.vue76
163 files changed, 7952 insertions, 7264 deletions
diff --git a/core/src/OC/admin.js b/core/src/OC/admin.js
index 5c939415266..d29e4cf676b 100644
--- a/core/src/OC/admin.js
+++ b/core/src/OC/admin.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const isAdmin = !!window._oc_isadmin
diff --git a/core/src/OC/appconfig.js b/core/src/OC/appconfig.js
index 4b03f8db5fb..350ffc3f21c 100644
--- a/core/src/OC/appconfig.js
+++ b/core/src/OC/appconfig.js
@@ -1,32 +1,11 @@
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Jörn Friedrich Dreyer <jfd@butonic.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
- import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig'
+ import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig.js'
export const appConfig = window.oc_appconfig || {}
diff --git a/core/src/OC/apps.js b/core/src/OC/apps.js
index bbda177409e..dec2b94bfbb 100644
--- a/core/src/OC/apps.js
+++ b/core/src/OC/apps.js
@@ -1,24 +1,7 @@
/**
- * @copyright Bernhard Posselt 2014
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -90,6 +73,7 @@ export const registerAppsSlideToggle = () => {
})
area.removeClass('opened')
$(button).removeClass('opened')
+ $(button).attr('aria-expanded', 'false')
}
/**
@@ -101,6 +85,7 @@ export const registerAppsSlideToggle = () => {
})
area.addClass('opened')
$(button).addClass('opened')
+ $(button).attr('aria-expanded', 'true')
const input = $(areaSelector + ' [autofocus]')
if (input.length === 1) {
input.focus()
diff --git a/core/src/OC/appsettings.js b/core/src/OC/appsettings.js
deleted file mode 100644
index a81708ca461..00000000000
--- a/core/src/OC/appsettings.js
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-/* eslint-disable */
-import $ from 'jquery'
-import { filePath } from './routing'
-import { generateFilePath } from "@nextcloud/router"
-
-/**
- * Opens a popup with the setting for an app.
- * @param {string} appid The ID of the app e.g. 'calendar', 'contacts' or 'files'.
- * @param {boolean|string} loadJS If true 'js/settings.js' is loaded. If it's a string
- * it will attempt to load a script by that name in the 'js' directory.
- * @param {boolean} [cache] If true the javascript file won't be forced refreshed. Defaults to true.
- * @param {string} [scriptName] The name of the PHP file to load. Defaults to 'settings.php' in
- * the root of the app directory hierarchy.
- *
- * @deprecated 17.0.0 this method is unused and will be removed with Nextcloud 18
- */
-export const appSettings = args => {
- console.warn('OC.appSettings is deprecated and will be removed with Nextcloud 18')
-
- if (typeof args === 'undefined' || typeof args.appid === 'undefined') {
- throw {
- name: 'MissingParameter',
- message: 'The parameter appid is missing'
- }
- }
- var props = { scriptName: 'settings.php', cache: true }
- $.extend(props, args)
- var settings = $('#appsettings')
- if (settings.length === 0) {
- throw {
- name: 'MissingDOMElement',
- message: 'There has be be an element with id "appsettings" for the popup to show.'
- }
- }
- var popup = $('#appsettings_popup')
- if (popup.length === 0) {
- $('body').prepend('<div class="popup hidden" id="appsettings_popup"></div>')
- popup = $('#appsettings_popup')
- popup.addClass(settings.hasClass('topright') ? 'topright' : 'bottomleft')
- }
- if (popup.is(':visible')) {
- popup.hide().remove()
- } else {
- const arrowclass = settings.hasClass('topright') ? 'up' : 'left'
- $.get(generateFilePath(props.appid, '', props.scriptName), function(data) {
- popup.html(data).ready(function() {
- popup.prepend('<span class="arrow ' + arrowclass + '"></span><h2>' + t('core', 'Settings') + '</h2><a class="close"></a>').show()
- popup.find('.close').bind('click', function() {
- popup.remove()
- })
- if (typeof props.loadJS !== 'undefined') {
- var scriptname
- if (props.loadJS === true) {
- scriptname = 'settings.js'
- } else if (typeof props.loadJS === 'string') {
- scriptname = props.loadJS
- } else {
- throw {
- name: 'InvalidParameter',
- message: 'The "loadJS" parameter must be either boolean or a string.'
- }
- }
- if (props.cache) {
- $.ajaxSetup({ cache: true })
- }
- $.getScript(generateFilePath(props.appid, 'js', scriptname))
- .fail(function(jqxhr, settings, e) {
- throw e
- })
- }
- }).show()
- }, 'html')
- }
-}
diff --git a/core/src/OC/appswebroots.js b/core/src/OC/appswebroots.js
index ec2420eeed5..debbd2084bf 100644
--- a/core/src/OC/appswebroots.js
+++ b/core/src/OC/appswebroots.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const appswebroots = (window._oc_appswebroots !== undefined) ? window._oc_appswebroots : false
diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js
index ab234e22005..318c50e8ee5 100644
--- a/core/src/OC/backbone-webdav.js
+++ b/core/src/OC/backbone-webdav.js
@@ -1,25 +1,6 @@
/**
- * Copyright (c) 2015
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
diff --git a/core/src/OC/backbone.js b/core/src/OC/backbone.js
index 17ef1c87109..08520e278f6 100644
--- a/core/src/OC/backbone.js
+++ b/core/src/OC/backbone.js
@@ -1,28 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import VendorBackbone from 'backbone'
-import { davCall, davSync } from './backbone-webdav'
+import { davCall, davSync } from './backbone-webdav.js'
const Backbone = VendorBackbone.noConflict()
diff --git a/core/src/OC/capabilities.js b/core/src/OC/capabilities.js
index c7df9b4f3d1..10623229625 100644
--- a/core/src/OC/capabilities.js
+++ b/core/src/OC/capabilities.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities'
@@ -32,6 +13,6 @@ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities'
* @since 14.0.0
*/
export const getCapabilities = () => {
- console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities')
+ OC.debug && console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities')
return realGetCapabilities()
}
diff --git a/core/src/OC/config.js b/core/src/OC/config.js
index 702105a4836..c47df61f6e6 100644
--- a/core/src/OC/config.js
+++ b/core/src/OC/config.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const config = window._oc_config || {}
diff --git a/core/src/OC/constants.js b/core/src/OC/constants.js
index f2ba7bf7a97..5298107e94d 100644
--- a/core/src/OC/constants.js
+++ b/core/src/OC/constants.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const coreApps = ['', 'admin', 'log', 'core/search', 'core', '3rdparty']
diff --git a/core/src/OC/contactsmenu.js b/core/src/OC/contactsmenu.js
deleted file mode 100644
index b9f4b0fc064..00000000000
--- a/core/src/OC/contactsmenu.js
+++ /dev/null
@@ -1,468 +0,0 @@
-/**
- * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-/* eslint-disable */
-import _ from 'underscore'
-import $ from 'jquery'
-import { Collection, Model, View } from 'backbone'
-
-import OC from './index'
-
-/**
- * @class Contact
- */
-const Contact = Model.extend({
- defaults: {
- fullName: '',
- lastMessage: '',
- actions: [],
- hasOneAction: false,
- hasTwoActions: false,
- hasManyActions: false
- },
-
- /**
- * @returns {undefined}
- */
- initialize: function() {
- // Add needed property for easier template rendering
- if (this.get('actions').length === 0) {
- this.set('hasOneAction', true)
- } else if (this.get('actions').length === 1) {
- this.set('hasTwoActions', true)
- this.set('secondAction', this.get('actions')[0])
- } else {
- this.set('hasManyActions', true)
- }
- }
-})
-
-/**
- * @class ContactCollection
- * @private
- */
-const ContactCollection = Collection.extend({
- model: Contact
-})
-
-/**
- * @class ContactsListView
- * @private
- */
-const ContactsListView = View.extend({
-
- /** @type {ContactCollection} */
- _collection: undefined,
-
- /** @type {array} */
- _subViews: [],
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this._collection = options.collection
- },
-
- /**
- * @returns {self}
- */
- render: function() {
- var self = this
- self.$el.html('')
- self._subViews = []
-
- self._collection.forEach(function(contact) {
- var item = new ContactsListItemView({
- model: contact
- })
- item.render()
- self.$el.append(item.$el)
- item.on('toggle:actionmenu', self._onChildActionMenuToggle, self)
- self._subViews.push(item)
- })
-
- return self
- },
-
- /**
- * Event callback to propagate opening (another) entry's action menu
- *
- * @param {type} $src
- * @returns {undefined}
- */
- _onChildActionMenuToggle: function($src) {
- this._subViews.forEach(function(view) {
- view.trigger('parent:toggle:actionmenu', $src)
- })
- }
-})
-
-/**
- * @class ContactsListItemView
- * @private
- */
-const ContactsListItemView = View.extend({
-
- /** @type {string} */
- className: 'contact',
-
- /** @type {undefined|function} */
- _template: undefined,
-
- /** @type {Contact} */
- _model: undefined,
-
- /** @type {boolean} */
- _actionMenuShown: false,
-
- events: {
- 'click .icon-more': '_onToggleActionsMenu'
- },
-
- contactTemplate: require('./contactsmenu/contact.handlebars'),
-
- /**
- * @param {object} data
- * @returns {undefined}
- */
- template: function(data) {
- return this.contactTemplate(data)
- },
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this._model = options.model
- this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this)
- },
-
- /**
- * @returns {self}
- */
- render: function() {
- this.$el.html(this.template({
- contact: this._model.toJSON()
- }))
- this.delegateEvents()
-
- // Show placeholder if no avatar is available (avatar is rendered as img, not div)
- this.$('div.avatar').imageplaceholder(this._model.get('fullName'))
-
- // Show tooltip for top action
- this.$('.top-action').tooltip({ placement: 'left' })
- // Show tooltip for second action
- this.$('.second-action').tooltip({ placement: 'left' })
-
- return this
- },
-
- /**
- * Toggle the visibility of the action popover menu
- *
- * @private
- * @returns {undefined}
- */
- _onToggleActionsMenu: function() {
- this._actionMenuShown = !this._actionMenuShown
- if (this._actionMenuShown) {
- this.$('.menu').show()
- } else {
- this.$('.menu').hide()
- }
- this.trigger('toggle:actionmenu', this.$el)
- },
-
- /**
- * @private
- * @argument {jQuery} $src
- * @returns {undefined}
- */
- _onOtherActionMenuOpened: function($src) {
- if (this.$el.is($src)) {
- // Ignore
- return
- }
- this._actionMenuShown = false
- this.$('.menu').hide()
- }
-})
-
-/**
- * @class ContactsMenuView
- * @private
- */
-const ContactsMenuView = View.extend({
-
- /** @type {undefined|function} */
- _loadingTemplate: undefined,
-
- /** @type {undefined|function} */
- _errorTemplate: undefined,
-
- /** @type {undefined|function} */
- _contentTemplate: undefined,
-
- /** @type {undefined|function} */
- _contactsTemplate: undefined,
-
- /** @type {undefined|ContactCollection} */
- _contacts: undefined,
-
- /** @type {string} */
- _searchTerm: '',
-
- events: {
- 'input #contactsmenu-search': '_onSearch'
- },
-
- templates: {
- loading: require('./contactsmenu/loading.handlebars'),
- error: require('./contactsmenu/error.handlebars'),
- menu: require('./contactsmenu/menu.handlebars'),
- list: require('./contactsmenu/list.handlebars')
- },
-
- /**
- * @returns {undefined}
- */
- _onSearch: _.debounce(function(e) {
- var searchTerm = this.$('#contactsmenu-search').val()
- // IE11 triggers an 'input' event after the view has been rendered
- // resulting in an endless loading loop. To prevent this, we remember
- // the last search term to savely ignore some events
- // See https://github.com/nextcloud/server/issues/5281
- if (searchTerm !== this._searchTerm) {
- this.trigger('search', this.$('#contactsmenu-search').val())
- this._searchTerm = searchTerm
- }
- }, 700),
-
- /**
- * @param {object} data
- * @returns {string}
- */
- loadingTemplate: function(data) {
- return this.templates.loading(data)
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- errorTemplate: function(data) {
- return this.templates.error(
- _.extend({
- couldNotLoadText: t('core', 'Could not load your contacts')
- }, data)
- )
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- contentTemplate: function(data) {
- return this.templates.menu(
- _.extend({
- searchContactsText: t('core', 'Search contacts …')
- }, data)
- )
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- contactsTemplate: function(data) {
- return this.templates.list(
- _.extend({
- noContactsFoundText: t('core', 'No contacts found'),
- showAllContactsText: t('core', 'Show all contacts …'),
- contactsAppMgmtText: t('core', 'Install the Contacts app')
- }, data)
- )
- },
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this.options = options
- },
-
- /**
- * @param {string} text
- * @returns {undefined}
- */
- showLoading: function(text) {
- this.render()
- this._contacts = undefined
- this.$('.content').html(this.loadingTemplate({
- loadingText: text
- }))
- },
-
- /**
- * @returns {undefined}
- */
- showError: function() {
- this.render()
- this._contacts = undefined
- this.$('.content').html(this.errorTemplate())
- },
-
- /**
- * @param {object} viewData
- * @param {string} searchTerm
- * @returns {undefined}
- */
- showContacts: function(viewData, searchTerm) {
- this._contacts = viewData.contacts
- this.render({
- contacts: viewData.contacts
- })
-
- var list = new ContactsListView({
- collection: viewData.contacts
- })
- list.render()
- this.$('.content').html(this.contactsTemplate({
- contacts: viewData.contacts,
- searchTerm: searchTerm,
- contactsAppEnabled: viewData.contactsAppEnabled,
- contactsAppURL: OC.generateUrl('/apps/contacts'),
- canInstallApp: OC.isUserAdmin(),
- contactsAppMgmtURL: OC.generateUrl('/settings/apps/social/contacts')
- }))
- this.$('#contactsmenu-contacts').html(list.$el)
- },
-
- /**
- * @param {object} data
- * @returns {self}
- */
- render: function(data) {
- var searchVal = this.$('#contactsmenu-search').val()
- this.$el.html(this.contentTemplate(data))
-
- // Focus search
- this.$('#contactsmenu-search').val(searchVal)
- this.$('#contactsmenu-search').focus()
- return this
- }
-
-})
-
-/**
- * @param {Object} options
- * @param {string} options.el
- * @class ContactsMenu
- * @memberOf OC
- */
-const ContactsMenu = function(options) {
- this.initialize(options)
-}
-
-ContactsMenu.prototype = {
- /** @type {string} */
- $el: undefined,
-
- /** @type {ContactsMenuView} */
- _view: undefined,
-
- /** @type {Promise} */
- _contactsPromise: undefined,
-
- /**
- * @param {Object} options
- * @param {string} options.el - the selector of the element to render the menu in
- * @returns {undefined}
- */
- initialize: function(options) {
- this.$el = $(options.el)
-
- this._view = new ContactsMenuView({
- el: this.$el,
- })
-
- this._view.on('search', function(searchTerm) {
- this.loadContacts(searchTerm)
- }, this)
- },
-
- /**
- * @private
- * @param {string|undefined} searchTerm
- * @returns {Promise}
- */
- _getContacts: function(searchTerm) {
- var url = OC.generateUrl('/contactsmenu/contacts')
- return Promise.resolve($.ajax(url, {
- method: 'POST',
- data: {
- filter: searchTerm
- }
- }))
- },
-
- /**
- * @param {string|undefined} searchTerm
- * @returns {undefined}
- */
- loadContacts: function(searchTerm) {
- var self = this
-
- if (!self._contactsPromise) {
- self._contactsPromise = self._getContacts(searchTerm)
- }
-
- if (_.isUndefined(searchTerm) || searchTerm === '') {
- self._view.showLoading(t('core', 'Loading your contacts …'))
- } else {
- self._view.showLoading(t('core', 'Looking for {term} …', {
- term: searchTerm
- }))
- }
- return self._contactsPromise.then(function(data) {
- // Convert contact entries to Backbone collection
- data.contacts = new ContactCollection(data.contacts)
-
- self._view.showContacts(data, searchTerm)
- }, function(e) {
- self._view.showError()
- console.error('There was an error loading your contacts', e)
- }).then(function() {
- // Delete promise, so that contacts are fetched again when the
- // menu is opened the next time.
- delete self._contactsPromise
- }).catch(console.error.bind(this))
- }
-}
-
-export default ContactsMenu
diff --git a/core/src/OC/contactsmenu/contact.handlebars b/core/src/OC/contactsmenu/contact.handlebars
deleted file mode 100644
index afb2f627663..00000000000
--- a/core/src/OC/contactsmenu/contact.handlebars
+++ /dev/null
@@ -1,70 +0,0 @@
-{{#if contact.avatar}}
- {{#if contact.profileUrl}}
- {{#if contact.profileTitle}}
- <a class="profile-link--avatar" href="{{contact.profileUrl}}">
- <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
- </a>
- {{/if}}
- {{else}}
- <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
- {{/if}}
-{{else}}
- {{#if contact.profileUrl}}
- {{#if contact.profileTitle}}
- <a class="profile-link--avatar" href="{{contact.profileUrl}}">
- <div class="avatar"></div>
- </a>
- {{/if}}
- {{else}}
- <div class="avatar"></div>
- {{/if}}
-{{/if}}
-{{#if contact.profileUrl}}
- {{#if contact.profileTitle}}
- <a class="body profile-link--full-name" href="{{contact.profileUrl}}">
- <div class="full-name">{{contact.fullName}}</div>
- <div class="last-message">{{contact.lastMessage}}</div>
- <div class="email-address">{{contact.emailAddresses}}</div>
- </a>
- {{/if}}
- {{#if contact.topAction}}
- <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}">
- <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}">
- </a>
- {{/if}}
-{{else if contact.topAction}}
- <a class="body" href="{{contact.topAction.hyperlink}}">
- <div class="full-name">{{contact.fullName}}</div>
- <div class="last-message">{{contact.lastMessage}}</div>
- <div class="email-address">{{contact.emailAddresses}}</div>
- </a>
- <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}">
- <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}">
- </a>
-{{else}}
- <div class="body">
- <div class="full-name">{{contact.fullName}}</div>
- <div class="last-message">{{contact.lastMessage}}</div>
- <div class="email-address">{{contact.emailAddresses}}</div>
- </div>
-{{/if}}
-{{#if contact.hasTwoActions}}
-<a class="second-action" href="{{contact.secondAction.hyperlink}}" title="{{contact.secondAction.title}}">
- <img src="{{contact.secondAction.icon}}" alt="{{contact.secondAction.title}}">
-</a>
-{{/if}}
-{{#if contact.hasManyActions}}
- <button class="other-actions icon-more"></button>
- <div class="menu popovermenu">
- <ul>
- {{#each contact.actions}}
- <li>
- <a href="{{hyperlink}}">
- <img src="{{icon}}" alt="">
- <span>{{title}}</span>
- </a>
- </li>
- {{/each}}
- </ul>
- </div>
-{{/if}}
diff --git a/core/src/OC/contactsmenu/error.handlebars b/core/src/OC/contactsmenu/error.handlebars
deleted file mode 100644
index 5115595b4e1..00000000000
--- a/core/src/OC/contactsmenu/error.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<div class="emptycontent">
- <div class="icon-search"></div>
- <h2>{{couldNotLoadText}}</h2>
-</div>
diff --git a/core/src/OC/contactsmenu/list.handlebars b/core/src/OC/contactsmenu/list.handlebars
deleted file mode 100644
index 0bcff7d1a85..00000000000
--- a/core/src/OC/contactsmenu/list.handlebars
+++ /dev/null
@@ -1,12 +0,0 @@
-{{#unless contacts.length}}
-<div class="emptycontent">
- <div class="icon-search"></div>
- <h2>{{noContactsFoundText}}</h2>
-</div>
-{{/unless}}
-<div id="contactsmenu-contacts"></div>
-{{#if contactsAppEnabled}}
-<div class="footer"><a href="{{contactsAppURL}}">{{showAllContactsText}}</a></div>
-{{else if canInstallApp}}
-<div class="footer"><a href="{{contactsAppMgmtURL}}">{{contactsAppMgmtText}}</a></div>
-{{/if}}
diff --git a/core/src/OC/contactsmenu/loading.handlebars b/core/src/OC/contactsmenu/loading.handlebars
deleted file mode 100644
index 7fb22a6ed8e..00000000000
--- a/core/src/OC/contactsmenu/loading.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<div class="emptycontent">
- <div class="icon-loading"></div>
- <h2>{{loadingText}}</h2>
-</div>
diff --git a/core/src/OC/contactsmenu/menu.handlebars b/core/src/OC/contactsmenu/menu.handlebars
deleted file mode 100644
index 7d7697e780c..00000000000
--- a/core/src/OC/contactsmenu/menu.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<label class="hidden-visually" for="contactsmenu-search">{{searchContactsText}}</label>
-<input id="contactsmenu-search" type="search" placeholder="{{searchContactsText}}" value="{{searchTerm}}">
-<div class="content">
-</div>
diff --git a/core/src/OC/currentuser.js b/core/src/OC/currentuser.js
index c6e8a8ee62c..a022698eab0 100644
--- a/core/src/OC/currentuser.js
+++ b/core/src/OC/currentuser.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const rawUid = document
diff --git a/core/src/OC/debug.js b/core/src/OC/debug.js
index 25a6eae9597..52a9ef28145 100644
--- a/core/src/OC/debug.js
+++ b/core/src/OC/debug.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const base = window._oc_debug
diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js
index 286f9848290..5c6934e67a2 100644
--- a/core/src/OC/dialogs.js
+++ b/core/src/OC/dialogs.js
@@ -1,79 +1,51 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- *
- * @author Bartek Przybylski <bart.p.pl@gmail.com>
- * @author Christopher Schäpers <kondou@ts.unde.re>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Florian Schunk <florian.schunk@rwth-aachen.de>
- * @author Gary Kim <gary@garykim.dev>
- * @author Hendrik Leppelsack <hendrik@leppelsack.de>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Jörn Friedrich Dreyer <jfd@butonic.de>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Loïc Hermann <loic.hermann@sciam.fr>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Olivier Paroz <github@oparoz.com>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Sujith Haridasan <Sujith_Haridasan@mentor.com>
- * @author Thomas Citharel <nextcloud@tcit.fr>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Thomas Tanghus <thomas@tanghus.net>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2015 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
import _ from 'underscore'
import $ from 'jquery'
-import OC from './index'
-import OCA from '../OCA/index'
-import { isA11yActivation } from '../Util/a11y'
+import IconMove from '@mdi/svg/svg/folder-move.svg?raw'
+import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
+
+import OC from './index.js'
+import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { basename } from 'path'
+import { defineAsyncComponent } from 'vue'
/**
* this class to ease the usage of jquery dialogs
*/
const Dialogs = {
// dialog button types
+ /** @deprecated use `@nextcloud/dialogs` */
YES_NO_BUTTONS: 70,
+ /** @deprecated use `@nextcloud/dialogs` */
OK_BUTTONS: 71,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_CHOOSE: 1,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_MOVE: 2,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_COPY: 3,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_COPY_MOVE: 4,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_CUSTOM: 5,
- // used to name each dialog
- dialogsCounter: 0,
-
/**
* displays alert dialog
* @param {string} text content of dialog
* @param {string} title dialog title
* @param {function} callback which will be triggered when user presses OK
* @param {boolean} [modal] make the dialog modal
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
alert: function(text, title, callback, modal) {
this.message(
@@ -85,12 +57,15 @@ const Dialogs = {
modal
)
},
+
/**
* displays info dialog
* @param {string} text content of dialog
* @param {string} title dialog title
* @param {function} callback which will be triggered when user presses OK
* @param {boolean} [modal] make the dialog modal
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
info: function(text, title, callback, modal) {
this.message(text, title, 'info', Dialogs.OK_BUTTON, callback, modal)
@@ -103,6 +78,8 @@ const Dialogs = {
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
confirm: function(text, title, callback, modal) {
return this.message(
@@ -122,16 +99,34 @@ const Dialogs = {
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
- confirmDestructive: function(text, title, buttons, callback, modal) {
- return this.message(
- text,
- title,
- 'none',
- buttons,
- callback,
- modal === undefined ? true : modal
- )
+ confirmDestructive: function(text, title, buttons = Dialogs.OK_BUTTONS, callback = () => {}, modal) {
+ return (new DialogBuilder())
+ .setName(title)
+ .setText(text)
+ .setButtons(
+ buttons === Dialogs.OK_BUTTONS
+ ? [
+ {
+ label: t('core', 'Yes'),
+ type: 'error',
+ callback: () => {
+ callback.clicked = true
+ callback(true)
+ },
+ }
+ ]
+ : Dialogs._getLegacyButtons(buttons, callback)
+ )
+ .build()
+ .show()
+ .then(() => {
+ if (!callback.clicked) {
+ callback(false)
+ }
+ })
},
/**
* displays confirmation dialog
@@ -140,17 +135,35 @@ const Dialogs = {
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
confirmHtml: function(text, title, callback, modal) {
- return this.message(
- text,
- title,
- 'notice',
- Dialogs.YES_NO_BUTTONS,
- callback,
- modal,
- true
- )
+ return (new DialogBuilder())
+ .setName(title)
+ .setText('')
+ .setButtons([
+ {
+ label: t('core', 'No'),
+ callback: () => {},
+ },
+ {
+ label: t('core', 'Yes'),
+ type: 'primary',
+ callback: () => {
+ callback.clicked = true
+ callback(true)
+ },
+ },
+ ])
+ .build()
+ .setHTML(text)
+ .show()
+ .then(() => {
+ if (!callback.clicked) {
+ callback(false)
+ }
+ })
},
/**
* displays prompt dialog
@@ -161,73 +174,32 @@ const Dialogs = {
* @param {string} name name of the input field
* @param {boolean} password whether the input should be a password input
* @returns {Promise}
+ *
+ * @deprecated Use NcDialog from `@nextcloud/vue` instead
*/
prompt: function(text, title, callback, modal, name, password) {
- return $.when(this._getMessageTemplate()).then(function($tmpl) {
- var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content'
- var dialogId = '#' + dialogName
- var $dlg = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- message: text,
- type: 'notice'
- })
- var input = $('<input/>')
- input.attr('type', password ? 'password' : 'text').attr('id', dialogName + '-input').attr('placeholder', name)
- var label = $('<label/>').attr('for', dialogName + '-input').text(name + ': ')
- $dlg.append(label)
- $dlg.append(input)
- if (modal === undefined) {
- modal = false
- }
- $('body').append($dlg)
-
- // wrap callback in _.once():
- // only call callback once and not twice (button handler and close
- // event) but call it for the close event, if ESC or the x is hit
- if (callback !== undefined) {
- callback = _.once(callback)
- }
-
- var buttonlist = [{
- text: t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(false, input.val())
- }
- $(dialogId).ocdialog('close')
- }
- }, {
- text: t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(true, input.val())
- }
- $(dialogId).ocdialog('close')
+ return new Promise((resolve) => {
+ spawnDialog(
+ defineAsyncComponent(() => import('../components/LegacyDialogPrompt.vue')),
+ {
+ text,
+ name: title,
+ callback,
+ inputName: name,
+ isPassword: !!password
},
- defaultButton: true
- }]
-
- $(dialogId).ocdialog({
- closeOnEscape: true,
- modal: modal,
- buttons: buttonlist,
- close: function() {
- // callback is already fired if Yes/No is clicked directly
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(false, input.val())
- }
- }
- })
- input.focus()
- Dialogs.dialogsCounter++
+ (...args) => {
+ callback(...args)
+ resolve()
+ },
+ )
})
},
+
/**
- * show a file picker to pick a file from
+ * Legacy wrapper to the new Vue based filepicker from `@nextcloud/dialogs`
+ *
+ * Prefer to use the Vue filepicker directly instead.
*
* In order to pick several types of mime types they need to be passed as an
* array of strings.
@@ -237,441 +209,196 @@ const Dialogs = {
* should be used instead.
*
* @param {string} title dialog title
- * @param {function} callback which will be triggered when user presses Choose
+ * @param {Function} callback which will be triggered when user presses Choose
* @param {boolean} [multiselect] whether it should be possible to select multiple files
- * @param {string[]} [mimetypeFilter] mimetype to filter by - directories will always be included
- * @param {boolean} [modal] make the dialog modal
+ * @param {string[]} [mimetype] mimetype to filter by - directories will always be included
+ * @param {boolean} [_modal] do not use
* @param {string} [type] Type of file picker : Choose, copy, move, copy and move
* @param {string} [path] path to the folder that the the file can be picket from
- * @param {Object} [options] additonal options that need to be set
+ * @param {object} [options] additonal options that need to be set
* @param {Function} [options.filter] filter function for advanced filtering
+ * @param {boolean} [options.allowDirectoryChooser] Allow to select directories
+ * @deprecated since 27.1.0 use the filepicker from `@nextcloud/dialogs` instead
*/
- filepicker: function(title, callback, multiselect, mimetypeFilter, modal, type, path, options) {
- var self = this
-
- this.filepicker.sortField = 'name'
- this.filepicker.sortOrder = 'asc'
- // avoid opening the picker twice
- if (this.filepicker.loading) {
- return
- }
+ filepicker(title, callback, multiselect = false, mimetype = undefined, _modal = undefined, type = FilePickerType.Choose, path = undefined, options = undefined) {
- if (type === undefined) {
- type = this.FILEPICKER_TYPE_CHOOSE
- }
-
- var emptyText = t('core', 'No files in here')
- var newText = t('files', 'New folder')
- if (type === this.FILEPICKER_TYPE_COPY || type === this.FILEPICKER_TYPE_MOVE || type === this.FILEPICKER_TYPE_COPY_MOVE) {
- emptyText = t('core', 'No more subfolders in here')
- }
-
- this.filepicker.loading = true
- this.filepicker.filesClient = (OCA.Sharing && OCA.Sharing.PublicApp && OCA.Sharing.PublicApp.fileList) ? OCA.Sharing.PublicApp.fileList.filesClient : OC.Files.getClient()
-
- this.filelist = null
- path = path || ''
- options = Object.assign({
- allowDirectoryChooser: false
- }, options)
-
- $.when(this._getFilePickerTemplate()).then(function($tmpl) {
- self.filepicker.loading = false
- var dialogName = 'oc-dialog-filepicker-content'
- if (self.$filePicker) {
- self.$filePicker.ocdialog('close')
- }
-
- if (mimetypeFilter === undefined || mimetypeFilter === null) {
- mimetypeFilter = []
- }
- if (typeof (mimetypeFilter) === 'string') {
- mimetypeFilter = [mimetypeFilter]
- }
-
- self.$filePicker = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- emptytext: emptyText,
- newtext: newText,
- nameCol: t('core', 'Name'),
- sizeCol: t('core', 'Size'),
- modifiedCol: t('core', 'Modified')
- }).data('path', path).data('multiselect', multiselect).data('mimetype', mimetypeFilter).data('allowDirectoryChooser', options.allowDirectoryChooser)
- if (typeof(options.filter) === 'function') {
- self.$filePicker.data('filter', options.filter)
+ /**
+ * Create legacy callback wrapper to support old filepicker syntax
+ * @param fn The original callback
+ * @param type The file picker type which was used to pick the file(s)
+ */
+ const legacyCallback = (fn, type) => {
+ const getPath = (node) => {
+ const root = node?.root || ''
+ let path = node?.path || ''
+ // TODO: Fix this in @nextcloud/files
+ if (path.startsWith(root)) {
+ path = path.slice(root.length) || '/'
+ }
+ return path
}
- if (modal === undefined) {
- modal = false
- }
- if (multiselect === undefined) {
- multiselect = false
+ if (multiselect) {
+ return (nodes) => fn(nodes.map(getPath), type)
+ } else {
+ return (nodes) => fn(getPath(nodes[0]), type)
}
+ }
- $('body').prepend(self.$filePicker)
-
- self.$showGridView = $('button#picker-showgridview')
- self.$showGridView.on('click keydown', function(event) {
- if (isA11yActivation(event)) {
- self._onGridviewChange()
- }
- })
- self._getGridSettings()
+ /**
+ * Coverting a Node into a legacy file info to support the OC.dialogs.filepicker filter function
+ * @param node The node to convert
+ */
+ const nodeToLegacyFile = (node) => ({
+ id: node.fileid || null,
+ path: node.path,
+ mimetype: node.mime || null,
+ mtime: node.mtime?.getTime() || null,
+ permissions: node.permissions,
+ name: node.attributes?.displayName || node.basename,
+ etag: node.attributes?.etag || null,
+ hasPreview: node.attributes?.hasPreview || null,
+ mountType: node.attributes?.mountType || null,
+ quotaAvailableBytes: node.attributes?.quotaAvailableBytes || null,
+ icon: null,
+ sharePermissions: null,
+ })
- var newButton = self.$filePicker.find('.actions.creatable .button-add')
- if (type === self.FILEPICKER_TYPE_CHOOSE && !options.allowDirectoryChooser) {
- self.$filePicker.find('.actions.creatable').hide()
- }
- newButton.on('focus', function() {
- self.$filePicker.ocdialog('setEnterCallback', function(event) {
- event.stopImmediatePropagation()
- event.preventDefault()
- newButton.click()
- })
- })
- newButton.on('blur', function() {
- self.$filePicker.ocdialog('unsetEnterCallback')
- })
+ const builder = getFilePickerBuilder(title)
- OC.registerMenu(newButton, self.$filePicker.find('.menu'), function() {
- $input.tooltip('hide')
- $input.focus()
- self.$filePicker.ocdialog('setEnterCallback', function(event) {
- event.stopImmediatePropagation()
- event.preventDefault()
- self.$filePicker.submit()
+ // Setup buttons
+ if (type === this.FILEPICKER_TYPE_CUSTOM) {
+ (options.buttons || []).forEach((button) => {
+ builder.addButton({
+ callback: legacyCallback(callback, button.type),
+ label: button.text,
+ type: button.defaultButton ? 'primary' : 'secondary',
})
- var newName = $input.val()
- var lastPos = newName.lastIndexOf('.')
- if (lastPos === -1) {
- lastPos = newName.length
- }
- $input.selectRange(0, lastPos)
})
- var $form = self.$filePicker.find('.filenameform')
- var $input = $form.find('input[type=\'text\']')
- var $submit = $form.find('input[type=\'submit\']')
- $input.on('keydown', function(event) {
- if (isA11yActivation(event)) {
- event.stopImmediatePropagation()
- event.preventDefault()
- $form.submit()
- }
- })
- $submit.on('click', function(event) {
- event.stopImmediatePropagation()
- event.preventDefault()
- $form.submit()
- })
-
- /**
- * Checks whether the given file name is valid.
- *
- * @param name file name to check
- * @return true if the file name is valid.
- * @throws a string exception with an error message if
- * the file name is not valid
- *
- * NOTE: This function is duplicated in the files app:
- * https://github.com/nextcloud/server/blob/b9bc2417e7a8dc81feb0abe20359bedaf864f790/apps/files/js/files.js#L127-L148
- */
- var isFileNameValid = function (name) {
- var trimmedName = name.trim();
- if (trimmedName === '.' || trimmedName === '..')
- {
- throw t('files', '"{name}" is an invalid file name.', {name: name})
- } else if (trimmedName.length === 0) {
- throw t('files', 'File name cannot be empty.')
- } else if (trimmedName.indexOf('/') !== -1) {
- throw t('files', '"/" is not allowed inside a file name.')
- } else if (!!(trimmedName.match(OC.config.blacklist_files_regex))) {
- throw t('files', '"{name}" is not an allowed filetype', {name: name})
+ } else {
+ builder.setButtonFactory((nodes, path) => {
+ const buttons = []
+ const [node] = nodes
+ const target = node?.displayname || node?.basename || basename(path)
+
+ if (type === FilePickerType.Choose) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Choose),
+ label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'),
+ type: 'primary',
+ })
}
-
- return true
- }
-
- var checkInput = function() {
- var filename = $input.val()
- try {
- if (!isFileNameValid(filename)) {
- // isFileNameValid(filename) throws an exception itself
- } else if (self.filelist.find(function(file) {
- return file.name === this
- }, filename)) {
- throw t('files', '{newName} already exists', { newName: filename }, undefined, {
- escape: false
- })
- } else {
- return true
- }
- } catch (error) {
- $input.attr('title', error)
- $input.tooltip({
- placement: 'right',
- trigger: 'manual',
- 'container': '.newFolderMenu'
+ if (type === FilePickerType.CopyMove || type === FilePickerType.Copy) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Copy),
+ label: target ? t('core', 'Copy to {target}', { target }) : t('core', 'Copy'),
+ type: 'primary',
+ icon: IconCopy,
})
- $input.tooltip('_fixTitle')
- $input.tooltip('show')
- $input.addClass('error')
}
- return false
- }
-
- $form.on('submit', function(event) {
- event.stopPropagation()
- event.preventDefault()
-
- if (checkInput()) {
- var newname = $input.val()
- self.filepicker.filesClient.createDirectory(self.$filePicker.data('path') + "/" + newname).always(function (status) {
- self._fillFilePicker(self.$filePicker.data('path') + "/" + newname)
+ if (type === FilePickerType.Move || type === FilePickerType.CopyMove) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Move),
+ label: target ? t('core', 'Move to {target}', { target }) : t('core', 'Move'),
+ type: type === FilePickerType.Move ? 'primary' : 'secondary',
+ icon: IconMove,
})
- OC.hideMenus()
- self.$filePicker.ocdialog('unsetEnterCallback')
- self.$filePicker.click()
- $input.val(newText)
}
+ return buttons
})
- $input.on('input', function(event) {
- $input.tooltip('hide')
- })
-
- self.$filePicker.ready(function() {
- self.$fileListHeader = self.$filePicker.find('.filelist thead tr')
- self.$filelist = self.$filePicker.find('.filelist tbody')
- self.$filelistContainer = self.$filePicker.find('.filelist-container')
- self.$dirTree = self.$filePicker.find('.dirtree')
- self.$dirTree.on('click keydown', 'div:not(:last-child)', self, function(event) {
- if (isA11yActivation(event)) {
- self._handleTreeListSelect(event, type)
- }
- })
- self.$filelist.on('click keydown', 'tr', function(event) {
- if (isA11yActivation(event)) {
- self._handlePickerClick(event, $(this), type)
- }
- })
- self.$fileListHeader.on('click keydown', 'a', function(event) {
- if (isA11yActivation(event)) {
- var dir = self.$filePicker.data('path')
- self.filepicker.sortField = $(event.currentTarget).data('sort')
- self.filepicker.sortOrder = self.filepicker.sortOrder === 'asc' ? 'desc' : 'asc'
- self._fillFilePicker(dir)
- }
- })
- self._fillFilePicker(path)
- })
-
- // build buttons
- var functionToCall = function(returnType) {
- if (callback !== undefined) {
- var datapath
- if (multiselect === true) {
- datapath = []
- self.$filelist.find('tr.filepicker_element_selected').each(function(index, element) {
- datapath.push(self.$filePicker.data('path') + '/' + $(element).data('entryname'))
- })
- } else {
- datapath = self.$filePicker.data('path')
- var selectedName = self.$filelist.find('tr.filepicker_element_selected').data('entryname')
- if (selectedName) {
- datapath += '/' + selectedName
- }
- }
- callback(datapath, returnType)
- self.$filePicker.ocdialog('close')
- }
- }
-
- var chooseCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_CHOOSE)
- }
-
- var copyCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_COPY)
- }
+ }
- var moveCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_MOVE)
- }
+ if (mimetype) {
+ builder.setMimeTypeFilter(typeof mimetype === 'string' ? [mimetype] : (mimetype || []))
+ }
+ if (typeof options?.filter === 'function') {
+ builder.setFilter((node) => options.filter(nodeToLegacyFile(node)))
+ }
+ builder.allowDirectories(options?.allowDirectoryChooser === true || mimetype?.includes('httpd/unix-directory') || false)
+ .setMultiSelect(multiselect)
+ .startAt(path)
+ .build()
+ .pick()
+ },
- var buttonlist = []
- if (type === Dialogs.FILEPICKER_TYPE_CHOOSE) {
- buttonlist.push({
- text: t('core', 'Choose'),
- click: chooseCallback,
- defaultButton: true
- })
- } else if (type === Dialogs.FILEPICKER_TYPE_CUSTOM) {
- options.buttons.forEach(function(button) {
- buttonlist.push({
- text: button.text,
- click: function() {
- functionToCall(button.type)
- },
- defaultButton: button.defaultButton
- })
- })
- } else {
- if (type === Dialogs.FILEPICKER_TYPE_COPY || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) {
- buttonlist.push({
- text: t('core', 'Copy'),
- click: copyCallback,
- defaultButton: false
- })
- }
- if (type === Dialogs.FILEPICKER_TYPE_MOVE || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) {
- buttonlist.push({
- text: t('core', 'Move'),
- click: moveCallback,
- defaultButton: true
- })
- }
- }
+ /**
+ * Displays raw dialog
+ * You better use a wrapper instead ...
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
+ */
+ message: function(content, title, dialogType, buttons, callback = () => {}, modal, allowHtml) {
+ const builder = (new DialogBuilder())
+ .setName(title)
+ .setText(allowHtml ? '' : content)
+ .setButtons(Dialogs._getLegacyButtons(buttons, callback))
+
+ switch (dialogType) {
+ case 'alert':
+ builder.setSeverity('warning')
+ break
+ case 'notice':
+ builder.setSeverity('info')
+ break
+ default:
+ break
+ }
- self.$filePicker.ocdialog({
- closeOnEscape: true,
- // max-width of 600
- width: 600,
- height: 500,
- modal: modal,
- buttons: buttonlist,
- style: {
- buttons: 'aside'
- },
- close: function() {
- try {
- $(this).ocdialog('destroy').remove()
- } catch (e) {
- }
- self.$filePicker = null
- }
- })
+ const dialog = builder.build()
+
+ if (allowHtml) {
+ dialog.setHTML(content)
+ }
- // We can access primary class only from oc-dialog.
- // Hence this is one of the approach to get the choose button.
- var getOcDialog = self.$filePicker.closest('.oc-dialog')
- var buttonEnableDisable = getOcDialog.find('.primary')
- if (self.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || self.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
+ return dialog.show().then(() => {
+ if(!callback._clicked) {
+ callback(false)
}
})
- .fail(function(status, error) {
- // If the method is called while navigating away
- // from the page, it is probably not needed ;)
- self.filepicker.loading = false
- if (status !== 0) {
- alert(t('core', 'Error loading file picker template: {error}', { error: error }))
- }
- })
},
+
/**
- * Displays raw dialog
- * You better use a wrapper instead ...
+ * Helper for legacy API
+ * @deprecated
*/
- message: function(content, title, dialogType, buttons, callback, modal, allowHtml) {
- return $.when(this._getMessageTemplate()).then(function($tmpl) {
- var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content'
- var dialogId = '#' + dialogName
- var $dlg = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- message: content,
- type: dialogType
- }, allowHtml ? { escapeFunction: '' } : {})
- if (modal === undefined) {
- modal = false
- }
- $('body').append($dlg)
- var buttonlist = []
- switch (buttons) {
+ _getLegacyButtons(buttons, callback) {
+ const buttonList = []
+
+ switch (typeof buttons === 'object' ? buttons.type : buttons) {
case Dialogs.YES_NO_BUTTONS:
- buttonlist = [{
- text: t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- callback(false)
- }
- $(dialogId).ocdialog('close')
- }
- },
- {
- text: t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- callback(true)
- }
- $(dialogId).ocdialog('close')
+ buttonList.push({
+ label: buttons?.cancel ?? t('core', 'No'),
+ callback: () => {
+ callback._clicked = true
+ callback(false)
},
- defaultButton: true
- }]
+ })
+ buttonList.push({
+ label: buttons?.confirm ?? t('core', 'Yes'),
+ type: 'primary',
+ callback: () => {
+ callback._clicked = true
+ callback(true)
+ },
+ })
break
- case Dialogs.OK_BUTTON:
- var functionToCall = function() {
- $(dialogId).ocdialog('close')
- if (callback !== undefined) {
- callback()
- }
- }
- buttonlist[0] = {
- text: t('core', 'OK'),
- click: functionToCall,
- defaultButton: true
- }
+ case Dialogs.OK_BUTTONS:
+ buttonList.push({
+ label: buttons?.confirm ?? t('core', 'OK'),
+ type: 'primary',
+ callback: () => {
+ callback._clicked = true
+ callback(true)
+ },
+ })
break
default:
- if (typeof(buttons) === 'object') {
- switch (buttons.type) {
- case Dialogs.YES_NO_BUTTONS:
- buttonlist = [{
- text: buttons.cancel || t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- callback(false)
- }
- $(dialogId).ocdialog('close')
- }
- },
- {
- text: buttons.confirm || t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- callback(true)
- }
- $(dialogId).ocdialog('close')
- },
- defaultButton: true,
- classes: buttons.confirmClasses
- }]
- break
- }
- }
+ console.error('Invalid call to OC.dialogs')
break
- }
-
- $(dialogId).ocdialog({
- closeOnEscape: true,
- closeCallback: () => { callback && callback(false) },
- modal: modal,
- buttons: buttonlist
- })
- Dialogs.dialogsCounter++
- })
- .fail(function(status, error) {
- // If the method is called while navigating away from
- // the page, we still want to deliver the message.
- if (status === 0) {
- alert(title + ': ' + content)
- } else {
- alert(t('core', 'Error loading message template: {error}', { error: error }))
- }
- })
+ }
+ return buttonList
},
+
_fileexistsshown: false,
/**
* Displays file exists dialog
@@ -680,6 +407,8 @@ const Dialogs = {
* @param {object} replacement file with name, size and mtime
* @param {object} controller with onCancel, onSkip, onReplace and onRename methods
* @returns {Promise} jquery promise that resolves after the dialog template was loaded
+ *
+ * @deprecated 29.0.0 Use openConflictPicker from the @nextcloud/upload package instead
*/
fileexists: function(data, original, replacement, controller) {
var self = this
@@ -1038,73 +767,12 @@ const Dialogs = {
// }
return dialogDeferred.promise()
},
- // get the gridview setting and set the input accordingly
- _getGridSettings: function() {
- const self = this
- $.get(OC.generateUrl('/apps/files/api/v1/showgridview'), function(response) {
- self.$showGridView
- .removeClass('icon-toggle-filelist icon-toggle-pictures')
- .addClass(response.gridview ? 'icon-toggle-filelist' : 'icon-toggle-pictures')
- self.$showGridView.attr(
- 'aria-label',
- response.gridview ? t('files', 'Show list view') : t('files', 'Show grid view'),
- )
- $('.list-container').toggleClass('view-grid', response.gridview)
- })
- },
- _onGridviewChange: function() {
- const isGridView = this.$showGridView.hasClass('icon-toggle-filelist')
- // only save state if user is logged in
- if (OC.currentUser) {
- $.post(OC.generateUrl('/apps/files/api/v1/showgridview'), { show: !isGridView })
- }
- this.$showGridView
- .removeClass('icon-toggle-filelist icon-toggle-pictures')
- .addClass(isGridView ? 'icon-toggle-pictures' : 'icon-toggle-filelist')
- this.$showGridView.attr(
- 'aria-label',
- isGridView ? t('files', 'Show grid view') : t('files', 'Show list view'),
- )
- this.$filePicker.find('.list-container').toggleClass('view-grid', !isGridView)
- },
- _getFilePickerTemplate: function() {
- var defer = $.Deferred()
- if (!this.$filePickerTemplate) {
- var self = this
- $.get(OC.filePath('core', 'templates', 'filepicker.html'), function(tmpl) {
- self.$filePickerTemplate = $(tmpl)
- self.$listTmpl = self.$filePickerTemplate.find('.filelist tbody tr:first-child').detach()
- defer.resolve(self.$filePickerTemplate)
- })
- .fail(function(jqXHR, textStatus, errorThrown) {
- defer.reject(jqXHR.status, errorThrown)
- })
- } else {
- defer.resolve(this.$filePickerTemplate)
- }
- return defer.promise()
- },
- _getMessageTemplate: function() {
- var defer = $.Deferred()
- if (!this.$messageTemplate) {
- var self = this
- $.get(OC.filePath('core', 'templates', 'message.html'), function(tmpl) {
- self.$messageTemplate = $(tmpl)
- defer.resolve(self.$messageTemplate)
- })
- .fail(function(jqXHR, textStatus, errorThrown) {
- defer.reject(jqXHR.status, errorThrown)
- })
- } else {
- defer.resolve(this.$messageTemplate)
- }
- return defer.promise()
- },
+
_getFileExistsTemplate: function() {
var defer = $.Deferred()
if (!this.$fileexistsTemplate) {
var self = this
- $.get(OC.filePath('files', 'templates', 'fileexists.html'), function(tmpl) {
+ $.get(OC.filePath('core', 'templates/legacy', 'fileexists.html'), function(tmpl) {
self.$fileexistsTemplate = $(tmpl)
defer.resolve(self.$fileexistsTemplate)
})
@@ -1116,270 +784,6 @@ const Dialogs = {
}
return defer.promise()
},
-
- /**
- * fills the filepicker with files
- */
- _fillFilePicker: async function(dir) {
- var self = this
- this.$filelist.empty()
- this.$filePicker.find('.emptycontent').hide()
- this.$filelistContainer.addClass('icon-loading')
- this.$filePicker.data('path', dir)
- var filter = this.$filePicker.data('mimetype')
- var advancedFilter = this.$filePicker.data('filter')
- if (typeof (filter) === 'string') {
- filter = [filter]
- }
- self.$fileListHeader.find('.sort-indicator').addClass('hidden').removeClass('icon-triangle-n').removeClass('icon-triangle-s')
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').removeClass('hidden')
- if (self.filepicker.sortOrder === 'asc') {
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-n')
- } else {
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-s')
- }
-
- // Wrap within a method because a promise cannot return multiple values
- // But the client impleemntation still does it...
- var getFolderContents = async function(dir) {
- return self.filepicker.filesClient.getFolderContents(dir)
- .then((status, files) => {
- return files
- })
- }
-
- try {
- var files = await getFolderContents(dir)
- } catch (error) {
- // fallback to root if requested dir is non-existent
- console.error('Requested path does not exists, falling back to root')
- var files = await getFolderContents('/')
- this.$filePicker.data('path', '/')
- }
-
- self.filelist = files
- if (filter && filter.length > 0 && filter.indexOf('*') === -1) {
- files = files.filter(function(file) {
- return file.type === 'dir' || filter.indexOf(file.mimetype) !== -1
- })
- }
-
- if (advancedFilter) {
- files = files.filter(advancedFilter)
- }
-
- // Check if the showHidden input field exist and if it exist follow it
- // Otherwise just show the hidden files
- const showHiddenInput = document.getElementById('showHiddenFiles')
- const showHidden = showHiddenInput === null || showHiddenInput.value === "1"
- if (!showHidden) {
- files = files.filter(function(file) {
- return !file.name.startsWith('.')
- })
- }
-
- var Comparators = {
- name: function(fileInfo1, fileInfo2) {
- if (fileInfo1.type === 'dir' && fileInfo2.type !== 'dir') {
- return -1
- }
- if (fileInfo1.type !== 'dir' && fileInfo2.type === 'dir') {
- return 1
- }
- return OC.Util.naturalSortCompare(fileInfo1.name, fileInfo2.name)
- },
- size: function(fileInfo1, fileInfo2) {
- return fileInfo1.size - fileInfo2.size
- },
- mtime: function(fileInfo1, fileInfo2) {
- return fileInfo1.mtime - fileInfo2.mtime
- }
- }
- var comparator = Comparators[self.filepicker.sortField] || Comparators.name
- files = files.sort(function(file1, file2) {
- var isFavorite = function(fileInfo) {
- return fileInfo.tags && fileInfo.tags.indexOf(OC.TAG_FAVORITE) >= 0
- }
-
- if (isFavorite(file1) && !isFavorite(file2)) {
- return -1
- } else if (!isFavorite(file1) && isFavorite(file2)) {
- return 1
- }
-
- return self.filepicker.sortOrder === 'asc' ? comparator(file1, file2) : -comparator(file1, file2)
- })
-
- self._fillSlug()
-
- if (files.length === 0) {
- self.$filePicker.find('.emptycontent').show()
- self.$fileListHeader.hide()
- } else {
- self.$filePicker.find('.emptycontent').hide()
- self.$fileListHeader.show()
- }
-
- self.$filelist.empty();
-
- $.each(files, function(idx, entry) {
- if (entry.isEncrypted && entry.mimetype === 'httpd/unix-directory') {
- entry.icon = OC.MimeType.getIconUrl('dir-encrypted')
- } else {
- entry.icon = OC.MimeType.getIconUrl(entry.mimetype)
- }
-
- var simpleSize, sizeColor
- if (typeof (entry.size) !== 'undefined' && entry.size >= 0) {
- simpleSize = OC.Util.humanFileSize(parseInt(entry.size, 10), true)
- sizeColor = Math.round(160 - Math.pow((entry.size / (1024 * 1024)), 2))
- } else {
- simpleSize = t('files', 'Pending')
- sizeColor = 80
- }
-
- // split the filename in half if the size is bigger than 20 char
- // for ellipsis
- if (entry.name.length >= 10) {
- // leave maximum 10 letters
- var split = Math.min(Math.floor(entry.name.length / 2), 10)
- var filename1 = entry.name.substr(0, entry.name.length - split)
- var filename2 = entry.name.substr(entry.name.length - split)
- } else {
- var filename1 = entry.name
- var filename2 = ''
- }
-
- var $row = self.$listTmpl.octemplate({
- type: entry.type,
- dir: dir,
- filename: entry.name,
- filename1: filename1,
- filename2: filename2,
- date: OC.Util.relativeModifiedDate(entry.mtime),
- size: simpleSize,
- sizeColor: sizeColor,
- icon: entry.icon
- })
- if (entry.type === 'file') {
- var urlSpec = {
- file: dir + '/' + entry.name,
- x: 100,
- y: 100
- }
- var img = new Image()
- var previewUrl = OC.generateUrl('/core/preview.png?') + $.param(urlSpec)
- img.onload = function() {
- if (img.width > 5) {
- $row.find('td.filename').attr('style', 'background-image:url(' + previewUrl + ')')
- }
- }
- img.src = previewUrl
- }
- self.$filelist.append($row)
- })
-
- self.$filelistContainer.removeClass('icon-loading')
- },
- /**
- * fills the tree list with directories
- */
- _fillSlug: function() {
- var addButton = this.$dirTree.find('.actions.creatable').detach()
- this.$dirTree.empty()
- var self = this
-
- self.$dirTree.append(addButton)
-
- var dir
- var path = this.$filePicker.data('path')
- var $template = $('<div data-dir="{dir}" tabindex="0"><a>{name}</a></div>').addClass('crumb')
- if (path) {
- var paths = path.split('/')
- $.each(paths, function(index, dir) {
- dir = paths.pop()
- if (dir === '') {
- return false
- }
- self.$dirTree.prepend($template.octemplate({
- dir: paths.join('/') + '/' + dir,
- name: dir
- }))
- })
- }
-
- $template.octemplate({
- dir: '',
- name: '' // Ugly but works ;)
- }, { escapeFunction: null }).prependTo(this.$dirTree)
-
- },
- /**
- * handle selection made in the tree list
- */
- _handleTreeListSelect: function(event, type) {
- var self = event.data
- var dir = $(event.target).closest('.crumb').data('dir')
- self._fillFilePicker(dir)
- var getOcDialog = (event.target).closest('.oc-dialog')
- var buttonEnableDisable = $('.primary', getOcDialog)
- this._changeButtonsText(type, dir.split(/[/]+/).pop())
- if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
- }
- },
- /**
- * handle clicks made in the filepicker
- */
- _handlePickerClick: function(event, $element, type) {
- var getOcDialog = this.$filePicker.closest('.oc-dialog')
- var buttonEnableDisable = getOcDialog.find('.primary')
- if ($element.data('type') === 'file') {
- if (this.$filePicker.data('multiselect') !== true || !event.ctrlKey) {
- this.$filelist.find('.filepicker_element_selected').removeClass('filepicker_element_selected')
- }
- $element.toggleClass('filepicker_element_selected')
- buttonEnableDisable.prop('disabled', false)
- } else if ($element.data('type') === 'dir') {
- this._fillFilePicker(this.$filePicker.data('path') + '/' + $element.data('entryname'))
- this._changeButtonsText(type, $element.data('entryname'))
- if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
- }
- }
- },
-
- /**
- * Handle
- * @param type of action
- * @param dir on which to change buttons text
- * @private
- */
- _changeButtonsText: function(type, dir) {
- var copyText = dir === '' ? t('core', 'Copy') : t('core', 'Copy to {folder}', { folder: dir })
- var moveText = dir === '' ? t('core', 'Move') : t('core', 'Move to {folder}', { folder: dir })
- var buttons = $('.oc-dialog-buttonrow button')
- switch (type) {
- case this.FILEPICKER_TYPE_CHOOSE:
- break
- case this.FILEPICKER_TYPE_CUSTOM:
- break
- case this.FILEPICKER_TYPE_COPY:
- buttons.text(copyText)
- break
- case this.FILEPICKER_TYPE_MOVE:
- buttons.text(moveText)
- break
- case this.FILEPICKER_TYPE_COPY_MOVE:
- buttons.eq(0).text(copyText)
- buttons.eq(1).text(moveText)
- break
- }
- }
}
export default Dialogs
diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js
index 537d68cb434..090c351c057 100644
--- a/core/src/OC/eventsource.js
+++ b/core/src/OC/eventsource.js
@@ -1,35 +1,13 @@
/**
- * @copyright 2012 Robin Appelman icewind1991@gmail.com
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2015 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
import $ from 'jquery'
-import { getToken } from './requesttoken'
+import { getRequestToken } from './requesttoken.ts'
/**
* Create a new event source
@@ -50,7 +28,7 @@ const OCEventSource = function(src, data) {
dataStr += name + '=' + encodeURIComponent(data[name]) + '&'
}
}
- dataStr += 'requesttoken=' + encodeURIComponent(getToken())
+ dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken())
if (!this.useFallBack && typeof EventSource !== 'undefined') {
joinChar = '&'
if (src.indexOf('?') === -1) {
diff --git a/core/src/OC/get_set.js b/core/src/OC/get_set.js
index 32f202ad35b..0c909ad04fd 100644
--- a/core/src/OC/get_set.js
+++ b/core/src/OC/get_set.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const get = context => name => {
diff --git a/core/src/OC/host.js b/core/src/OC/host.js
index 31f13d01a7f..75c7d63804b 100644
--- a/core/src/OC/host.js
+++ b/core/src/OC/host.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const getProtocol = () => window.location.protocol.split(':')[0]
diff --git a/core/src/OC/index.js b/core/src/OC/index.js
index 5267e2491f4..5afc941b396 100644
--- a/core/src/OC/index.js
+++ b/core/src/OC/index.js
@@ -1,40 +1,19 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { subscribe } from '@nextcloud/event-bus'
-import { addScript, addStyle } from './legacy-loader'
import {
ajaxConnectionLostHandler,
processAjaxError,
registerXHRForErrorProcessing,
-} from './xhr-error'
-import Apps from './apps'
-import { AppConfig, appConfig } from './appconfig'
-import { appSettings } from './appsettings'
-import appswebroots from './appswebroots'
-import Backbone from './backbone'
+} from './xhr-error.js'
+import Apps from './apps.js'
+import { AppConfig, appConfig } from './appconfig.js'
+import appswebroots from './appswebroots.js'
+import Backbone from './backbone.js'
import {
basename,
dirname,
@@ -45,8 +24,8 @@ import {
import {
build as buildQueryString,
parse as parseQueryString,
-} from './query-string'
-import Config from './config'
+} from './query-string.js'
+import Config from './config.js'
import {
coreApps,
menuSpeed,
@@ -58,35 +37,31 @@ import {
PERMISSION_SHARE,
PERMISSION_UPDATE,
TAG_FAVORITE,
-} from './constants'
-import ContactsMenu from './contactsmenu'
-import { currentUser, getCurrentUser } from './currentuser'
-import Dialogs from './dialogs'
-import EventSource from './eventsource'
-import { get, set } from './get_set'
-import { getCapabilities } from './capabilities'
+} from './constants.js'
+import { currentUser, getCurrentUser } from './currentuser.js'
+import Dialogs from './dialogs.js'
+import EventSource from './eventsource.js'
+import { get, set } from './get_set.js'
+import { getCapabilities } from './capabilities.js'
import {
getHost,
getHostName,
getPort,
getProtocol,
-} from './host'
-import {
- getToken as getRequestToken,
-} from './requesttoken'
+} from './host.js'
+import { getRequestToken } from './requesttoken.ts'
import {
hideMenus,
registerMenu,
showMenu,
unregisterMenu,
-} from './menu'
-import { isUserAdmin } from './admin'
-import L10N, {
- getLanguage,
- getLocale,
-} from './l10n'
+} from './menu.js'
+import { isUserAdmin } from './admin.js'
+import L10N from './l10n.js'
import {
getCanonicalLocale,
+ getLanguage,
+ getLocale,
} from '@nextcloud/l10n'
import {
@@ -101,16 +76,16 @@ import {
import {
linkToRemoteBase,
-} from './routing'
-import msg from './msg'
-import Notification from './notification'
-import PasswordConfirmation from './password-confirmation'
-import Plugins from './plugins'
-import { theme } from './theme'
-import Util from './util'
-import { debug } from './debug'
-import { redirect, reload } from './navigation'
-import webroot from './webroot'
+} from './routing.js'
+import msg from './msg.js'
+import Notification from './notification.js'
+import PasswordConfirmation from './password-confirmation.js'
+import Plugins from './plugins.js'
+import { theme } from './theme.js'
+import Util from './util.js'
+import { debug } from './debug.js'
+import { redirect, reload } from './navigation.js'
+import webroot from './webroot.js'
/** @namespace OC */
export default {
@@ -139,16 +114,11 @@ export default {
* @deprecated 17.0.0
*/
fileIsBlacklisted: file => !!(file.match(Config.blacklist_files_regex)),
-
- addScript,
- addStyle,
Apps,
AppConfig,
appConfig,
- appSettings,
appswebroots,
Backbone,
- ContactsMenu,
config: Config,
/**
* Currently logged in user or null if none
@@ -231,17 +201,14 @@ export default {
* @deprecated 20.0.0 use `getCanonicalLocale` from https://www.npmjs.com/package/@nextcloud/l10n
*/
getCanonicalLocale,
+ /**
+ * @deprecated 26.0.0 use `getLocale` from https://www.npmjs.com/package/@nextcloud/l10n
+ */
getLocale,
- getLanguage,
/**
- * Loads translations for the given app asynchronously.
- *
- * @param {string} app app name
- * @param {Function} callback callback to call after loading
- * @return {Promise}
- * @deprecated 17.0.0 use OC.L10N.load instead
+ * @deprecated 26.0.0 use `getLanguage` from https://www.npmjs.com/package/@nextcloud/l10n
*/
- addTranslations: L10N.load,
+ getLanguage,
/**
* Query string helpers
@@ -251,6 +218,9 @@ export default {
msg,
Notification,
+ /**
+ * @deprecated 28.0.0 use methods from '@nextcloud/password-confirmation'
+ */
PasswordConfirmation,
Plugins,
theme,
diff --git a/core/src/OC/l10n-registry.js b/core/src/OC/l10n-registry.js
deleted file mode 100644
index 9e542b1aa8c..00000000000
--- a/core/src/OC/l10n-registry.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-// This var is global because it's shared across webpack bundles
-window._oc_l10n_registry_translations = window._oc_l10n_registry_translations || {}
-window._oc_l10n_registry_plural_functions = window._oc_l10n_registry_plural_functions || {}
-
-/**
- * @param {string} appId the app id
- * @param {object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-const register = (appId, translations, pluralFunction) => {
- window._oc_l10n_registry_translations[appId] = translations
- window._oc_l10n_registry_plural_functions[appId] = pluralFunction
-}
-
-/**
- * @param {string} appId the app id
- * @param {object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-const extend = (appId, translations, pluralFunction) => {
- window._oc_l10n_registry_translations[appId] = Object.assign(
- window._oc_l10n_registry_translations[appId],
- translations
- )
- window._oc_l10n_registry_plural_functions[appId] = pluralFunction
-}
-
-/**
- * @param {string} appId the app id
- * @param {object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-export const registerAppTranslations = (appId, translations, pluralFunction) => {
- if (!hasAppTranslations(appId)) {
- register(appId, translations, pluralFunction)
- } else {
- extend(appId, translations, pluralFunction)
- }
-}
-
-/**
- * @param {string} appId the app id
- */
-export const unregisterAppTranslations = appId => {
- delete window._oc_l10n_registry_translations[appId]
- delete window._oc_l10n_registry_plural_functions[appId]
-}
-
-/**
- * @param {string} appId the app id
- * @return {boolean}
- */
-export const hasAppTranslations = appId => {
- return window._oc_l10n_registry_translations[appId] !== undefined
- && window._oc_l10n_registry_plural_functions[appId] !== undefined
-}
-
-/**
- * @param {string} appId the app id
- * @return {object}
- */
-export const getAppTranslations = appId => {
- return {
- translations: window._oc_l10n_registry_translations[appId] || {},
- pluralFunction: window._oc_l10n_registry_plural_functions[appId],
- }
-}
diff --git a/core/src/OC/l10n.js b/core/src/OC/l10n.js
index 2a4569ee272..02f912d6a99 100644
--- a/core/src/OC/l10n.js
+++ b/core/src/OC/l10n.js
@@ -1,106 +1,60 @@
/**
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
- * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import _ from 'underscore'
-import $ from 'jquery'
-import DOMPurify from 'dompurify'
import Handlebars from 'handlebars'
-import identity from 'lodash/fp/identity'
-import escapeHTML from 'escape-html'
-import { generateFilePath } from '@nextcloud/router'
-
-import OC from './index'
import {
- getAppTranslations,
- hasAppTranslations,
- registerAppTranslations,
- unregisterAppTranslations,
-} from './l10n-registry'
+ loadTranslations,
+ translate,
+ translatePlural,
+ register,
+ unregister,
+} from '@nextcloud/l10n'
/**
* L10N namespace with localization functions.
*
* @namespace OC.L10n
+ * @deprecated 26.0.0 use https://www.npmjs.com/package/@nextcloud/l10n
*/
const L10n = {
/**
* Load an app's translation bundle if not loaded already.
*
+ * @deprecated 26.0.0 use `loadTranslations` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
* @param {string} appName name of the app
* @param {Function} callback callback to be called when
* the translations are loaded
* @return {Promise} promise
*/
- load(appName, callback) {
- // already available ?
- if (hasAppTranslations(appName) || OC.getLocale() === 'en') {
- const deferred = $.Deferred()
- const promise = deferred.promise()
- promise.then(callback)
- deferred.resolve()
- return promise
- }
-
- const self = this
- const url = generateFilePath(appName, 'l10n', OC.getLocale() + '.json')
-
- // load JSON translation bundle per AJAX
- return $.get(url)
- .then(
- function(result) {
- if (result.translations) {
- self.register(appName, result.translations, result.pluralForm)
- }
- })
- .then(callback)
- },
+ load: loadTranslations,
/**
* Register an app's translation bundle.
*
+ * @deprecated 26.0.0 use `register` from https://www.npmjs.com/package/@nextcloud/l10
+ *
* @param {string} appName name of the app
- * @param {Object<string, string>} bundle bundle
+ * @param {Record<string, string>} bundle bundle
*/
- register(appName, bundle) {
- registerAppTranslations(appName, bundle, this._getPlural)
- },
+ register,
/**
* @private
+ * @deprecated 26.0.0 use `unregister` from https://www.npmjs.com/package/@nextcloud/l10n
*/
- _unregister: unregisterAppTranslations,
+ _unregister: unregister,
/**
* Translate a string
*
+ * @deprecated 26.0.0 use `translate` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} text the string to translate
* @param {object} [vars] map of placeholder key to value
@@ -110,49 +64,13 @@ const L10n = {
* @param {boolean} [options.sanitize=true] enable/disable sanitization (by default enabled)
* @return {string}
*/
- translate(app, text, vars, count, options) {
- const defaultOptions = {
- escape: true,
- sanitize: true,
- }
- const allOptions = options || {}
- _.defaults(allOptions, defaultOptions)
-
- const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity
- const optEscape = allOptions.escape ? escapeHTML : identity
-
- // TODO: cache this function to avoid inline recreation
- // of the same function over and over again in case
- // translate() is used in a loop
- const _build = function(text, vars, count) {
- return text.replace(/%n/g, count).replace(/{([^{}]*)}/g,
- function(a, b) {
- const r = vars[b]
- if (typeof r === 'string' || typeof r === 'number') {
- return optSanitize(optEscape(r))
- } else {
- return optSanitize(a)
- }
- }
- )
- }
- let translation = text
- const bundle = getAppTranslations(app)
- const value = bundle.translations[text]
- if (typeof (value) !== 'undefined') {
- translation = value
- }
-
- if (typeof vars === 'object' || count !== undefined) {
- return optSanitize(_build(translation, vars, count))
- } else {
- return optSanitize(translation)
- }
- },
+ translate,
/**
* Translate a plural string
*
+ * @deprecated 26.0.0 use `translatePlural` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} textSingular the string to translate for exactly one object
* @param {string} textPlural the string to translate for n objects
@@ -162,203 +80,11 @@ const L10n = {
* @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled)
* @return {string} Translated string
*/
- translatePlural(app, textSingular, textPlural, count, vars, options) {
- const identifier = '_' + textSingular + '_::_' + textPlural + '_'
- const bundle = getAppTranslations(app)
- const value = bundle.translations[identifier]
- if (typeof (value) !== 'undefined') {
- const translation = value
- if ($.isArray(translation)) {
- const plural = bundle.pluralFunction(count)
- return this.translate(app, translation[plural], vars, count, options)
- }
- }
-
- if (count === 1) {
- return this.translate(app, textSingular, vars, count, options)
- } else {
- return this.translate(app, textPlural, vars, count, options)
- }
- },
-
- /**
- * The plural function taken from symfony
- *
- * @param {number} number the number of elements
- * @return {number}
- * @private
- */
- _getPlural(number) {
- let language = OC.getLanguage()
- if (language === 'pt-BR') {
- // temporary set a locale for brazilian
- language = 'xbr'
- }
-
- if (typeof language === 'undefined' || language === '') {
- return (number === 1) ? 0 : 1
- }
-
- if (language.length > 3) {
- language = language.substring(0, language.lastIndexOf('-'))
- }
-
- /*
- * The plural rules are derived from code of the Zend Framework (2010-09-25),
- * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd).
- * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
- */
- switch (language) {
- case 'az':
- case 'bo':
- case 'dz':
- case 'id':
- case 'ja':
- case 'jv':
- case 'ka':
- case 'km':
- case 'kn':
- case 'ko':
- case 'ms':
- case 'th':
- case 'tr':
- case 'vi':
- case 'zh':
- return 0
-
- case 'af':
- case 'bn':
- case 'bg':
- case 'ca':
- case 'da':
- case 'de':
- case 'el':
- case 'en':
- case 'eo':
- case 'es':
- case 'et':
- case 'eu':
- case 'fa':
- case 'fi':
- case 'fo':
- case 'fur':
- case 'fy':
- case 'gl':
- case 'gu':
- case 'ha':
- case 'he':
- case 'hu':
- case 'is':
- case 'it':
- case 'ku':
- case 'lb':
- case 'ml':
- case 'mn':
- case 'mr':
- case 'nah':
- case 'nb':
- case 'ne':
- case 'nl':
- case 'nn':
- case 'no':
- case 'oc':
- case 'om':
- case 'or':
- case 'pa':
- case 'pap':
- case 'ps':
- case 'pt':
- case 'so':
- case 'sq':
- case 'sv':
- case 'sw':
- case 'ta':
- case 'te':
- case 'tk':
- case 'ur':
- case 'zu':
- return (number === 1) ? 0 : 1
-
- case 'am':
- case 'bh':
- case 'fil':
- case 'fr':
- case 'gun':
- case 'hi':
- case 'hy':
- case 'ln':
- case 'mg':
- case 'nso':
- case 'xbr':
- case 'ti':
- case 'wa':
- return ((number === 0) || (number === 1)) ? 0 : 1
-
- case 'be':
- case 'bs':
- case 'hr':
- case 'ru':
- case 'sh':
- case 'sr':
- case 'uk':
- return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2)
-
- case 'cs':
- case 'sk':
- return (number === 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2)
-
- case 'ga':
- return (number === 1) ? 0 : ((number === 2) ? 1 : 2)
-
- case 'lt':
- return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2)
-
- case 'sl':
- return (number % 100 === 1) ? 0 : ((number % 100 === 2) ? 1 : (((number % 100 === 3) || (number % 100 === 4)) ? 2 : 3))
-
- case 'mk':
- return (number % 10 === 1) ? 0 : 1
-
- case 'mt':
- return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3))
-
- case 'lv':
- return (number === 0) ? 0 : (((number % 10 === 1) && (number % 100 !== 11)) ? 1 : 2)
-
- case 'pl':
- return (number === 1) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2)
-
- case 'cy':
- return (number === 1) ? 0 : ((number === 2) ? 1 : (((number === 8) || (number === 11)) ? 2 : 3))
-
- case 'ro':
- return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2)
-
- case 'ar':
- return (number === 0) ? 0 : ((number === 1) ? 1 : ((number === 2) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5))))
-
- default:
- return 0
- }
- },
+ translatePlural,
}
export default L10n
-/**
- * Returns the user's locale
- *
- * @return {string} locale string
- */
-export const getLocale = () => $('html').data('locale') ?? 'en'
-
-/**
- * Returns the user's language
- *
- * @return {string} language string
- */
-export const getLanguage = () => $('html').prop('lang')
-
Handlebars.registerHelper('t', function(app, text) {
- return L10n.translate(app, text)
+ return translate(app, text)
})
diff --git a/core/src/OC/legacy-loader.js b/core/src/OC/legacy-loader.js
deleted file mode 100644
index e5bbac8f3a0..00000000000
--- a/core/src/OC/legacy-loader.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-/** @typedef {import('jquery')} jQuery */
-import $ from 'jquery'
-import { generateFilePath } from '@nextcloud/router'
-
-const loadedScripts = {}
-const loadedStyles = []
-
-/**
- * Load a script for the server and load it. If the script is already loaded,
- * the event handler will be called directly
- *
- * @param {string} app the app id to which the script belongs
- * @param {string} script the filename of the script
- * @param {Function} ready event handler to be called when the script is loaded
- * @return {jQuery.Deferred}
- * @deprecated 16.0.0 Use OCP.Loader.loadScript
- */
-export const addScript = (app, script, ready) => {
- console.warn('OC.addScript is deprecated, use OCP.Loader.loadScript instead')
-
- let deferred
- const path = generateFilePath(app, 'js', script + '.js')
- if (!loadedScripts[path]) {
- deferred = $.Deferred()
- $.getScript(path, () => deferred.resolve())
- loadedScripts[path] = deferred
- } else {
- if (ready) {
- ready()
- }
- }
- return loadedScripts[path]
-}
-
-/**
- * Loads a CSS file
- *
- * @param {string} app the app id to which the css style belongs
- * @param {string} style the filename of the css file
- * @deprecated 16.0.0 Use OCP.Loader.loadStylesheet
- */
-export const addStyle = (app, style) => {
- console.warn('OC.addStyle is deprecated, use OCP.Loader.loadStylesheet instead')
-
- const path = generateFilePath(app, 'css', style + '.css')
- if (loadedStyles.indexOf(path) === -1) {
- loadedStyles.push(path)
- if (document.createStyleSheet) {
- document.createStyleSheet(path)
- } else {
- style = $('<link rel="stylesheet" type="text/css" href="' + path + '"/>')
- $('head').append(style)
- }
- }
-}
diff --git a/core/src/OC/menu.js b/core/src/OC/menu.js
index 7d4d2f91a6c..4b4eb658592 100644
--- a/core/src/OC/menu.js
+++ b/core/src/OC/menu.js
@@ -1,32 +1,13 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
/** @typedef {import('jquery')} jQuery */
import $ from 'jquery'
-import { menuSpeed } from './constants'
+import { menuSpeed } from './constants.js'
export let currentMenu = null
export let currentMenuToggle = null
@@ -123,7 +104,7 @@ export const hideMenus = function(complete) {
/**
* Shows a given element as menu
*
- * @param {object} [$toggle=null] menu toggle
+ * @param {object} [$toggle] menu toggle
* @param {object} $menuEl menu element
* @param {Function} complete callback when the showing animation is done
*/
diff --git a/core/src/OC/msg.js b/core/src/OC/msg.js
index ef6f9ec0f09..655631a03ff 100644
--- a/core/src/OC/msg.js
+++ b/core/src/OC/msg.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author rakekniven <mark.ziegler@rakekniven.de>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
diff --git a/core/src/OC/navigation.js b/core/src/OC/navigation.js
index 2102c37b3f5..b279b9a60f3 100644
--- a/core/src/OC/navigation.js
+++ b/core/src/OC/navigation.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const redirect = targetURL => { window.location = targetURL }
diff --git a/core/src/OC/notification.js b/core/src/OC/notification.js
index 949df6a519c..b658f4163bb 100644
--- a/core/src/OC/notification.js
+++ b/core/src/OC/notification.js
@@ -1,28 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author npmbuildbot[bot] "npmbuildbot[bot]@users.noreply.github.com"
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
@@ -98,7 +76,7 @@ export default {
* @param {string} html Message to display
* @param {object} [options] options
* @param {string} [options.type] notification type
- * @param {number} [options.timeout=0] timeout value, defaults to 0 (permanent)
+ * @param {number} [options.timeout] timeout value, defaults to 0 (permanent)
* @return {jQuery} jQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
@@ -117,7 +95,7 @@ export default {
* @param {string} text Message to display
* @param {object} [options] options
* @param {string} [options.type] notification type
- * @param {number} [options.timeout=0] timeout value, defaults to 0 (permanent)
+ * @param {number} [options.timeout] timeout value, defaults to 0 (permanent)
* @return {jQuery} jQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
@@ -142,7 +120,7 @@ export default {
* Updates (replaces) a sanitized notification.
*
* @param {string} text Message to display
- * @return {jQuery} JQuery element for notificaiton row
+ * @return {jQuery} JQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
showUpdate(text) {
@@ -160,10 +138,10 @@ export default {
*
* @param {string} text Message to show
* @param {Array} [options] options array
- * @param {number} [options.timeout=7] timeout in seconds, if this is 0 it will show the message permanently
- * @param {boolean} [options.isHTML=false] an indicator for HTML notifications (true) or text (false)
+ * @param {number} [options.timeout] timeout in seconds, if this is 0 it will show the message permanently
+ * @param {boolean} [options.isHTML] an indicator for HTML notifications (true) or text (false)
* @param {string} [options.type] notification type
- * @return {JQuery} the toast element
+ * @return {jQuery} the toast element
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
showTemporary(text, options) {
diff --git a/core/src/OC/password-confirmation.js b/core/src/OC/password-confirmation.js
index 4aa643c19fb..621f7a0695f 100644
--- a/core/src/OC/password-confirmation.js
+++ b/core/src/OC/password-confirmation.js
@@ -1,130 +1,26 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import _ from 'underscore'
-import $ from 'jquery'
-import moment from 'moment'
-import { generateUrl } from '@nextcloud/router'
-
-import OC from './index'
+import { confirmPassword, isPasswordConfirmationRequired } from '@nextcloud/password-confirmation'
+import '@nextcloud/password-confirmation/dist/style.css'
/**
* @namespace OC.PasswordConfirmation
*/
export default {
- callback: null,
-
- pageLoadTime: null,
-
- init() {
- $('.password-confirm-required').on('click', _.bind(this.requirePasswordConfirmation, this))
- this.pageLoadTime = moment.now()
- },
requiresPasswordConfirmation() {
- const serverTimeDiff = this.pageLoadTime - (window.nc_pageLoad * 1000)
- const timeSinceLogin = moment.now() - (serverTimeDiff + (window.nc_lastLogin * 1000))
-
- // if timeSinceLogin > 30 minutes and user backend allows password confirmation
- return (window.backendAllowsPasswordConfirmation && timeSinceLogin > 30 * 60 * 1000)
+ return isPasswordConfirmationRequired()
},
/**
* @param {Function} callback success callback function
- * @param {object} options options
+ * @param {object} options options currently not used by confirmPassword
* @param {Function} rejectCallback error callback function
*/
requirePasswordConfirmation(callback, options, rejectCallback) {
- options = typeof options !== 'undefined' ? options : {}
- const defaults = {
- title: t('core', 'Authentication required'),
- text: t(
- 'core',
- 'This action requires you to confirm your password'
- ),
- confirm: t('core', 'Confirm'),
- label: t('core', 'Password'),
- error: '',
- }
-
- const config = _.extend(defaults, options)
-
- const self = this
-
- if (this.requiresPasswordConfirmation()) {
- OC.dialogs.prompt(
- config.text,
- config.title,
- function(result, password) {
- if (result && password !== '') {
- self._confirmPassword(password, config)
- } else if (_.isFunction(rejectCallback)) {
- rejectCallback()
- }
- },
- true,
- config.label,
- true
- ).then(function() {
- const $dialog = $('.oc-dialog:visible')
- $dialog.find('.ui-icon').remove()
- $dialog.addClass('password-confirmation')
- if (config.error !== '') {
- const $error = $('<p></p>').addClass('msg warning').text(config.error)
- $dialog.find('.oc-dialog-content').append($error)
- }
- $dialog.find('.oc-dialog-buttonrow').addClass('aside')
-
- const $buttons = $dialog.find('button')
- $buttons.eq(0).hide()
- $buttons.eq(1).text(config.confirm)
- })
- }
-
- this.callback = callback
- },
-
- _confirmPassword(password, config) {
- const self = this
-
- $.ajax({
- url: generateUrl('/login/confirm'),
- data: {
- password,
- },
- type: 'POST',
- success(response) {
- window.nc_lastLogin = response.lastLogin
-
- if (_.isFunction(self.callback)) {
- self.callback()
- }
- },
- error() {
- config.error = t('core', 'Failed to authenticate, try again')
- OC.PasswordConfirmation.requirePasswordConfirmation(self.callback, config)
- },
- })
+ confirmPassword().then(callback, rejectCallback)
},
}
diff --git a/core/src/OC/plugins.js b/core/src/OC/plugins.js
index 4425c118589..8212fc0b4ee 100644
--- a/core/src/OC/plugins.js
+++ b/core/src/OC/plugins.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default {
diff --git a/core/src/OC/query-string.js b/core/src/OC/query-string.js
index 56bf85186fb..df0f366133a 100644
--- a/core/src/OC/query-string.js
+++ b/core/src/OC/query-string.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -28,7 +9,7 @@ import $ from 'jquery'
* Parses a URL query string into a JS map
*
* @param {string} queryString query string in the format param1=1234&param2=abcde&param3=xyz
- * @return {Object<string, string>} map containing key/values matching the URL parameters
+ * @return {Record<string, string>} map containing key/values matching the URL parameters
*/
export const parse = queryString => {
let pos
@@ -77,7 +58,7 @@ export const parse = queryString => {
/**
* Builds a URL query from a JS map.
*
- * @param {Object<string, string>} params map containing key/values matching the URL parameters
+ * @param {Record<string, string>} params map containing key/values matching the URL parameters
* @return {string} String containing a URL query (without question) mark
*/
export const build = params => {
diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js
deleted file mode 100644
index eba15e88e08..00000000000
--- a/core/src/OC/requesttoken.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { emit } from '@nextcloud/event-bus'
-
-/**
- * @private
- * @param {Document} global the document to read the initial value from
- * @param {Function} emit the function to invoke for every new token
- * @return {object}
- */
-export const manageToken = (global, emit) => {
- let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken')
-
- return {
- getToken: () => token,
- setToken: newToken => {
- token = newToken
-
- emit('csrf-token-update', {
- token,
- })
- },
- }
-}
-
-const manageFromDocument = manageToken(document, emit)
-
-/**
- * @return {string}
- */
-export const getToken = manageFromDocument.getToken
-
-/**
- * @param {string} newToken new token
- */
-export const setToken = manageFromDocument.setToken
diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts
new file mode 100644
index 00000000000..8ecf0b3de7e
--- /dev/null
+++ b/core/src/OC/requesttoken.ts
@@ -0,0 +1,49 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { generateUrl } from '@nextcloud/router'
+
+/**
+ * Get the current CSRF token.
+ */
+export function getRequestToken(): string {
+ return document.head.dataset.requesttoken!
+}
+
+/**
+ * Set a new CSRF token (e.g. because of session refresh).
+ * This also emits an event bus event for the updated token.
+ *
+ * @param token - The new token
+ * @fires Error - If the passed token is not a potential valid token
+ */
+export function setRequestToken(token: string): void {
+ if (!token || typeof token !== 'string') {
+ throw new Error('Invalid CSRF token given', { cause: { token } })
+ }
+
+ document.head.dataset.requesttoken = token
+ emit('csrf-token-update', { token })
+}
+
+/**
+ * Fetch the request token from the API.
+ * This does also set it on the current context, see `setRequestToken`.
+ *
+ * @fires Error - If the request failed
+ */
+export async function fetchRequestToken(): Promise<string> {
+ const url = generateUrl('/csrftoken')
+
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error('Could not fetch CSRF token from API', { cause: response })
+ }
+
+ const { token } = await response.json()
+ setRequestToken(token)
+ return token
+}
diff --git a/core/src/OC/routing.js b/core/src/OC/routing.js
index 8752aa3883e..4b81714d6f0 100644
--- a/core/src/OC/routing.js
+++ b/core/src/OC/routing.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
diff --git a/core/src/OC/theme.js b/core/src/OC/theme.js
index b7fcfd8ce4d..af45c37de7e 100644
--- a/core/src/OC/theme.js
+++ b/core/src/OC/theme.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const theme = window._theme || {}
diff --git a/core/src/OC/util-history.js b/core/src/OC/util-history.js
index a7398b4a2fc..7ecd0e098c6 100644
--- a/core/src/OC/util-history.js
+++ b/core/src/OC/util-history.js
@@ -1,28 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
-import OC from './index'
+import OC from './index.js'
/**
* Utility class for the history API,
@@ -45,7 +27,7 @@ export default {
* or a map
* @param {string} [url] URL to be used, otherwise the current URL will be used,
* using the params as query string
- * @param {boolean} [replace=false] whether to replace instead of pushing
+ * @param {boolean} [replace] whether to replace instead of pushing
*/
_pushState(params, url, replace) {
let strParams
diff --git a/core/src/OC/util.js b/core/src/OC/util.js
index e1a2f8f0687..c46d9a141b1 100644
--- a/core/src/OC/util.js
+++ b/core/src/OC/util.js
@@ -1,30 +1,12 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import moment from 'moment'
-import History from './util-history'
-import OC from './index'
+import History from './util-history.js'
+import OC from './index.js'
import { formatFileSize as humanFileSize } from '@nextcloud/files'
/**
@@ -64,7 +46,7 @@ export default {
History,
/**
- * @deprecated use https://nextcloud.github.io/nextcloud-files/modules/_humanfilesize_.html#formatfilesize
+ * @deprecated use https://nextcloud.github.io/nextcloud-files/functions/formatFileSize.html
*/
humanFileSize,
@@ -73,7 +55,7 @@ export default {
* Makes 2kB to 2048.
* Inspired by computerFileSize in helper.php
*
- * @param {string} string file size in human readable format
+ * @param {string} string file size in human-readable format
* @return {number} or null if string could not be parsed
*
*
@@ -124,7 +106,7 @@ export default {
*/
formatDate(timestamp, format) {
if (window.TESTING === undefined) {
- console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
+ OC.debug && console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
}
format = format || 'LLL'
return moment(timestamp).format(format)
@@ -136,7 +118,7 @@ export default {
*/
relativeModifiedDate(timestamp) {
if (window.TESTING === undefined) {
- console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
+ OC.debug && console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
}
const diff = moment().diff(moment(timestamp))
if (diff >= 0 && diff < 45000) {
diff --git a/core/src/OC/webroot.js b/core/src/OC/webroot.js
index f5d063d6b50..cbe5a6190e1 100644
--- a/core/src/OC/webroot.js
+++ b/core/src/OC/webroot.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
let webroot = window._oc_webroot
diff --git a/core/src/OC/xhr-error.js b/core/src/OC/xhr-error.js
index 990340a1fda..233aaf60350 100644
--- a/core/src/OC/xhr-error.js
+++ b/core/src/OC/xhr-error.js
@@ -1,41 +1,25 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
import $ from 'jquery'
-import OC from './index'
-import Notification from './notification'
+import OC from './index.js'
+import Notification from './notification.js'
+import { getCurrentUser } from '@nextcloud/auth'
+import { showWarning } from '@nextcloud/dialogs'
/**
* Warn users that the connection to the server was lost temporarily
*
- * This function is throttled to prevent stacked notfications.
+ * This function is throttled to prevent stacked notifications.
* After 7sec the first notification is gone, then we can show another one
* if necessary.
*/
export const ajaxConnectionLostHandler = _.throttle(() => {
- Notification.showTemporary(t('core', 'Connection to server lost'))
+ showWarning(t('core', 'Connection to server lost'))
}, 7 * 1000, { trailing: false })
/**
@@ -46,13 +30,13 @@ export const ajaxConnectionLostHandler = _.throttle(() => {
*/
export const processAjaxError = xhr => {
// purposefully aborted request ?
- // OC._userIsNavigatingAway needed to distinguish ajax calls cancelled by navigating away
- // from calls cancelled by failed cross-domain ajax due to SSO redirect
+ // OC._userIsNavigatingAway needed to distinguish Ajax calls cancelled by navigating away
+ // from calls cancelled by failed cross-domain Ajax due to SSO redirect
if (xhr.status === 0 && (xhr.statusText === 'abort' || xhr.statusText === 'timeout' || OC._reloadCalled)) {
return
}
- if (_.contains([302, 303, 307, 401], xhr.status) && OC.currentUser) {
+ if ([302, 303, 307, 401].includes(xhr.status) && getCurrentUser()) {
// sometimes "beforeunload" happens later, so need to defer the reload a bit
setTimeout(function() {
if (!OC._userIsNavigatingAway && !OC._reloadCalled) {
@@ -65,7 +49,7 @@ export const processAjaxError = xhr => {
OC.reload()
}
timer++
- }, 1000 // 1 second interval
+ }, 1000, // 1 second interval
)
// only call reload once
diff --git a/core/src/OCA/index.js b/core/src/OCA/index.js
index 1fde0dc5918..cf5c29ce60a 100644
--- a/core/src/OCA/index.js
+++ b/core/src/OCA/index.js
@@ -1,35 +1,11 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import Search from './search'
-
/**
* Namespace for apps
*
* @namespace OCA
*/
-export default {
- /**
- * @deprecated 20.0.0, will be removed in Nextcloud 22
- */
- Search,
-}
+export default { }
diff --git a/core/src/OCA/search.js b/core/src/OCA/search.js
deleted file mode 100644
index 10b629a0cf3..00000000000
--- a/core/src/OCA/search.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-export default class Search {
-
- /**
- * @deprecated 20.0.0, will be removed in Nextcloud 22
- */
- constructor() {
- console.warn('OCA.Search is deprecated. Please use the unified search API instead')
- }
-
-}
diff --git a/core/src/OCP/accessibility.js b/core/src/OCP/accessibility.js
index 3839509228f..4a1399f3f96 100644
--- a/core/src/OCP/accessibility.js
+++ b/core/src/OCP/accessibility.js
@@ -1,27 +1,22 @@
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { loadState } from '@nextcloud/initial-state'
+/**
+ * Set the page heading
+ *
+ * @param {string} heading page title from the history api
+ * @since 27.0.0
+ */
+export function setPageHeading(heading) {
+ const headingEl = document.getElementById('page-heading-level-1')
+ if (headingEl) {
+ headingEl.textContent = heading
+ }
+}
export default {
/**
* @return {boolean} Whether the user opted-out of shortcuts so that they should not be registered
@@ -29,4 +24,5 @@ export default {
disableKeyboardShortcuts() {
return loadState('theming', 'shortcutsDisabled', false)
},
+ setPageHeading,
}
diff --git a/core/src/OCP/appconfig.js b/core/src/OCP/appconfig.js
index 57e13048fe0..78f94922d53 100644
--- a/core/src/OCP/appconfig.js
+++ b/core/src/OCP/appconfig.js
@@ -1,30 +1,13 @@
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
import { generateOcsUrl } from '@nextcloud/router'
-import OC from '../OC/index'
+import OC from '../OC/index.js'
/**
* @param {string} method 'post' or 'delete'
diff --git a/core/src/OCP/collaboration.js b/core/src/OCP/collaboration.js
index 2e49cebe9f1..82ff34392cf 100644
--- a/core/src/OCP/collaboration.js
+++ b/core/src/OCP/collaboration.js
@@ -1,31 +1,13 @@
/**
- * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import escapeHTML from 'escape-html'
/**
* @typedef TypeDefinition
- * @function {Function} action This action is executed to let the user select a resource
+ * @function action This action is executed to let the user select a resource
* @param {string} icon Contains the icon css class for the type
* @function Object() { [native code] }
*/
diff --git a/core/src/OCP/comments.js b/core/src/OCP/comments.js
index cd1e8a8fa77..34699a477d1 100644
--- a/core/src/OCP/comments.js
+++ b/core/src/OCP/comments.js
@@ -1,24 +1,6 @@
/**
- * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -34,7 +16,7 @@ import $ from 'jquery'
*
* This is a copy of the backend regex in IURLGenerator, make sure to adjust both when changing
*/
-const urlRegex = /(\s|^)(https?:\/\/)((?:[-A-Z0-9+_]+\.)+[-A-Z]+(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig
+const urlRegex = /(\s|^)(https?:\/\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig
/**
* @param {any} content -
diff --git a/core/src/OCP/index.js b/core/src/OCP/index.js
index 12766ad4977..94f4e8e5eb3 100644
--- a/core/src/OCP/index.js
+++ b/core/src/OCP/index.js
@@ -1,37 +1,18 @@
/**
- * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { loadState } from '@nextcloud/initial-state'
-import * as AppConfig from './appconfig'
-import * as Comments from './comments'
-import * as WhatsNew from './whatsnew'
+import * as AppConfig from './appconfig.js'
+import * as Comments from './comments.js'
+import * as WhatsNew from './whatsnew.js'
-import Accessibility from './accessibility'
-import Collaboration from './collaboration'
-import Loader from './loader'
-import Toast from './toast'
+import Accessibility from './accessibility.js'
+import Collaboration from './collaboration.js'
+import Loader from './loader.js'
+import Toast from './toast.js'
/** @namespace OCP */
export default {
diff --git a/core/src/OCP/loader.js b/core/src/OCP/loader.js
index 6e58208a849..d307eb27996 100644
--- a/core/src/OCP/loader.js
+++ b/core/src/OCP/loader.js
@@ -1,27 +1,10 @@
/**
- * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { generateFilePath } from '@nextcloud/router'
+
const loadedScripts = {}
const loadedStylesheets = {}
/**
@@ -44,7 +27,7 @@ export default {
}
loadedScripts[key] = true
return new Promise(function(resolve, reject) {
- const scriptPath = OC.filePath(app, 'js', file)
+ const scriptPath = generateFilePath(app, 'js', file)
const script = document.createElement('script')
script.src = scriptPath
script.setAttribute('nonce', btoa(OC.requestToken))
@@ -68,7 +51,7 @@ export default {
}
loadedStylesheets[key] = true
return new Promise(function(resolve, reject) {
- const stylePath = OC.filePath(app, 'css', file)
+ const stylePath = generateFilePath(app, 'css', file)
const link = document.createElement('link')
link.href = stylePath
link.type = 'text/css'
diff --git a/core/src/OCP/toast.js b/core/src/OCP/toast.js
index 40c46aa8f51..f93344bbc8e 100644
--- a/core/src/OCP/toast.js
+++ b/core/src/OCP/toast.js
@@ -1,24 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
@@ -28,6 +10,8 @@ import {
showWarning,
} from '@nextcloud/dialogs'
+/** @typedef {import('toastify-js')} Toast */
+
export default {
/**
* @deprecated 19.0.0 use `showSuccess` from the `@nextcloud/dialogs` package instead
diff --git a/core/src/OCP/whatsnew.js b/core/src/OCP/whatsnew.js
index aafe1740e0d..acada6a8383 100644
--- a/core/src/OCP/whatsnew.js
+++ b/core/src/OCP/whatsnew.js
@@ -1,24 +1,6 @@
/**
- * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
diff --git a/core/src/Polyfill/index.js b/core/src/Polyfill/index.js
deleted file mode 100644
index 610619217d2..00000000000
--- a/core/src/Polyfill/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import 'focus-visible'
diff --git a/core/src/Polyfill/tooltip.js b/core/src/Polyfill/tooltip.js
deleted file mode 100644
index 452cb30305b..00000000000
--- a/core/src/Polyfill/tooltip.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @copyright 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import $ from 'jquery'
-
-$.prototype.tooltip = (function(tooltip) {
- return function(config) {
- try {
- return tooltip.call(this, config)
- } catch (ex) {
- if (ex instanceof TypeError && config === 'destroy') {
- if (window.TESTING === undefined) {
- console.error('Deprecated call $.tooltip(\'destroy\') has been deprecated and should be removed')
- }
- return tooltip.call(this, 'dispose')
- }
- if (ex instanceof TypeError && config === 'fixTitle') {
- if (window.TESTING === undefined) {
- console.error('Deprecated call $.tooltip(\'fixTitle\') has been deprecated and should be removed')
- }
- return tooltip.call(this, '_fixTitle')
- }
- }
- }
-})($.prototype.tooltip)
diff --git a/core/src/Util/a11y.js b/core/src/Util/a11y.js
index 550ae375e02..2eb753b3faf 100644
--- a/core/src/Util/a11y.js
+++ b/core/src/Util/a11y.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2022 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
diff --git a/core/src/Util/get-url-parameter.js b/core/src/Util/get-url-parameter.js
index 00fa66a9eb3..6df264f009f 100644
--- a/core/src/Util/get-url-parameter.js
+++ b/core/src/Util/get-url-parameter.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
@@ -27,6 +9,6 @@
export default function getURLParameter(name) {
return decodeURIComponent(
// eslint-disable-next-line no-sparse-arrays
- (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20')
+ (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20'),
) || ''
}
diff --git a/core/src/ajax-cron.ts b/core/src/ajax-cron.ts
new file mode 100644
index 00000000000..d903a3596ea
--- /dev/null
+++ b/core/src/ajax-cron.ts
@@ -0,0 +1,18 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRootUrl } from '@nextcloud/router'
+import logger from './logger'
+
+window.addEventListener('DOMContentLoaded', async () => {
+ // When the page is loaded send GET to the cron endpoint to trigger background jobs
+ try {
+ logger.debug('Running web cron')
+ await window.fetch(`${getRootUrl()}/cron.php`)
+ logger.debug('Web cron successfull')
+ } catch (e) {
+ logger.debug('Running web cron failed', { error: e })
+ }
+})
diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue
new file mode 100644
index 00000000000..d983226d273
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuEntry.vue
@@ -0,0 +1,117 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="href ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ class="account-menu-entry"
+ compact
+ :href="href"
+ :name="name"
+ target="_self"
+ @click="onClick">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" />
+ <slot v-else-if="$scopedSlots.icon" name="icon" />
+ <img v-else
+ class="account-menu-entry__icon"
+ :class="{ 'account-menu-entry__icon--active': active }"
+ :src="iconSource"
+ alt="">
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const versionHash = loadState('core', 'versionHash', '')
+
+export default defineComponent({
+ name: 'AccountMenuEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ computed: {
+ iconSource() {
+ return `${this.icon}?v=${versionHash}`
+ },
+ },
+
+ methods: {
+ onClick(e: MouseEvent) {
+ this.$emit('click', e)
+
+ // Allow to not show the loading indicator
+ // in case the click event was already handled
+ if (!e.defaultPrevented) {
+ this.loading = true
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.account-menu-entry {
+ &__icon {
+ height: 16px;
+ width: 16px;
+ margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size
+ filter: var(--background-invert-if-dark);
+
+ &--active {
+ filter: var(--primary-invert-if-dark);
+ }
+ }
+
+ &__loading {
+ height: 20px;
+ width: 20px;
+ margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size
+ }
+
+ :deep(.list-item-content__main) {
+ width: fit-content;
+ }
+}
+</style>
diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
new file mode 100644
index 00000000000..8b895b8ca31
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
@@ -0,0 +1,100 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="profileEnabled ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ compact
+ :href="profileEnabled ? href : undefined"
+ :name="displayName"
+ target="_self">
+ <template v-if="profileEnabled" #subname>
+ {{ name }}
+ </template>
+ <template v-if="loading" #indicator>
+ <NcLoadingIcon />
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false })
+
+export default defineComponent({
+ name: 'AccountMenuProfileEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ profileEnabled,
+ displayName: getCurrentUser()!.displayName,
+ }
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ mounted() {
+ subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ methods: {
+ handleClick() {
+ if (this.profileEnabled) {
+ this.loading = true
+ }
+ },
+
+ handleProfileEnabledUpdate(profileEnabled: boolean) {
+ this.profileEnabled = profileEnabled
+ },
+
+ handleDisplayNameUpdate(displayName: string) {
+ this.displayName = displayName
+ },
+ },
+})
+</script>
diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue
index ed3a6293c57..88f626ff569 100644
--- a/core/src/components/AppMenu.vue
+++ b/core/src/components/AppMenu.vue
@@ -1,298 +1,161 @@
<!--
- - @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <nav class="app-menu">
- <ul class="app-menu-main">
- <li v-for="app in mainAppList"
+ <nav ref="appMenu"
+ class="app-menu"
+ :aria-label="t('core', 'Applications menu')">
+ <ul :aria-label="t('core', 'Apps')"
+ class="app-menu__list">
+ <AppMenuEntry v-for="app in mainAppList"
:key="app.id"
- :data-app-id="app.id"
- class="app-menu-entry"
- :class="{ 'app-menu-entry__active': app.active }">
- <a :href="app.href"
- :class="{ 'has-unread': app.unread > 0 }"
- :aria-label="appLabel(app)"
- :title="app.name"
- :aria-current="app.active ? 'page' : false"
- :target="app.target ? '_blank' : undefined"
- :rel="app.target ? 'noopener noreferrer' : undefined">
- <img :src="app.icon" alt="">
- <div class="app-menu-entry--label">
- {{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
- </div>
- </a>
- </li>
+ :app="app" />
</ul>
- <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')">
+ <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')">
<NcActionLink v-for="app in popoverAppList"
:key="app.id"
- :aria-label="appLabel(app)"
:aria-current="app.active ? 'page' : false"
:href="app.href"
- class="app-menu-popover-entry">
- <template #icon>
- <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }">
- <img :src="app.icon" alt="">
- </div>
- </template>
+ :icon="app.icon"
+ class="app-menu__overflow-entry">
{{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
</NcActionLink>
</NcActions>
</nav>
</template>
-<script>
-import { loadState } from '@nextcloud/initial-state'
+<script lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink'
+import { loadState } from '@nextcloud/initial-state'
+import { n, t } from '@nextcloud/l10n'
+import { useElementSize } from '@vueuse/core'
+import { defineComponent, ref } from 'vue'
+
+import AppMenuEntry from './AppMenuEntry.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import logger from '../logger'
-export default {
+export default defineComponent({
name: 'AppMenu',
+
components: {
- NcActions, NcActionLink,
+ AppMenuEntry,
+ NcActions,
+ NcActionLink,
+ },
+
+ setup() {
+ const appMenu = ref()
+ const { width: appMenuWidth } = useElementSize(appMenu)
+ return {
+ t,
+ n,
+ appMenu,
+ appMenuWidth,
+ }
},
+
data() {
+ const appList = loadState<INavigationEntry[]>('core', 'apps', [])
return {
- apps: loadState('core', 'apps', {}),
- appLimit: 0,
- observer: null,
+ appList,
}
},
+
computed: {
- appList() {
- return Object.values(this.apps)
+ appLimit() {
+ const maxApps = Math.floor(this.appMenuWidth / 50)
+ if (maxApps < this.appList.length) {
+ // Ensure there is space for the overflow menu
+ return Math.max(maxApps - 1, 0)
+ }
+ return maxApps
},
+
mainAppList() {
return this.appList.slice(0, this.appLimit)
},
+
popoverAppList() {
return this.appList.slice(this.appLimit)
},
- appLabel() {
- return (app) => app.name
- + (app.active ? ' (' + t('core', 'Currently open') + ')' : '')
- + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '')
- },
},
+
mounted() {
- this.observer = new ResizeObserver(this.resize)
- this.observer.observe(this.$el)
- this.resize()
subscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
beforeDestroy() {
- this.observer.disconnect()
unsubscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
methods: {
- setNavigationCounter(id, counter) {
- this.$set(this.apps[id], 'unread', counter)
- },
- setApps({ apps }) {
- this.apps = apps
- },
- resize() {
- const availableWidth = this.$el.offsetWidth
- let appCount = Math.floor(availableWidth / 50) - 1
- const popoverAppCount = this.appList.length - appCount
- if (popoverAppCount === 1) {
- appCount--
+ setNavigationCounter(id: string, counter: number) {
+ const app = this.appList.find(({ app }) => app === id)
+ if (app) {
+ this.$set(app, 'unread', counter)
+ } else {
+ logger.warn(`Could not find app "${id}" for setting navigation count`)
}
- if (appCount < 1) {
- appCount = 0
- }
- this.appLimit = appCount
+ },
+
+ setApps({ apps }: { apps: INavigationEntry[]}) {
+ this.appList = apps
},
},
-}
+})
</script>
-<style lang="scss" scoped>
-$header-icon-size: 20px;
-
+<style scoped lang="scss">
.app-menu {
- width: 100%;
- display: flex;
- flex-shrink: 1;
- flex-wrap: wrap;
-}
-.app-menu-main {
+ // The size the currently focussed entry will grow to show the full name
+ --app-menu-entry-growth: calc(var(--default-grid-baseline) * 4);
display: flex;
- flex-wrap: nowrap;
+ flex: 1 1;
+ width: 0;
- .app-menu-entry {
- width: 50px;
- height: 50px;
- position: relative;
+ &__list {
display: flex;
- opacity: .7;
- filter: var(--background-image-invert-if-bright);
-
- &.app-menu-entry__active {
- opacity: 1;
-
- &::before {
- content: " ";
- position: absolute;
- pointer-events: none;
- border-bottom-color: var(--color-main-background);
- transform: translateX(-50%);
- width: 12px;
- height: 5px;
- border-radius: 3px;
- background-color: var(--color-primary-text);
- left: 50%;
- bottom: 6px;
- display: block;
- transition: all 0.1s ease-in-out;
- opacity: 1;
- }
+ flex-wrap: nowrap;
+ margin-inline: calc(var(--app-menu-entry-growth) / 2);
+ }
- .app-menu-entry--label {
- font-weight: bold;
- }
- }
+ &__overflow {
+ margin-block: auto;
- a {
- width: calc(100% - 4px);
- height: calc(100% - 4px);
- margin: 2px;
- color: var(--color-primary-text);
- position: relative;
- }
+ // Adjust the overflow NcActions styles as they are directly rendered on the background
+ :deep(.button-vue--vue-tertiary) {
+ opacity: .7;
+ margin: 3px;
+ filter: var(--background-image-invert-if-bright);
- img {
- transition: margin 0.1s ease-in-out;
- width: $header-icon-size;
- height: $header-icon-size;
- padding: calc((100% - $header-icon-size) / 2);
- box-sizing: content-box;
- }
+ /* Remove all background and align text color if not expanded */
+ &:not([aria-expanded="true"]) {
+ color: var(--color-background-plain-text);
- .app-menu-entry--label {
- opacity: 0;
- position: absolute;
- font-size: 12px;
- color: var(--color-primary-text);
- text-align: center;
- bottom: -5px;
- left: 50%;
- top: 45%;
- display: block;
- min-width: 100%;
- transform: translateX(-50%);
- transition: all 0.1s ease-in-out;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
- letter-spacing: -0.5px;
- }
+ &:hover {
+ opacity: 1;
+ background-color: transparent !important;
+ }
+ }
- &:hover,
- &:focus-within {
- opacity: 1;
- .app-menu-entry--label {
+ &:focus-visible {
opacity: 1;
- font-weight: bolder;
- bottom: 0;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
+ outline: none !important;
}
}
-
- }
-
- // Show labels
- &:hover,
- &:focus-within,
- .app-menu-entry:hover,
- .app-menu-entry:focus {
- opacity: 1;
-
- img {
- margin-top: -8px;
- }
-
- .app-menu-entry--label {
- opacity: 1;
- bottom: 0;
- }
-
- &::before, .app-menu-entry::before {
- opacity: 0;
- }
- }
-}
-
-::v-deep .app-menu-more .button-vue--vue-tertiary {
- color: var(--color-primary-text);
- opacity: .7;
- margin: 3px;
- filter: var(--background-image-invert-if-bright);
-
- &:hover {
- opacity: 1;
- background-color: transparent !important;
- }
-
- &:focus-visible {
- opacity: 1;
- outline: none !important;
}
-}
-.app-menu-popover-entry {
- .app-icon {
- position: relative;
- height: 44px;
-
- &.has-unread::after {
- background-color: var(--color-main-text);
- }
-
- img {
- width: $header-icon-size;
- height: $header-icon-size;
- padding: calc((50px - $header-icon-size) / 2);
+ &__overflow-entry {
+ :deep(.action-link__icon) {
+ // Icons are bright so invert them if bright color theme == bright background is used
+ filter: var(--background-invert-if-bright) !important;
}
}
}
-
-.has-unread::after {
- content: "";
- width: 8px;
- height: 8px;
- background-color: var(--color-primary-text);
- border-radius: 50%;
- position: absolute;
- display: block;
- top: 10px;
- right: 10px;
-}
-
-.unread-counter {
- display: none;
-}
</style>
diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue
new file mode 100644
index 00000000000..4c5acb7e9c8
--- /dev/null
+++ b/core/src/components/AppMenuEntry.vue
@@ -0,0 +1,189 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <li ref="containerElement"
+ class="app-menu-entry"
+ :class="{
+ 'app-menu-entry--active': app.active,
+ 'app-menu-entry--truncated': needsSpace,
+ }">
+ <a class="app-menu-entry__link"
+ :href="app.href"
+ :title="app.name"
+ :aria-current="app.active ? 'page' : false"
+ :target="app.target ? '_blank' : undefined"
+ :rel="app.target ? 'noopener noreferrer' : undefined">
+ <AppMenuIcon class="app-menu-entry__icon" :app="app" />
+ <span ref="labelElement" class="app-menu-entry__label">
+ {{ app.name }}
+ </span>
+ </a>
+ </li>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+import { onMounted, ref, watch } from 'vue'
+import AppMenuIcon from './AppMenuIcon.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+const containerElement = ref<HTMLLIElement>()
+const labelElement = ref<HTMLSpanElement>()
+const needsSpace = ref(false)
+
+/** Update the space requirements of the app label */
+function calculateSize() {
+ const maxWidth = containerElement.value!.clientWidth
+ // Also keep the 0.5px letter spacing in mind
+ needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth)
+}
+// Update size on mounted and when the app name changes
+onMounted(calculateSize)
+watch(() => props.app.name, calculateSize)
+</script>
+
+<style scoped lang="scss">
+.app-menu-entry {
+ --app-menu-entry-font-size: 12px;
+ width: var(--header-height);
+ height: var(--header-height);
+ position: relative;
+
+ &__link {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ // Set color as this is shown directly on the background
+ color: var(--color-background-plain-text);
+ // Make space for focus-visible outline
+ width: calc(100% - 4px);
+ height: calc(100% - 4px);
+ margin: 2px;
+ }
+
+ &__label {
+ opacity: 0;
+ position: absolute;
+ font-size: var(--app-menu-entry-font-size);
+ // this is shown directly on the background
+ color: var(--color-background-plain-text);
+ text-align: center;
+ bottom: 0;
+ inset-inline-start: 50%;
+ top: 50%;
+ display: block;
+ transform: translateX(-50%);
+ max-width: 100%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ letter-spacing: -0.5px;
+ }
+ body[dir=rtl] &__label {
+ transform: translateX(50%) !important;
+ }
+
+ &__icon {
+ font-size: var(--app-menu-entry-font-size);
+ }
+
+ &--active {
+ // When hover or focus, show the label and make it bolder than the other entries
+ .app-menu-entry__label {
+ font-weight: bolder;
+ }
+
+ // When active show a line below the entry as an "active" indicator
+ &::before {
+ content: " ";
+ position: absolute;
+ pointer-events: none;
+ border-bottom-color: var(--color-main-background);
+ transform: translateX(-50%);
+ width: 10px;
+ height: 5px;
+ border-radius: 3px;
+ background-color: var(--color-background-plain-text);
+ inset-inline-start: 50%;
+ bottom: 8px;
+ display: block;
+ transition: all var(--animation-quick) ease-in-out;
+ opacity: 1;
+ }
+ body[dir=rtl] &::before {
+ transform: translateX(50%) !important;
+ }
+ }
+
+ &__icon,
+ &__label {
+ transition: all var(--animation-quick) ease-in-out;
+ }
+
+ // Make the hovered entry bold to see that it is hovered
+ &:hover .app-menu-entry__label,
+ &:focus-within .app-menu-entry__label {
+ font-weight: bold;
+ }
+
+ // Adjust the width when an entry is focussed
+ // The focussed / hovered entry should grow, while both neighbors need to shrink
+ &--truncated:hover,
+ &--truncated:focus-within {
+ .app-menu-entry__label {
+ max-width: calc(var(--header-height) + var(--app-menu-entry-growth));
+ }
+
+ // The next entry needs to shrink half the growth
+ + .app-menu-entry {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+ }
+
+ // The previous entry needs to shrink half the growth
+ &:has(+ .app-menu-entry--truncated:hover),
+ &:has(+ .app-menu-entry--truncated:focus-within) {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+}
+</style>
+
+<style lang="scss">
+// Showing the label
+.app-menu-entry:hover,
+.app-menu-entry:focus-within,
+.app-menu__list:hover,
+.app-menu__list:focus-within {
+ // Move icon up so that the name does not overflow the icon
+ .app-menu-entry__icon {
+ margin-block-end: 1lh;
+ }
+
+ // Make the label visible
+ .app-menu-entry__label {
+ opacity: 1;
+ }
+
+ // Hide indicator when the text is shown
+ .app-menu-entry--active::before {
+ opacity: 0;
+ }
+
+ .app-menu-icon__unread {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue
new file mode 100644
index 00000000000..1b0d48daf8c
--- /dev/null
+++ b/core/src/components/AppMenuIcon.vue
@@ -0,0 +1,67 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <span class="app-menu-icon"
+ role="img"
+ :aria-hidden="ariaHidden"
+ :aria-label="ariaLabel">
+ <img class="app-menu-icon__icon" :src="app.icon" alt="">
+ <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" />
+ </span>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation.ts'
+
+import { n } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import IconDot from 'vue-material-design-icons/CircleOutline.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+// only hide if there are no unread notifications
+const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined)
+
+const ariaLabel = computed(() => {
+ if (!props.app.unread) {
+ return undefined
+ }
+
+ return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})`
+})
+</script>
+
+<style scoped lang="scss">
+$icon-size: 20px;
+$unread-indicator-size: 10px;
+
+.app-menu-icon {
+ box-sizing: border-box;
+ position: relative;
+
+ height: $icon-size;
+ width: $icon-size;
+
+ &__icon {
+ transition: margin 0.1s ease-in-out;
+ height: $icon-size;
+ width: $icon-size;
+ filter: var(--background-image-invert-if-bright);
+ mask: var(--header-menu-icon-mask);
+ }
+
+ &__unread {
+ color: var(--color-error);
+ position: absolute;
+ // Align the dot to the top right corner of the icon
+ inset-block-end: calc($icon-size + ($unread-indicator-size / -2));
+ inset-inline-end: calc($unread-indicator-size / -2);
+ transition: all 0.1s ease-in-out;
+ }
+}
+</style>
diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js
index 1b7b25873d0..e07a699ab9f 100644
--- a/core/src/components/ContactsMenu.js
+++ b/core/src/components/ContactsMenu.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Christopher Ng <chrng8@gmail.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
@@ -34,6 +15,7 @@ export const setUp = () => {
if (mountPoint) {
// eslint-disable-next-line no-new
new Vue({
+ name: 'ContactsMenuRoot',
el: mountPoint,
render: h => h(ContactsMenu),
})
diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue
new file mode 100644
index 00000000000..322f53647b1
--- /dev/null
+++ b/core/src/components/ContactsMenu/Contact.vue
@@ -0,0 +1,193 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <li class="contact">
+ <NcAvatar class="contact__avatar"
+ :size="44"
+ :user="contact.isUser ? contact.uid : undefined"
+ :is-no-user="!contact.isUser"
+ :disable-menu="true"
+ :display-name="contact.avatarLabel"
+ :preloaded-user-status="preloadedUserStatus" />
+ <a class="contact__body"
+ :href="contact.profileUrl || contact.topAction?.hyperlink">
+ <div class="contact__body__full-name">{{ contact.fullName }}</div>
+ <div v-if="contact.lastMessage" class="contact__body__last-message">{{ contact.lastMessage }}</div>
+ <div v-if="contact.statusMessage" class="contact__body__status-message">{{ contact.statusMessage }}</div>
+ <div v-else class="contact__body__email-address">{{ contact.emailAddresses[0] }}</div>
+ </a>
+ <NcActions v-if="actions.length"
+ :inline="contact.topAction ? 1 : 0">
+ <template v-for="(action, idx) in actions">
+ <NcActionLink v-if="action.hyperlink !== '#'"
+ :key="`${idx}-link`"
+ :href="action.hyperlink"
+ class="other-actions">
+ <template #icon>
+ <img aria-hidden="true" class="contact__action__icon" :src="action.icon">
+ </template>
+ {{ action.title }}
+ </NcActionLink>
+ <NcActionText v-else :key="`${idx}-text`" class="other-actions">
+ <template #icon>
+ <img aria-hidden="true" class="contact__action__icon" :src="action.icon">
+ </template>
+ {{ action.title }}
+ </NcActionText>
+ </template>
+ <NcActionButton v-for="action in jsActions"
+ :key="action.id"
+ :close-after-click="true"
+ class="other-actions"
+ @click="action.callback(contact)">
+ <template #icon>
+ <NcIconSvgWrapper class="contact__action__icon-svg"
+ :svg="action.iconSvg(contact)" />
+ </template>
+ {{ action.displayName(contact) }}
+ </NcActionButton>
+ </NcActions>
+ </li>
+</template>
+
+<script>
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { getEnabledContactsMenuActions } from '@nextcloud/vue/functions/contactsMenu'
+
+export default {
+ name: 'Contact',
+ components: {
+ NcActionLink,
+ NcActionText,
+ NcActionButton,
+ NcActions,
+ NcAvatar,
+ NcIconSvgWrapper,
+ },
+ props: {
+ contact: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ actions() {
+ if (this.contact.topAction) {
+ return [this.contact.topAction, ...this.contact.actions]
+ }
+ return this.contact.actions
+ },
+ jsActions() {
+ return getEnabledContactsMenuActions(this.contact)
+ },
+ preloadedUserStatus() {
+ if (this.contact.status) {
+ return {
+ status: this.contact.status,
+ message: this.contact.statusMessage,
+ icon: this.contact.statusIcon,
+ }
+ }
+ return undefined
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.contact {
+ display: flex;
+ position: relative;
+ align-items: center;
+ padding: 3px;
+ padding-inline-start: 10px;
+
+ &__action {
+ &__icon {
+ width: 20px;
+ height: 20px;
+ padding: 12px;
+ filter: var(--background-invert-if-dark);
+ }
+
+ &__icon-svg {
+ padding: 5px;
+ }
+ }
+
+ &__avatar {
+ display: inherit;
+ }
+
+ &__body {
+ flex-grow: 1;
+ padding-inline-start: 10px;
+ margin-inline-start: 10px;
+ min-width: 0;
+
+ div {
+ position: relative;
+ width: 100%;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ margin: -1px 0;
+ }
+ div:first-of-type {
+ margin-top: 0;
+ }
+ div:last-of-type {
+ margin-bottom: 0;
+ }
+
+ &__last-message, &__status-message, &__email-address {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 4px var(--color-main-background) !important;
+ outline: 2px solid var(--color-main-text) !important;
+ }
+ }
+
+ .other-actions {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+
+ img {
+ filter: var(--background-invert-if-dark);
+ }
+ }
+
+ button.other-actions {
+ width: 44px;
+
+ &:focus {
+ border-color: transparent;
+ box-shadow: 0 0 0 2px var(--color-main-text);
+ }
+
+ &:focus-visible {
+ border-radius: var(--border-radius-pill);
+ }
+ }
+
+ /* actions menu */
+ .menu {
+ top: 47px;
+ margin-inline-end: 13px;
+ }
+
+ .popovermenu::after {
+ inset-inline-end: 2px;
+ }
+}
+</style>
diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue
new file mode 100644
index 00000000000..f2ee4be9151
--- /dev/null
+++ b/core/src/components/LegacyDialogPrompt.vue
@@ -0,0 +1,111 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog dialog-classes="legacy-prompt__dialog"
+ :buttons="buttons"
+ :name="name"
+ @update:open="$emit('close', false, inputValue)">
+ <p class="legacy-prompt__text" v-text="text" />
+ <NcPasswordField v-if="isPassword"
+ ref="input"
+ autocomplete="new-password"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ <NcTextField v-else
+ ref="input"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+export default defineComponent({
+ name: 'LegacyDialogPrompt',
+
+ components: {
+ NcDialog,
+ NcTextField,
+ NcPasswordField,
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ text: {
+ type: String,
+ required: true,
+ },
+
+ isPassword: {
+ type: Boolean,
+ required: true,
+ },
+
+ inputName: {
+ type: String,
+ default: 'prompt-input',
+ },
+ },
+
+ emits: ['close'],
+
+ data() {
+ return {
+ inputValue: '',
+ }
+ },
+
+ computed: {
+ buttons() {
+ return [
+ {
+ label: t('core', 'No'),
+ callback: () => this.$emit('close', false, this.inputValue),
+ },
+ {
+ label: t('core', 'Yes'),
+ type: 'primary',
+ callback: () => this.$emit('close', true, this.inputValue),
+ },
+ ]
+ },
+ },
+
+ mounted() {
+ this.$nextTick(() => this.$refs.input?.focus?.())
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.legacy-prompt {
+ &__text {
+ margin-block: 0 .75em;
+ }
+
+ &__input {
+ margin-block: 0 1em;
+ }
+}
+
+:deep(.legacy-prompt__dialog .dialog__actions) {
+ min-width: calc(100% - 12px);
+ justify-content: space-between;
+}
+</style>
diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js
index 46e0e5c510b..21a0b6a772f 100644
--- a/core/src/components/MainMenu.js
+++ b/core/src/components/MainMenu.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
@@ -36,7 +17,7 @@ export const setUp = () => {
},
})
- const container = document.getElementById('header-left__appmenu')
+ const container = document.getElementById('header-start__appmenu')
if (!container) {
// no container, possibly we're on a public page
return
diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue
index 7a1f031b60c..dbc446b3d90 100644
--- a/core/src/components/Profile/PrimaryActionButton.vue
+++ b/core/src/components/Profile/PrimaryActionButton.vue
@@ -1,43 +1,36 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <a class="profile__primary-action-button"
- :class="{ 'disabled': disabled }"
+ <NcButton type="primary"
:href="href"
+ alignment="center"
:target="target"
- rel="noopener noreferrer nofollow"
- v-on="$listeners">
- <img class="icon"
- :class="[icon, { 'icon-invert': colorPrimaryText === '#ffffff' }]"
- :src="icon">
+ :disabled="disabled">
+ <template #icon>
+ <img class="icon"
+ aria-hidden="true"
+ :src="icon"
+ alt="">
+ </template>
<slot />
- </a>
+ </NcButton>
</template>
<script>
-export default {
+import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
+
+export default defineComponent({
name: 'PrimaryActionButton',
+ components: {
+ NcButton,
+ },
+
props: {
disabled: {
type: Boolean,
@@ -58,46 +51,14 @@ export default {
},
},
- computed: {
- colorPrimaryText() {
- // For some reason the returned string has prepended whitespace
- return getComputedStyle(document.body).getPropertyValue('--color-primary-text').trim()
- },
+ methods: {
+ t,
},
-}
+})
</script>
<style lang="scss" scoped>
- .profile__primary-action-button {
- font-size: var(--default-font-size);
- font-weight: bold;
- width: 188px;
- height: 44px;
- padding: 0 16px;
- line-height: 44px;
- text-align: center;
- border-radius: var(--border-radius-pill);
- color: var(--color-primary-text);
- background-color: var(--color-primary-element);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-
- .icon {
- display: inline-block;
- vertical-align: middle;
- margin-bottom: 2px;
- margin-right: 4px;
-
- &.icon-invert {
- filter: invert(1);
- }
- }
-
- &:hover,
- &:focus,
- &:active {
- background-color: var(--color-primary-element-light);
- }
+ .icon {
+ filter: var(--primary-invert-if-dark);
}
</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
new file mode 100644
index 00000000000..f3c57a12042
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <li ref="listItem" :role="itemRole" v-html="html" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+
+defineProps<{
+ id: string
+ html: string
+}>()
+
+const listItem = ref<HTMLLIElement>()
+const itemRole = ref('presentation')
+
+onMounted(() => {
+ // check for proper roles
+ const menuitem = listItem.value?.querySelector('[role="menuitem"]')
+ if (menuitem) {
+ return
+ }
+ // check if a button is available
+ const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a')
+ if (button) {
+ button.role = 'menuitem'
+ } else {
+ // if nothing is available set role on `<li>`
+ itemRole.value = 'menuitem'
+ }
+})
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
new file mode 100644
index 00000000000..413806c7089
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcListItem :anchor-id="`${id}--link`"
+ compact
+ :details="details"
+ :href="href"
+ :name="label"
+ role="presentation"
+ @click="$emit('click')">
+ <template #icon>
+ <slot v-if="$scopedSlots.icon" name="icon" />
+ <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" />
+ </template>
+ </NcListItem>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+
+const props = defineProps<{
+ /** Only emit click event but do not open href */
+ clickOnly?: boolean
+ // menu entry props
+ id: string
+ label: string
+ icon?: string
+ href: string
+ details?: string
+}>()
+
+onMounted(() => {
+ const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement
+ // Make the `<a>` a menuitem
+ anchor.role = 'menuitem'
+ // Prevent native click handling if required
+ if (props.clickOnly) {
+ anchor.onclick = (event) => event.preventDefault()
+ }
+})
+</script>
+
+<style scoped>
+.public-page-menu-entry__icon {
+ padding-inline-start: var(--default-grid-baseline);
+}
+</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
new file mode 100644
index 00000000000..0f02bdf7524
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
@@ -0,0 +1,90 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcDialog is-form
+ :name="label"
+ :open.sync="open"
+ @submit="createFederatedShare">
+ <NcTextField ref="input"
+ :label="t('core', 'Federated user')"
+ :placeholder="t('core', 'user@your-nextcloud.org')"
+ required
+ :value.sync="remoteUrl" />
+ <template #actions>
+ <NcButton :disabled="loading" type="primary" native-type="submit">
+ <template v-if="loading" #icon>
+ <NcLoadingIcon />
+ </template>
+ {{ t('core', 'Create share') }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import type Vue from 'vue'
+
+import { t } from '@nextcloud/l10n'
+import { showError } from '@nextcloud/dialogs'
+import { generateUrl } from '@nextcloud/router'
+import { getSharingToken } from '@nextcloud/sharing/public'
+import { nextTick, onMounted, ref, watch } from 'vue'
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import logger from '../../logger'
+
+defineProps<{
+ label: string
+}>()
+
+const loading = ref(false)
+const remoteUrl = ref('')
+// Todo: @nextcloud/vue should expose the types correctly
+const input = ref<Vue & { focus: () => void }>()
+const open = ref(true)
+
+// Focus when mounted
+onMounted(() => nextTick(() => input.value!.focus()))
+
+// Check validity
+watch(remoteUrl, () => {
+ let validity = ''
+ if (!remoteUrl.value.includes('@')) {
+ validity = t('core', 'The remote URL must include the user.')
+ } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) {
+ validity = t('core', 'Invalid remote URL.')
+ }
+ input.value!.$el.querySelector('input')!.setCustomValidity(validity)
+ input.value!.$el.querySelector('input')!.reportValidity()
+})
+
+/**
+ * Create a federated share for the current share
+ */
+async function createFederatedShare() {
+ loading.value = true
+
+ try {
+ const url = generateUrl('/apps/federatedfilesharing/createFederatedShare')
+ const { data } = await axios.post<{ remoteUrl: string }>(url, {
+ shareWith: remoteUrl.value,
+ token: getSharingToken(),
+ })
+ if (data.remoteUrl.includes('://')) {
+ window.location.href = data.remoteUrl
+ } else {
+ window.location.href = `${window.location.protocol}//${data.remoteUrl}`
+ }
+ } catch (error) {
+ logger.error('Failed to create federated share', { error })
+ showError(t('files_sharing', 'Failed to add the public link to your Nextcloud'))
+ } finally {
+ loading.value = false
+ }
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
new file mode 100644
index 00000000000..a4451a38bbe
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ :icon="icon"
+ href="#"
+ :label="label"
+ @click="openDialog" />
+</template>
+
+<script setup lang="ts">
+import { spawnDialog } from '@nextcloud/dialogs'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Open the "create federated share" dialog
+ */
+function openDialog() {
+ spawnDialog(PublicPageMenuExternalDialog, { label: props.label })
+ emit('click')
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
new file mode 100644
index 00000000000..5f3a4883d6d
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ click-only
+ :icon="icon"
+ :href="href"
+ :label="label"
+ @click="onClick" />
+</template>
+
+<script setup lang="ts">
+import { showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Copy the href to the clipboard
+ */
+async function copyLink() {
+ try {
+ await window.navigator.clipboard.writeText(props.href)
+ showSuccess(t('core', 'Direct link copied'))
+ } catch {
+ // No secure context -> fallback to dialog
+ window.prompt(t('core', 'Please copy the link manually:'), props.href)
+ }
+}
+
+/**
+ * onclick handler to trigger the "copy link" action
+ * and emit the event so the menu can be closed
+ */
+function onClick() {
+ copyLink()
+ emit('click')
+}
+</script>
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
new file mode 100644
index 00000000000..d86192d156e
--- /dev/null
+++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
@@ -0,0 +1,107 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcModal v-if="isModalOpen"
+ id="unified-search"
+ :name="t('core', 'Custom date range')"
+ :show.sync="isModalOpen"
+ :size="'small'"
+ :clear-view-delay="0"
+ :title="t('core', 'Custom date range')"
+ @close="closeModal">
+ <!-- Custom date range -->
+ <div class="unified-search-custom-date-modal">
+ <h1>{{ t('core', 'Custom date range') }}</h1>
+ <div class="unified-search-custom-date-modal__pickers">
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'"
+ v-model="dateFilter.startFrom"
+ :label="t('core', 'Pick start date')"
+ type="date" />
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'"
+ v-model="dateFilter.endAt"
+ :label="t('core', 'Pick end date')"
+ type="date" />
+ </div>
+ <div class="unified-search-custom-date-modal__footer">
+ <NcButton @click="applyCustomRange">
+ {{ t('core', 'Search in date range') }}
+ <template #icon>
+ <CalendarRangeIcon :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+
+export default {
+ name: 'CustomDateRangeModal',
+ components: {
+ NcButton,
+ NcModal,
+ CalendarRangeIcon,
+ NcDateTimePicker,
+ },
+ props: {
+ isOpen: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dateFilter: { startFrom: null, endAt: null },
+ }
+ },
+ computed: {
+ isModalOpen: {
+ get() {
+ return this.isOpen
+ },
+ set(value) {
+ this.$emit('update:is-open', value)
+ },
+ },
+ },
+ methods: {
+ closeModal() {
+ this.isModalOpen = false
+ },
+ applyCustomRange() {
+ this.$emit('set:custom-date-range', this.dateFilter)
+ this.closeModal()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-custom-date-modal {
+ padding: 10px 20px 10px 20px;
+
+ h1 {
+ font-size: 16px;
+ font-weight: bolder;
+ line-height: 2em;
+ }
+
+ &__pickers {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: end;
+ }
+
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue
new file mode 100644
index 00000000000..4592adf08c9
--- /dev/null
+++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue
@@ -0,0 +1,242 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <a :href="resourceUrl || '#'"
+ class="unified-search__result"
+ :class="{
+ 'unified-search__result--focused': focused,
+ }"
+ @click="reEmitEvent"
+ @focus="reEmitEvent">
+
+ <!-- Icon describing the result -->
+ <div class="unified-search__result-icon"
+ :class="{
+ 'unified-search__result-icon--rounded': rounded,
+ 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
+ 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
+ [icon]: !loaded && !isIconUrl,
+ }"
+ :style="{
+ backgroundImage: isIconUrl ? `url(${icon})` : '',
+ }">
+
+ <img v-if="hasValidThumbnail"
+ v-show="loaded"
+ :src="thumbnailUrl"
+ alt=""
+ @error="onError"
+ @load="onLoad">
+ </div>
+
+ <!-- Title and sub-title -->
+ <span class="unified-search__result-content">
+ <span class="unified-search__result-line-one" :title="title">
+ <NcHighlight :text="title" :search="query" />
+ </span>
+ <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
+ </span>
+ </a>
+</template>
+
+<script>
+import NcHighlight from '@nextcloud/vue/components/NcHighlight'
+
+export default {
+ name: 'LegacySearchResult',
+
+ components: {
+ NcHighlight,
+ },
+
+ props: {
+ thumbnailUrl: {
+ type: String,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ subline: {
+ type: String,
+ default: null,
+ },
+ resourceUrl: {
+ type: String,
+ default: null,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ rounded: {
+ type: Boolean,
+ default: false,
+ },
+ query: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Only used for the first result as a visual feedback
+ * so we can keep the search input focused but pressing
+ * enter still opens the first result
+ */
+ focused: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
+ loaded: false,
+ }
+ },
+
+ computed: {
+ isIconUrl() {
+ // If we're facing an absolute url
+ if (this.icon.startsWith('/')) {
+ return true
+ }
+
+ // Otherwise, let's check if this is a valid url
+ try {
+ // eslint-disable-next-line no-new
+ new URL(this.icon)
+ } catch {
+ return false
+ }
+ return true
+ },
+ },
+
+ watch: {
+ // Make sure to reset state on change even when vue recycle the component
+ thumbnailUrl() {
+ this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
+ this.loaded = false
+ },
+ },
+
+ methods: {
+ reEmitEvent(e) {
+ this.$emit(e.type, e)
+ },
+
+ /**
+ * If the image fails to load, fallback to iconClass
+ */
+ onError() {
+ this.hasValidThumbnail = false
+ },
+
+ onLoad() {
+ this.loaded = true
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$clickable-area: 44px;
+$margin: 10px;
+
+.unified-search__result {
+ display: flex;
+ align-items: center;
+ height: $clickable-area;
+ padding: $margin;
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &--focused {
+ background-color: var(--color-background-hover);
+ }
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
+
+ * {
+ cursor: pointer;
+ }
+
+ &-icon {
+ overflow: hidden;
+ width: $clickable-area;
+ height: $clickable-area;
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 32px;
+ &--rounded {
+ border-radius: math.div($clickable-area, 2);
+ }
+ &--no-preview {
+ background-size: 32px;
+ }
+ &--with-thumbnail {
+ background-size: cover;
+ }
+ &--with-thumbnail:not(&--rounded) {
+ // compensate for border
+ max-width: $clickable-area - 2px;
+ max-height: $clickable-area - 2px;
+ border: 1px solid var(--color-border);
+ }
+
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
+
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+
+ &-icon,
+ &-actions {
+ flex: 0 0 $clickable-area;
+ }
+
+ &-content {
+ display: flex;
+ align-items: center;
+ flex: 1 1 100%;
+ flex-wrap: wrap;
+ // Set to minimum and gro from it
+ min-width: 0;
+ padding-inline-start: $margin;
+ }
+
+ &-line-one,
+ &-line-two {
+ overflow: hidden;
+ flex: 1 1 100%;
+ margin: 1px 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ // Use the same color as the `a`
+ color: inherit;
+ font-size: inherit;
+ }
+ &-line-two {
+ opacity: .7;
+ font-size: var(--default-font-size);
+ }
+}
+
+</style>
diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue
new file mode 100644
index 00000000000..e08ddd58a4b
--- /dev/null
+++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue
@@ -0,0 +1,79 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="chip">
+ <span class="icon">
+ <slot name="icon" />
+ <span v-if="pretext.length"> {{ pretext }} : </span>
+ </span>
+ <span class="text">{{ text }}</span>
+ <span class="close-icon" @click="deleteChip">
+ <CloseIcon :size="18" />
+ </span>
+ </div>
+</template>
+
+<script>
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+
+export default {
+ name: 'SearchFilterChip',
+ components: {
+ CloseIcon,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ pretext: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ deleteChip() {
+ this.$emit('delete', this.filter)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.chip {
+ display: flex;
+ align-items: center;
+ padding: 2px 4px;
+ border: 1px solid var(--color-primary-element-light);
+ border-radius: 20px;
+ background-color: var(--color-primary-element-light);
+ margin: 2px;
+
+ .icon {
+ display: flex;
+ align-items: center;
+ padding-inline-end: 5px;
+
+ img {
+ width: 20px;
+ padding: 2px;
+ border-radius: 20px;
+ filter: var(--background-invert-if-bright);
+ }
+ }
+
+ .text {
+ margin: 0 2px;
+ }
+
+ .close-icon {
+ cursor: pointer ;
+
+ :hover {
+ filter: invert(20%);
+ }
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue
index 0b8b6c8b33e..4f33fbd54cc 100644
--- a/core/src/components/UnifiedSearch/SearchResult.vue
+++ b/core/src/components/UnifiedSearch/SearchResult.vue
@@ -1,73 +1,44 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <a :href="resourceUrl || '#'"
- class="unified-search__result"
- :class="{
- 'unified-search__result--focused': focused,
- }"
- @click="reEmitEvent"
- @focus="reEmitEvent">
-
- <!-- Icon describing the result -->
- <div class="unified-search__result-icon"
- :class="{
- 'unified-search__result-icon--rounded': rounded,
- 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
- 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
- [icon]: !loaded && !isIconUrl,
- }"
- :style="{
- backgroundImage: isIconUrl ? `url(${icon})` : '',
- }">
-
- <img v-if="hasValidThumbnail"
- v-show="loaded"
- :src="thumbnailUrl"
- alt=""
- @error="onError"
- @load="onLoad">
- </div>
-
- <!-- Title and sub-title -->
- <span class="unified-search__result-content">
- <span class="unified-search__result-line-one" :title="title">
- <NcHighlight :text="title" :search="query" />
- </span>
- <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
- </span>
- </a>
+ <NcListItem class="result-item"
+ :name="title"
+ :bold="false"
+ :href="resourceUrl"
+ target="_self">
+ <template #icon>
+ <div aria-hidden="true"
+ class="result-item__icon"
+ :class="{
+ 'result-item__icon--rounded': rounded,
+ 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
+ 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
+ [icon]: !isValidIconOrPreviewUrl(icon),
+ }"
+ :style="{
+ backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
+ }">
+ <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
+ :src="thumbnailUrl"
+ @error="thumbnailErrorHandler">
+ </div>
+ </template>
+ <template #subname>
+ {{ subline }}
+ </template>
+ </NcListItem>
</template>
<script>
-import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
export default {
name: 'SearchResult',
-
components: {
- NcHighlight,
+ NcListItem,
},
-
props: {
thumbnailUrl: {
type: String,
@@ -108,111 +79,71 @@ export default {
default: false,
},
},
-
data() {
return {
- hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
- loaded: false,
+ thumbnailHasError: false,
}
},
-
- computed: {
- isIconUrl() {
- // If we're facing an absolute url
- if (this.icon.startsWith('/')) {
- return true
- }
-
- // Otherwise, let's check if this is a valid url
- try {
- // eslint-disable-next-line no-new
- new URL(this.icon)
- } catch {
- return false
- }
- return true
- },
- },
-
watch: {
- // Make sure to reset state on change even when vue recycle the component
thumbnailUrl() {
- this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
- this.loaded = false
+ this.thumbnailHasError = false
},
},
-
methods: {
- reEmitEvent(e) {
- this.$emit(e.type, e)
- },
-
- /**
- * If the image fails to load, fallback to iconClass
- */
- onError() {
- this.hasValidThumbnail = false
+ isValidIconOrPreviewUrl(url) {
+ return /^https?:\/\//.test(url) || url.startsWith('/')
},
-
- onLoad() {
- this.loaded = true
+ thumbnailErrorHandler() {
+ this.thumbnailHasError = true
},
},
}
</script>
<style lang="scss" scoped>
-@use "sass:math";
-
-$clickable-area: 44px;
-$margin: 10px;
-
-.unified-search__result {
- display: flex;
- align-items: center;
- height: $clickable-area;
- padding: $margin;
- border-bottom: 1px solid var(--color-border);
- border-radius: var(--border-radius-large) !important;
-
- // Load more entry,
- &:last-child {
- border-bottom: none;
- }
-
- &--focused,
- &:active,
- &:hover,
- &:focus {
- background-color: var(--color-background-hover);
- }
+.result-item {
+ :deep(a) {
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
- * {
- cursor: pointer;
+ * {
+ cursor: pointer;
+ }
}
- &-icon {
+ &__icon {
overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
border-radius: var(--border-radius);
background-repeat: no-repeat;
background-position: center center;
background-size: 32px;
+
&--rounded {
- border-radius: math.div($clickable-area, 2);
+ border-radius: calc(var(--default-clickable-area) / 2);
}
+
&--no-preview {
background-size: 32px;
}
+
&--with-thumbnail {
background-size: cover;
}
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
+
+ &--with-thumbnail:not(#{&}--rounded) {
border: 1px solid var(--color-border);
+ // compensate for border
+ max-height: calc(var(--default-clickable-area) - 2px);
+ max-width: calc(var(--default-clickable-area) - 2px);
}
img {
@@ -224,37 +155,5 @@ $margin: 10px;
object-position: center;
}
}
-
- &-icon,
- &-actions {
- flex: 0 0 $clickable-area;
- }
-
- &-content {
- display: flex;
- align-items: center;
- flex: 1 1 100%;
- flex-wrap: wrap;
- // Set to minimum and gro from it
- min-width: 0;
- padding-left: $margin;
- }
-
- &-line-one,
- &-line-two {
- overflow: hidden;
- flex: 1 1 100%;
- margin: 1px 0;
- white-space: nowrap;
- text-overflow: ellipsis;
- // Use the same color as the `a`
- color: inherit;
- font-size: inherit;
- }
- &-line-two {
- opacity: .7;
- font-size: var(--default-font-size);
- }
}
-
</style>
diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
index d2a297a0a37..aec2791d8e4 100644
--- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
+++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<ul>
<!-- Placeholder animation -->
diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
new file mode 100644
index 00000000000..d7abb6ffdbb
--- /dev/null
+++ b/core/src/components/UnifiedSearch/SearchableList.vue
@@ -0,0 +1,157 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcPopover :shown="opened"
+ @show="opened = true"
+ @hide="opened = false">
+ <template #trigger>
+ <slot ref="popoverTrigger" name="trigger" />
+ </template>
+ <div class="searchable-list__wrapper">
+ <NcTextField :value.sync="searchTerm"
+ :label="labelText"
+ trailing-button-icon="close"
+ :show-trailing-button="searchTerm !== ''"
+ @update:value="searchTermChanged"
+ @trailing-button-click="clearSearch">
+ <IconMagnify :size="20" />
+ </NcTextField>
+ <ul v-if="filteredList.length > 0" class="searchable-list__list">
+ <li v-for="element in filteredList"
+ :key="element.id"
+ :title="element.displayName"
+ role="button">
+ <NcButton alignment="start"
+ type="tertiary"
+ :wide="true"
+ @click="itemSelected(element)">
+ <template #icon>
+ <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" />
+ <NcAvatar v-else
+ :is-no-user="true"
+ :display-name="element.displayName"
+ :show-user-status="false" />
+ </template>
+ {{ element.displayName }}
+ </NcButton>
+ </li>
+ </ul>
+ <div v-else class="searchable-list__empty-content">
+ <NcEmptyContent :name="emptyContentText">
+ <template #icon>
+ <IconAlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+ </div>
+ </div>
+ </NcPopover>
+</template>
+
+<script>
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcPopover from '@nextcloud/vue/components/NcPopover'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import IconMagnify from 'vue-material-design-icons/Magnify.vue'
+
+export default {
+ name: 'SearchableList',
+
+ components: {
+ IconMagnify,
+ IconAlertCircleOutline,
+ NcAvatar,
+ NcButton,
+ NcEmptyContent,
+ NcPopover,
+ NcTextField,
+ },
+
+ props: {
+ labelText: {
+ type: String,
+ default: 'this is a label',
+ },
+
+ searchList: {
+ type: Array,
+ required: true,
+ },
+
+ emptyContentText: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ opened: false,
+ error: false,
+ searchTerm: '',
+ }
+ },
+
+ computed: {
+ filteredList() {
+ return this.searchList.filter((element) => {
+ if (!this.searchTerm.toLowerCase().length) {
+ return true
+ }
+ return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
+ })
+ },
+ },
+
+ methods: {
+ clearSearch() {
+ this.searchTerm = ''
+ },
+ itemSelected(element) {
+ this.$emit('item-selected', element)
+ this.clearSearch()
+ this.opened = false
+ },
+ searchTermChanged(term) {
+ this.$emit('search-term-change', term)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.searchable-list {
+ &__wrapper {
+ padding: calc(var(--default-grid-baseline) * 3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 250px;
+ }
+
+ &__list {
+ width: 100%;
+ max-height: 284px;
+ overflow-y: auto;
+ margin-top: var(--default-grid-baseline);
+ padding: var(--default-grid-baseline);
+
+ :deep(.button-vue) {
+ border-radius: var(--border-radius-large) !important;
+ span {
+ font-weight: initial;
+ }
+ }
+ }
+
+ &__empty-content {
+ margin-top: calc(var(--default-grid-baseline) * 3);
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
new file mode 100644
index 00000000000..171eada8a06
--- /dev/null
+++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
@@ -0,0 +1,166 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <Transition>
+ <div v-if="open"
+ class="local-unified-search animated-width"
+ :class="{ 'local-unified-search--open': open }">
+ <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder -->
+ <NcInputField ref="searchInput"
+ class="local-unified-search__input animated-width"
+ :aria-label="t('core', 'Search in current app')"
+ :placeholder="t('core', 'Search in current app')"
+ show-trailing-button
+ :trailing-button-label="t('core', 'Clear search')"
+ :value="query"
+ @update:value="$emit('update:query', $event)"
+ @trailing-button-click="clearAndCloseSearch">
+ <template #trailing-button-icon>
+ <NcIconSvgWrapper :path="mdiClose" />
+ </template>
+ </NcInputField>
+
+ <NcButton ref="searchGlobalButton"
+ class="local-unified-search__global-search"
+ :aria-label="t('core', 'Search everywhere')"
+ :title="t('core', 'Search everywhere')"
+ type="tertiary-no-background"
+ @click="$emit('global-search')">
+ <template v-if="!isMobile" #default>
+ {{ t('core', 'Search everywhere') }}
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCloudSearchOutline" />
+ </template>
+ </NcButton>
+ </div>
+ </Transition>
+</template>
+
+<script lang="ts" setup>
+import type { ComponentPublicInstance } from 'vue'
+import { mdiCloudSearchOutline, mdiClose } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { useElementSize } from '@vueuse/core'
+import { computed, ref, watchEffect } from 'vue'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+
+const props = defineProps<{
+ query: string,
+ open: boolean
+}>()
+
+const emit = defineEmits<{
+ (e: 'update:open', open: boolean): void
+ (e: 'update:query', query: string): void
+ (e: 'global-search'): void
+}>()
+
+// Hacky type until the library provides real Types
+type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }>
+/** The input field component */
+const searchInput = ref<FocusableComponent>()
+/** When the search bar is opened we focus the input */
+watchEffect(() => {
+ if (props.open && searchInput.value) {
+ searchInput.value.focus()
+ }
+})
+
+/** Current window size is below the "mobile" breakpoint (currently 1024px) */
+const isMobile = useIsMobile()
+
+const searchGlobalButton = ref<ComponentPublicInstance>()
+/** Width of the search global button, used to resize the input field */
+const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton)
+const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)')
+
+/**
+ * Clear the search query and close the search bar
+ */
+function clearAndCloseSearch() {
+ emit('update:query', '')
+ emit('update:open', false)
+}
+</script>
+
+<style scoped lang="scss">
+.local-unified-search {
+ --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw);
+ box-sizing: border-box;
+ position: relative;
+ height: var(--header-height);
+ width: var(--local-search-width);
+ display: flex;
+ align-items: center;
+ // Ensure it overlays the other entries
+ z-index: 10;
+ // add some padding for the focus visible outline
+ padding-inline: var(--border-width-input-focused);
+ // hide the overflow - needed for the transition
+ overflow: hidden;
+ // Ensure the position is fixed also during "position: absolut" (transition)
+ inset-inline-end: 0;
+
+ #{&} &__global-search {
+ position: absolute;
+ inset-inline-end: var(--default-clickable-area);
+ }
+
+ #{&} &__input {
+ box-sizing: border-box;
+ // override some nextcloud-vue styles
+ margin: 0;
+ width: var(--local-search-width);
+
+ // Fixup the spacing so we can fit in the "search globally" button
+ // this can break at any time the component library changes
+ :deep(input) {
+ // search global width + close button width
+ padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area));
+ }
+ }
+}
+
+.animated-width {
+ transition: width var(--animation-quick) linear;
+}
+
+// Make the position absolute during the transition
+// this is needed to "hide" the button behind it
+.v-leave-active {
+ position: absolute !important;
+}
+
+.v-enter,
+.v-leave-to {
+ &.local-unified-search {
+ // Start with only the overlay button
+ --local-search-width: var(--clickable-area-large);
+ }
+}
+
+@media screen and (max-width: 500px) {
+ .local-unified-search.local-unified-search--open {
+ // 100% but still show the menu toggle on the very right
+ --local-search-width: 100vw;
+ padding-inline: var(--default-grid-baseline);
+ }
+
+ // when open we need to position it absolute to allow overlay the full bar
+ :global(.unified-search-menu:has(.local-unified-search--open)) {
+ position: absolute !important;
+ inset-inline: 0;
+ }
+ // Hide all other entries, especially the user menu as it might leak pixels
+ :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) {
+ display: none;
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
new file mode 100644
index 00000000000..e59058bc0f0
--- /dev/null
+++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
@@ -0,0 +1,838 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog id="unified-search"
+ ref="unifiedSearchModal"
+ content-classes="unified-search-modal__content"
+ dialog-classes="unified-search-modal"
+ :name="t('core', 'Unified search')"
+ :open="open"
+ size="normal"
+ @update:open="onUpdateOpen">
+ <!-- Modal for picking custom time range -->
+ <CustomDateRangeModal :is-open="showDateRangeModal"
+ class="unified-search__date-range"
+ @set:custom-date-range="setCustomDateRange"
+ @update:is-open="showDateRangeModal = $event" />
+
+ <!-- Unified search form -->
+ <div class="unified-search-modal__header">
+ <NcInputField ref="searchInput"
+ data-cy-unified-search-input
+ :value.sync="searchQuery"
+ type="text"
+ :label="t('core', 'Search apps, files, tags, messages') + '...'"
+ @update:value="debouncedFind" />
+ <div class="unified-search-modal__filters" data-cy-unified-search-filters>
+ <NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places">
+ <template #icon>
+ <IconListBox :size="20" />
+ </template>
+ <!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults.
+ provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. -->
+ <NcActionButton v-for="provider in providers"
+ :key="`${provider.id}-${provider.name.replace(/\s/g, '')}`"
+ :disabled="provider.disabled"
+ @click="addProviderFilter(provider)">
+ <template #icon>
+ <img :src="provider.icon" class="filter-button__icon" alt="">
+ </template>
+ {{ provider.name }}
+ </NcActionButton>
+ </NcActions>
+ <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date">
+ <template #icon>
+ <IconCalendarRange :size="20" />
+ </template>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
+ {{ t('core', 'Today') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
+ {{ t('core', 'Last 7 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
+ {{ t('core', 'Last 30 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
+ {{ t('core', 'This year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
+ {{ t('core', 'Last year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
+ {{ t('core', 'Custom date range') }}
+ </NcActionButton>
+ </NcActions>
+ <SearchableList :label-text="t('core', 'Search people')"
+ :search-list="userContacts"
+ :empty-content-text="t('core', 'Not found')"
+ data-cy-unified-search-filter="people"
+ @search-term-change="debouncedFilterContacts"
+ @item-selected="applyPersonFilter">
+ <template #trigger>
+ <NcButton>
+ <template #icon>
+ <IconAccountGroup :size="20" />
+ </template>
+ {{ t('core', 'People') }}
+ </NcButton>
+ </template>
+ </SearchableList>
+ <NcButton v-if="localSearch" data-cy-unified-search-filter="current-view" @click="searchLocally">
+ {{ t('core', 'Filter in current view') }}
+ <template #icon>
+ <IconFilter :size="20" />
+ </template>
+ </NcButton>
+ <NcCheckboxRadioSwitch v-if="hasExternalResources"
+ v-model="searchExternalResources"
+ type="switch"
+ class="unified-search-modal__search-external-resources"
+ :class="{'unified-search-modal__search-external-resources--aligned': localSearch}">
+ {{ t('core', 'Search connected services') }}
+ </NcCheckboxRadioSwitch>
+ </div>
+ <div class="unified-search-modal__filters-applied">
+ <FilterChip v-for="filter in filters"
+ :key="filter.id"
+ :text="filter.name ?? filter.text"
+ :pretext="''"
+ @delete="removeFilter(filter)">
+ <template #icon>
+ <NcAvatar v-if="filter.type === 'person'"
+ :user="filter.user"
+ :size="24"
+ :disable-menu="true"
+ :show-user-status="false"
+ :hide-favorite="false" />
+ <IconCalendarRange v-else-if="filter.type === 'date'" />
+ <img v-else :src="filter.icon" alt="">
+ </template>
+ </FilterChip>
+ </div>
+ </div>
+
+ <div v-if="showEmptyContentInfo" class="unified-search-modal__no-content">
+ <NcEmptyContent :name="emptyContentMessage">
+ <template #icon>
+ <IconMagnify :size="64" />
+ </template>
+ </NcEmptyContent>
+ </div>
+
+ <div v-else class="unified-search-modal__results">
+ <h3 class="hidden-visually">
+ {{ t('core', 'Results') }}
+ </h3>
+ <div v-for="providerResult in results" :key="providerResult.id" class="result">
+ <h4 :id="`unified-search-result-${providerResult.id}`" class="result-title">
+ {{ providerResult.name }}
+ </h4>
+ <ul class="result-items" :aria-labelledby="`unified-search-result-${providerResult.id}`">
+ <SearchResult v-for="(result, index) in providerResult.results"
+ :key="index"
+ v-bind="result" />
+ </ul>
+ <div class="result-footer">
+ <NcButton v-if="providerResult.results.length === providerResult.limit" type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
+ {{ t('core', 'Load more results') }}
+ <template #icon>
+ <IconDotsHorizontal :size="20" />
+ </template>
+ </NcButton>
+ <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
+ {{ t('core', 'Search in') }} {{ providerResult.name }}
+ <template #icon>
+ <IconArrowRight :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </div>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { subscribe } from '@nextcloud/event-bus'
+import { translate as t } from '@nextcloud/l10n'
+import { useBrowserLocation } from '@vueuse/core'
+import { defineComponent } from 'vue'
+import { getProviders, search as unifiedSearch, getContacts } from '../../services/UnifiedSearchService.js'
+import { useSearchStore } from '../../store/unified-search-external-filters.js'
+
+import debounce from 'debounce'
+import { unifiedSearchLogger } from '../../logger'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue'
+import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue'
+import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
+import IconFilter from 'vue-material-design-icons/Filter.vue'
+import IconListBox from 'vue-material-design-icons/ListBox.vue'
+import IconMagnify from 'vue-material-design-icons/Magnify.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+import CustomDateRangeModal from './CustomDateRangeModal.vue'
+import FilterChip from './SearchFilterChip.vue'
+import SearchableList from './SearchableList.vue'
+import SearchResult from './SearchResult.vue'
+
+export default defineComponent({
+ name: 'UnifiedSearchModal',
+ components: {
+ IconArrowRight,
+ IconAccountGroup,
+ IconCalendarRange,
+ IconDotsHorizontal,
+ IconFilter,
+ IconListBox,
+ IconMagnify,
+
+ CustomDateRangeModal,
+ FilterChip,
+ NcActions,
+ NcActionButton,
+ NcAvatar,
+ NcButton,
+ NcEmptyContent,
+ NcDialog,
+ NcInputField,
+ NcCheckboxRadioSwitch,
+ SearchableList,
+ SearchResult,
+ },
+
+ props: {
+ /**
+ * Open state of the modal
+ */
+ open: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * The current query string
+ */
+ query: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * If the current page / app supports local search
+ */
+ localSearch: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ emits: ['update:open', 'update:query'],
+
+ setup() {
+ /**
+ * Reactive version of window.location
+ */
+ const currentLocation = useBrowserLocation()
+ const searchStore = useSearchStore()
+ return {
+ t,
+
+ currentLocation,
+ externalFilters: searchStore.externalFilters,
+ }
+ },
+
+ data() {
+ return {
+ providers: [],
+ providerActionMenuIsOpen: false,
+ dateActionMenuIsOpen: false,
+ providerResultLimit: 5,
+ dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
+ personFilter: { id: 'person', type: 'person', name: '' },
+ filteredProviders: [],
+ searching: false,
+ searchQuery: '',
+ lastSearchQuery: '',
+ placessearchTerm: '',
+ dateTimeFilter: null,
+ filters: [],
+ results: [],
+ contacts: [],
+ showDateRangeModal: false,
+ internalIsVisible: this.open,
+ initialized: false,
+ searchExternalResources: false,
+ }
+ },
+
+ computed: {
+ isEmptySearch() {
+ return this.searchQuery.length === 0
+ },
+
+ hasNoResults() {
+ return !this.isEmptySearch && this.results.length === 0
+ },
+
+ showEmptyContentInfo() {
+ return this.isEmptySearch || this.hasNoResults
+ },
+
+ emptyContentMessage() {
+ if (this.searching && this.hasNoResults) {
+ return t('core', 'Searching …')
+ }
+ if (this.isEmptySearch) {
+ return t('core', 'Start typing to search')
+ }
+ return t('core', 'No matching results')
+ },
+
+ userContacts() {
+ return this.contacts
+ },
+
+ debouncedFind() {
+ return debounce(this.find, 300)
+ },
+
+ debouncedFilterContacts() {
+ return debounce(this.filterContacts, 300)
+ },
+
+ hasExternalResources() {
+ return this.providers.some(provider => provider.isExternalProvider)
+ },
+ },
+
+ watch: {
+ open() {
+ // Load results when opened with already filled query
+ if (this.open) {
+ this.focusInput()
+ if (!this.initialized) {
+ Promise.all([getProviders(), getContacts({ searchTerm: '' })])
+ .then(([providers, contacts]) => {
+ this.providers = this.groupProvidersByApp([...providers, ...this.externalFilters])
+ this.contacts = this.mapContacts(contacts)
+ unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts })
+ this.initialized = true
+ })
+ .catch((error) => {
+ unifiedSearchLogger.error(error)
+ })
+ }
+ if (this.searchQuery) {
+ this.find(this.searchQuery)
+ }
+ }
+ },
+
+ query: {
+ immediate: true,
+ handler() {
+ this.searchQuery = this.query
+ },
+ },
+
+ searchQuery: {
+ handler() {
+ this.$emit('update:query', this.searchQuery)
+ },
+ },
+
+ searchExternalResources() {
+ if (this.searchQuery) {
+ this.find(this.searchQuery)
+ }
+ },
+ },
+
+ mounted() {
+ subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
+ },
+ methods: {
+ /**
+ * On close the modal is closed and the query is reset
+ * @param open The new open state
+ */
+ onUpdateOpen(open: boolean) {
+ if (!open) {
+ this.$emit('update:open', false)
+ this.$emit('update:query', '')
+ }
+ },
+
+ /**
+ * Only close the modal but keep the query for in-app search
+ */
+ searchLocally() {
+ this.$emit('update:query', this.searchQuery)
+ this.$emit('update:open', false)
+ },
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.searchInput?.focus()
+ })
+ },
+ find(query: string, providersToSearchOverride = null) {
+ if (query.length === 0) {
+ this.results = []
+ this.searching = false
+ return
+ }
+
+ // Reset the provider result limit when performing a new search
+ if (query !== this.lastSearchQuery) {
+ this.providerResultLimit = 5
+ }
+ this.lastSearchQuery = query
+
+ this.searching = true
+ const newResults = []
+ const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers)
+ const searchProvider = (provider) => {
+ const params = {
+ type: provider.searchFrom ?? provider.id,
+ query,
+ cursor: null,
+ extraQueries: provider.extraParams,
+ }
+
+ // This block of filter checks should be dynamic somehow and should be handled in
+ // nextcloud/search lib
+ const activeFilters = this.filters.filter(filter => {
+ return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
+ })
+
+ activeFilters.forEach(filter => {
+ switch (filter.type) {
+ case 'date':
+ if (provider.filters?.since && provider.filters?.until) {
+ params.since = this.dateFilter.startFrom
+ params.until = this.dateFilter.endAt
+ }
+ break
+ case 'person':
+ if (provider.filters?.person) {
+ params.person = this.personFilter.user
+ }
+ break
+ }
+ })
+
+ if (this.providerResultLimit > 5) {
+ params.limit = this.providerResultLimit
+ unifiedSearchLogger.debug('Limiting search to', params.limit)
+ }
+
+ const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider
+ const wasManuallySelected = this.filteredProviders.some(filteredProvider => filteredProvider.id === provider.id)
+ // if the provider is an external resource and the user has not manually selected it, skip the search
+ if (shouldSkipSearch && !wasManuallySelected) {
+ this.searching = false
+ return
+ }
+
+ const request = unifiedSearch(params).request
+
+ request().then((response) => {
+ newResults.push({
+ ...provider,
+ results: response.data.ocs.data.entries,
+ limit: params.limit ?? 5,
+ })
+
+ unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })
+
+ this.updateResults(newResults)
+ this.searching = false
+ })
+ }
+
+ providersToSearch.forEach(searchProvider)
+ },
+ updateResults(newResults) {
+ let updatedResults = [...this.results]
+ // If filters are applied, remove any previous results for providers that are not in current filters
+ if (this.filters.length > 0) {
+ updatedResults = updatedResults.filter(result => {
+ return this.filters.some(filter => filter.id === result.id)
+ })
+ }
+ // Process the new results
+ newResults.forEach(newResult => {
+ const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
+ if (existingResultIndex !== -1) {
+ if (newResult.results.length === 0) {
+ // If the new results data has no matches for and existing result, remove the existing result
+ updatedResults.splice(existingResultIndex, 1)
+ } else {
+ // If input triggered a change in existing results, update existing result
+ updatedResults.splice(existingResultIndex, 1, newResult)
+ }
+ } else if (newResult.results.length > 0) {
+ // Push the new result to the array only if its results array is not empty
+ updatedResults.push(newResult)
+ }
+ })
+ const sortedResults = updatedResults.slice(0)
+ // Order results according to provider preference
+ sortedResults.sort((a, b) => {
+ const aProvider = this.providers.find(provider => provider.id === a.id)
+ const bProvider = this.providers.find(provider => provider.id === b.id)
+ const aOrder = aProvider ? aProvider.order : 0
+ const bOrder = bProvider ? bProvider.order : 0
+ return aOrder - bOrder
+ })
+ this.results = sortedResults
+ },
+ mapContacts(contacts) {
+ return contacts.map(contact => {
+ return {
+ // id: contact.id,
+ // name: '',
+ displayName: contact.fullName,
+ isNoUser: false,
+ subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
+ icon: '',
+ user: contact.id,
+ isUser: contact.isUser,
+ }
+ })
+ },
+ filterContacts(query) {
+ getContacts({ searchTerm: query }).then((contacts) => {
+ this.contacts = this.mapContacts(contacts)
+ unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts })
+ })
+ },
+ applyPersonFilter(person) {
+
+ const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
+ if (existingPersonFilter === -1) {
+ this.personFilter.id = person.id
+ this.personFilter.user = person.user
+ this.personFilter.name = person.displayName
+ this.filters.push(this.personFilter)
+ } else {
+ this.filters[existingPersonFilter].id = person.id
+ this.filters[existingPersonFilter].user = person.user
+ this.filters[existingPersonFilter].name = person.displayName
+ }
+
+ this.providers.forEach(async (provider, index) => {
+ this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['person']))
+ })
+
+ this.debouncedFind(this.searchQuery)
+ unifiedSearchLogger.debug('Person filter applied', { person })
+ },
+ async loadMoreResultsForProvider(provider) {
+ this.providerResultLimit += 5
+ this.find(this.searchQuery, [provider])
+ },
+ addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
+ unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
+ if (!providerFilter.id) return
+ if (providerFilter.isPluginFilter) {
+ // There is no way to know what should go into the callback currently
+ // Here we are passing isProviderFilterApplied (boolean) which is a flag sent to the plugin
+ // This is sent to the plugin so that depending on whether the filter is applied or not, the plugin can decide what to do
+ // TODO : In nextcloud/search, this should be a proper interface that the plugin can implement
+ const isProviderFilterApplied = this.filteredProviders.some(provider => provider.id === providerFilter.id)
+ providerFilter.callback(!isProviderFilterApplied)
+ }
+ this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
+ this.providerActionMenuIsOpen = false
+ // With the possibility for other apps to add new filters
+ // Resulting in a possible id/provider collision
+ // If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one.
+ const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id)
+ if (existingFilterIndex > -1) {
+ this.filteredProviders.splice(existingFilterIndex, 1)
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ }
+ this.filteredProviders.push({
+ ...providerFilter,
+ type: providerFilter.type || 'provider',
+ isPluginFilter: providerFilter.isPluginFilter || false,
+ })
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ unifiedSearchLogger.debug('Search filters (newly added)', { filters: this.filters })
+ this.debouncedFind(this.searchQuery)
+ },
+ removeFilter(filter) {
+ if (filter.type === 'provider') {
+ for (let i = 0; i < this.filteredProviders.length; i++) {
+ if (this.filteredProviders[i].id === filter.id) {
+ this.filteredProviders.splice(i, 1)
+ break
+ }
+ }
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters })
+
+ } else {
+ // Remove non provider filters such as date and person filters
+ for (let i = 0; i < this.filters.length; i++) {
+ if (this.filters[i].id === filter.id) {
+ this.filters.splice(i, 1)
+ this.enableAllProviders()
+ break
+ }
+ }
+ }
+ this.debouncedFind(this.searchQuery)
+ },
+ syncProviderFilters(firstArray, secondArray) {
+ // Create a copy of the first array to avoid modifying it directly.
+ const synchronizedArray = firstArray.slice()
+ // Remove items from the synchronizedArray that are not in the secondArray.
+ synchronizedArray.forEach((item, index) => {
+ const itemId = item.id
+ if (item.type === 'provider') {
+ if (!secondArray.some(secondItem => secondItem.id === itemId)) {
+ synchronizedArray.splice(index, 1)
+ }
+ }
+ })
+ // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
+ secondArray.forEach(secondItem => {
+ const itemId = secondItem.id
+ if (secondItem.type === 'provider') {
+ if (!synchronizedArray.some(item => item.id === itemId)) {
+ synchronizedArray.push(secondItem)
+ }
+ }
+ })
+
+ return synchronizedArray
+ },
+ updateDateFilter() {
+ const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
+ if (currFilterIndex !== -1) {
+ this.filters[currFilterIndex] = this.dateFilter
+ } else {
+ this.filters.push(this.dateFilter)
+ }
+
+ this.providers.forEach(async (provider, index) => {
+ this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until']))
+ })
+ this.debouncedFind(this.searchQuery)
+ },
+ applyQuickDateRange(range) {
+ this.dateActionMenuIsOpen = false
+ const today = new Date()
+ let startDate
+ let endDate
+
+ switch (range) {
+ case 'today':
+ // For 'Today', both start and end are set to today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Today')
+ break
+ case '7days':
+ // For 'Last 7 days', start date is 7 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 7 days')
+ break
+ case '30days':
+ // For 'Last 30 days', start date is 30 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 30 days')
+ break
+ case 'thisyear':
+ // For 'This year', start date is the first day of the year, end is the last day of the year
+ startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'This year')
+ break
+ case 'lastyear':
+ // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
+ startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Last year')
+ break
+ case 'custom':
+ this.showDateRangeModal = true
+ return
+ default:
+ return
+ }
+ this.dateFilter.startFrom = startDate
+ this.dateFilter.endAt = endDate
+ this.updateDateFilter()
+
+ },
+ setCustomDateRange(event) {
+ unifiedSearchLogger.debug('Custom date range', { range: event })
+ this.dateFilter.startFrom = event.startFrom
+ this.dateFilter.endAt = event.endAt
+ this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
+ this.updateDateFilter()
+ },
+ handlePluginFilter(addFilterEvent) {
+ unifiedSearchLogger.debug('Handling plugin filter', { addFilterEvent })
+ for (let i = 0; i < this.filteredProviders.length; i++) {
+ const provider = this.filteredProviders[i]
+ if (provider.id === addFilterEvent.id) {
+ provider.name = addFilterEvent.filterUpdateText
+ // Filters attached may only make sense with certain providers,
+ // So, find the provider attached, add apply the extra parameters to those providers only
+ const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id)
+ if (compatibleProviderIndex > -1) {
+ provider.extraParams = addFilterEvent.filterParams
+ this.filteredProviders[i] = provider
+ }
+ break
+ }
+ }
+ this.debouncedFind(this.searchQuery)
+ },
+ groupProvidersByApp(filters) {
+ const groupedByProviderApp = {}
+
+ filters.forEach(filter => {
+ const provider = filter.appId ? filter.appId : 'general'
+ if (!groupedByProviderApp[provider]) {
+ groupedByProviderApp[provider] = []
+ }
+ groupedByProviderApp[provider].push(filter)
+ })
+
+ const flattenedArray = []
+ Object.values(groupedByProviderApp).forEach(group => {
+ flattenedArray.push(...group)
+ })
+
+ return flattenedArray
+ },
+ async providerIsCompatibleWithFilters(provider, filterIds) {
+ return filterIds.every(filterId => provider.filters?.[filterId] !== undefined)
+ },
+ async enableAllProviders() {
+ this.providers.forEach(async (_, index) => {
+ this.providers[index].disabled = false
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.unified-search-modal .unified-search-modal__content) {
+ --dialog-height: min(80vh, 800px);
+ box-sizing: border-box;
+ height: var(--dialog-height);
+ max-height: var(--dialog-height);
+ min-height: var(--dialog-height);
+
+ display: flex;
+ flex-direction: column;
+ // No padding to prevent scrollbar misplacement
+ padding-inline: 0;
+}
+
+.unified-search-modal {
+ &__header {
+ // Add background to prevent leaking scrolled content (because of sticky position)
+ background-color: var(--color-main-background);
+ // Fix padding to have the input centered
+ padding-inline-end: 12px;
+ // Some padding to make elements scrolled under sticky position look nicer
+ padding-block-end: 12px;
+ // Make it sticky with the input margin for the label
+ position: sticky;
+ top: 6px;
+ }
+
+ &__filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ justify-content: start;
+ padding-top: 4px;
+ }
+
+ &__search-external-resources {
+ :deep(span.checkbox-content) {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ :deep(.checkbox-content__icon) {
+ margin: auto !important;
+ }
+
+ &--aligned {
+ margin-inline-start: auto;
+ }
+ }
+
+ &__filters-applied {
+ padding-top: 4px;
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__no-content {
+ display: flex;
+ align-items: center;
+ margin-top: 0.5em;
+ height: 70%;
+ }
+
+ &__results {
+ overflow: hidden scroll;
+ // Adjust padding to match container but keep the scrollbar on the very end
+ padding-inline: 0 12px;
+ padding-block: 0 12px;
+
+ .result {
+ &-title {
+ color: var(--color-primary-element);
+ font-size: 16px;
+ margin-block: 8px 4px;
+ }
+
+ &-footer {
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+ }
+ }
+
+ }
+}
+
+.filter-button__icon {
+ height: 20px;
+ width: 20px;
+ object-fit: contain;
+ filter: var(--background-invert-if-bright);
+ padding: 11px; // align with text to fit at least 44px
+}
+
+// Ensure modal is accessible on small devices
+@media only screen and (max-height: 400px) {
+ .unified-search-modal__results {
+ overflow: unset;
+ }
+}
+</style>
diff --git a/core/src/components/UserMenu.js b/core/src/components/UserMenu.js
index f82a303d1fd..5c488f2341e 100644
--- a/core/src/components/UserMenu.js
+++ b/core/src/components/UserMenu.js
@@ -1,60 +1,20 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import OC from '../OC'
+import Vue from 'vue'
-import $ from 'jquery'
+import AccountMenu from '../views/AccountMenu.vue'
export const setUp = () => {
- const $menu = $('#header #settings')
- // Using page terminoogy as below
- const $excludedPageClasses = [
- 'user-status-menu-item__header',
- ]
-
- // show loading feedback
- $menu.delegate('a', 'click', event => {
- let $page = $(event.target)
- if (!$page.is('a')) {
- $page = $page.closest('a')
- }
- if (event.which === 1 && !event.ctrlKey && !event.metaKey) {
- if (!$excludedPageClasses.includes($page.attr('class'))) {
- $page.find('img').remove()
- $page.find('div').remove() // prevent odd double-clicks
- $page.prepend($('<div></div>').addClass('icon-loading-small'))
- }
- } else {
- // Close navigation when opening menu entry in
- // a new tab
- OC.hideMenus(() => false)
- }
- })
-
- $menu.delegate('a', 'mouseup', event => {
- if (event.which === 2) {
- // Close navigation when opening app in
- // a new tab via middle click
- OC.hideMenus(() => false)
- }
- })
+ const mountPoint = document.getElementById('user-menu')
+ if (mountPoint) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ name: 'AccountMenuRoot',
+ el: mountPoint,
+ render: h => h(AccountMenu),
+ })
+ }
}
diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue
index 3d3ac25de6d..da387df0ff6 100644
--- a/core/src/components/login/LoginButton.vue
+++ b/core/src/components/login/LoginButton.vue
@@ -1,28 +1,13 @@
<!--
- - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcButton type="primary"
native-type="submit"
:wide="true"
+ :disabled="loading"
@click="$emit('click')">
{{ !loading ? value : valueLoading }}
<template #icon>
@@ -33,7 +18,9 @@
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
export default {
diff --git a/core/src/components/login/LoginForm.cy.ts b/core/src/components/login/LoginForm.cy.ts
new file mode 100644
index 00000000000..1b1aeda6306
--- /dev/null
+++ b/core/src/components/login/LoginForm.cy.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import LoginForm from './LoginForm.vue'
+
+describe('core: LoginForm', { testIsolation: true }, () => {
+ beforeEach(() => {
+ // Mock the required global state
+ cy.window().then(($window) => {
+ $window.OC = {
+ theme: {
+ name: 'J\'s cloud',
+ },
+ requestToken: 'request-token',
+ }
+ })
+ })
+
+ /**
+ * Ensure that characters like ' are not double HTML escaped.
+ * This was a bug in https://github.com/nextcloud/server/issues/34990
+ */
+ it('does not double escape special characters in product name', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('h2').contains('J\'s cloud')
+ })
+
+ it('fills username from props into form', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('input[name="user"]')
+ .should('exist')
+ .and('have.attr', 'id', 'user')
+
+ cy.get('input[name="user"]')
+ .should('have.value', 'test-user')
+ })
+
+ it('clears password after timeout', () => {
+ // mock timeout of 5 seconds
+ cy.window().then(($window) => {
+ const state = $window.document.createElement('input')
+ state.type = 'hidden'
+ state.id = 'initial-state-core-loginTimeout'
+ state.value = btoa(JSON.stringify(5))
+ $window.document.body.appendChild(state)
+ })
+
+ // mount forms
+ cy.mount(LoginForm)
+
+ cy.get('input[name="password"]')
+ .should('exist')
+ .type('MyPassword')
+
+ cy.get('input[name="password"]')
+ .should('have.value', 'MyPassword')
+
+ // Wait for timeout
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(5100)
+
+ cy.get('input[name="password"]')
+ .should('have.value', '')
+ })
+})
diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue
index c7b0a9259f9..8cbe55f1f68 100644
--- a/core/src/components/login/LoginForm.vue
+++ b/core/src/components/login/LoginForm.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form ref="loginForm"
@@ -32,6 +16,11 @@
type="warning">
{{ t('core', 'Please contact your administrator.') }}
</NcNoteCard>
+ <NcNoteCard v-if="csrfCheckFailed"
+ :heading="t('core', 'Session error')"
+ type="error">
+ {{ t('core', 'It appears your session token has expired, please refresh the page and try again.') }}
+ </NcNoteCard>
<NcNoteCard v-if="messages.length > 0">
<div v-for="(message, index) in messages"
:key="index">
@@ -52,25 +41,28 @@
<!-- the following div ensures that the spinner is always inside the #message div -->
<div style="clear: both;" />
</div>
- <h2 class="login-form__headline" data-login-form-headline v-html="headline" />
+ <h2 class="login-form__headline" data-login-form-headline>
+ {{ headlineText }}
+ </h2>
<NcTextField id="user"
ref="user"
- :label="t('core', 'Account name or email')"
- :label-visible="true"
+ :label="loginText"
name="user"
+ :maxlength="255"
:value.sync="user"
:class="{shake: invalidPassword}"
autocapitalize="none"
:spellchecking="false"
:autocomplete="autoCompleteAllowed ? 'username' : 'off'"
required
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
data-login-form-input-user
@change="updateUsername" />
<NcPasswordField id="password"
ref="password"
name="password"
- :label-visible="true"
:class="{shake: invalidPassword}"
:value.sync="password"
:spellchecking="false"
@@ -96,7 +88,7 @@
:value="timezoneOffset">
<input type="hidden"
name="requesttoken"
- :value="OC.requestToken">
+ :value="requestToken">
<input v-if="directLogin"
type="hidden"
name="direct"
@@ -106,12 +98,16 @@
</template>
<script>
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router'
+import debounce from 'debounce'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import AuthMixin from '../../mixins/auth.js'
import LoginButton from './LoginButton.vue'
export default {
@@ -124,6 +120,8 @@ export default {
NcNoteCard,
},
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -153,30 +151,61 @@ export default {
type: Boolean,
default: false,
},
+ emailStates: {
+ type: Array,
+ default() {
+ return []
+ },
+ },
},
- data() {
+ setup() {
+ // non reactive props
return {
- loading: false,
+ t,
+
+ // Disable escape and sanitize to prevent special characters to be html escaped
+ // For example "J's cloud" would be escaped to "J&#39; cloud". But we do not need escaping as Vue does this in `v-text` automatically
+ headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }),
+
+ loginTimeout: loadState('core', 'loginTimeout', 300),
+ requestToken: window.OC.requestToken,
timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone,
timezoneOffset: (-new Date().getTimezoneOffset() / 60),
- headline: t('core', 'Log in to {productName}', { productName: OC.theme.name }),
+ }
+ },
+
+ data() {
+ return {
+ loading: false,
user: '',
password: '',
}
},
computed: {
+ /**
+ * Reset the login form after a long idle time (debounced)
+ */
+ resetFormTimeout() {
+ // Infinite timeout, do nothing
+ if (this.loginTimeout <= 0) {
+ return () => {}
+ }
+ // Debounce for given timeout (in seconds so convert to milli seconds)
+ return debounce(this.handleResetForm, this.loginTimeout * 1000)
+ },
+
isError() {
return this.invalidPassword || this.userDisabled
|| this.throttleDelay > 5000
},
errorLabel() {
if (this.invalidPassword) {
- return t('core', 'Wrong username or password.')
+ return t('core', 'Wrong login or password.')
}
if (this.userDisabled) {
- return t('core', 'User disabled')
+ return t('core', 'This account is disabled')
}
if (this.throttleDelay > 5000) {
return t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.')
@@ -186,6 +215,9 @@ export default {
apacheAuthFailed() {
return this.errors.indexOf('apacheAuthFailed') !== -1
},
+ csrfCheckFailed() {
+ return this.errors.indexOf('csrfCheckFailed') !== -1
+ },
internalException() {
return this.errors.indexOf('internalexception') !== -1
},
@@ -201,6 +233,24 @@ export default {
loginActionUrl() {
return generateUrl('login')
},
+ emailEnabled() {
+ return this.emailStates ? this.emailStates.every((state) => state === '1') : 1
+ },
+ loginText() {
+ if (this.emailEnabled) {
+ return t('core', 'Account name or email')
+ }
+ return t('core', 'Account name')
+ },
+ },
+
+ watch: {
+ /**
+ * Reset form reset after the password was changed
+ */
+ password() {
+ this.resetFormTimeout()
+ },
},
mounted() {
@@ -213,10 +263,24 @@ export default {
},
methods: {
+ /**
+ * Handle reset of the login form after a long IDLE time
+ * This is recommended security behavior to prevent password leak on public devices
+ */
+ handleResetForm() {
+ this.password = ''
+ },
+
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+ submit(event) {
+ if (this.loading) {
+ // Prevent the form from being submitted twice
+ event.preventDefault()
+ return
+ }
+
this.loading = true
this.$emit('submit')
},
@@ -226,8 +290,9 @@ export default {
<style lang="scss" scoped>
.login-form {
- text-align: left;
+ text-align: start;
font-size: 1rem;
+ margin: 0;
&__fieldset {
width: 100%;
@@ -238,6 +303,12 @@ export default {
&__headline {
text-align: center;
+ overflow-wrap: anywhere;
+ }
+
+ // Only show the error state if the user interacted with the login box
+ :deep(input:invalid:not(:user-invalid)) {
+ border-color: var(--color-border-maxcontrast) !important;
}
}
</style>
diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue
index 455017b8683..bc4d25bf70f 100644
--- a/core/src/components/login/PasswordLessLoginForm.vue
+++ b/core/src/components/login/PasswordLessLoginForm.vue
@@ -1,68 +1,75 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form v-if="(isHttps || isLocalhost) && hasPublicKeyCredential"
+ <form v-if="(isHttps || isLocalhost) && supportsWebauthn"
ref="loginForm"
+ aria-labelledby="password-less-login-form-title"
+ class="password-less-login-form"
method="post"
name="login"
@submit.prevent="submit">
- <fieldset>
- <p class="grouptop groupbottom">
- <label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
- <input id="user"
- ref="user"
- v-model="user"
- type="text"
- name="user"
- :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
- :placeholder="t('core', 'Username or email')"
- :aria-label="t('core', 'Username or email')"
- required
- @change="$emit('update:username', user)">
- </p>
-
- <div v-if="!validCredentials" class="body-login-container update form__message-box">
- {{ t('core', 'Your account is not setup for passwordless login.') }}
- </div>
-
- <LoginButton v-if="validCredentials"
- :loading="loading"
- @click="authenticate" />
- </fieldset>
+ <h2 id="password-less-login-form-title">
+ {{ t('core', 'Log in with a device') }}
+ </h2>
+
+ <NcTextField required
+ :value="user"
+ :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
+ :error="!validCredentials"
+ :label="t('core', 'Login or email')"
+ :placeholder="t('core', 'Login or email')"
+ :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
+ @update:value="changeUsername" />
+
+ <LoginButton v-if="validCredentials"
+ :loading="loading"
+ @click="authenticate" />
</form>
- <div v-else-if="!hasPublicKeyCredential" class="body-login-container update">
- <InformationIcon size="70" />
- <h2>{{ t('core', 'Browser not supported') }}</h2>
- <p class="infogroup">
- {{ t('core', 'Passwordless authentication is not supported in your browser.') }}
- </p>
- </div>
- <div v-else-if="!isHttps && !isLocalhost" class="body-login-container update">
- <LockOpenIcon size="70" />
- <h2>{{ t('core', 'Your connection is not secure') }}</h2>
- <p class="infogroup">
- {{ t('core', 'Passwordless authentication is only available over a secure connection.') }}
- </p>
- </div>
+
+ <NcEmptyContent v-else-if="!isHttps && !isLocalhost"
+ :name="t('core', 'Your connection is not secure')"
+ :description="t('core', 'Passwordless authentication is only available over a secure connection.')">
+ <template #icon>
+ <LockOpenIcon />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else
+ :name="t('core', 'Browser not supported')"
+ :description="t('core', 'Passwordless authentication is not supported in your browser.')">
+ <template #icon>
+ <InformationIcon />
+ </template>
+ </NcEmptyContent>
</template>
-<script>
+<script type="ts">
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
+import { defineComponent } from 'vue'
import {
+ NoValidCredentials,
startAuthentication,
finishAuthentication,
-} from '../../services/WebAuthnAuthenticationService'
-import LoginButton from './LoginButton'
-import InformationIcon from 'vue-material-design-icons/Information'
-import LockOpenIcon from 'vue-material-design-icons/LockOpen'
+} from '../../services/WebAuthnAuthenticationService.ts'
-class NoValidCredentials extends Error {
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-}
+import InformationIcon from 'vue-material-design-icons/InformationOutline.vue'
+import LoginButton from './LoginButton.vue'
+import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
+import logger from '../../logger'
-export default {
+export default defineComponent({
name: 'PasswordLessLoginForm',
components: {
LoginButton,
InformationIcon,
LockOpenIcon,
+ NcEmptyContent,
+ NcTextField,
},
props: {
username: {
@@ -85,11 +92,14 @@ export default {
type: Boolean,
default: false,
},
- hasPublicKeyCredential: {
- type: Boolean,
- default: false,
- },
},
+
+ setup() {
+ return {
+ supportsWebauthn: browserSupportsWebAuthn(),
+ }
+ },
+
data() {
return {
user: this.username,
@@ -98,111 +108,33 @@ export default {
}
},
methods: {
- authenticate() {
- console.debug('passwordless login initiated')
+ async authenticate() {
+ // check required fields
+ if (!this.$refs.loginForm.checkValidity()) {
+ return
+ }
- this.getAuthenticationData(this.user)
- .then(publicKey => {
- console.debug(publicKey)
- return publicKey
- })
- .then(this.sign)
- .then(this.completeAuthentication)
- .catch(error => {
- if (error instanceof NoValidCredentials) {
- this.validCredentials = false
- return
- }
- console.debug(error)
- })
- },
- getAuthenticationData(uid) {
- const base64urlDecode = function(input) {
- // Replace non-url compatible chars with base64 standard chars
- input = input
- .replace(/-/g, '+')
- .replace(/_/g, '/')
+ console.debug('passwordless login initiated')
- // Pad out with standard base64 required padding characters
- const pad = input.length % 4
- if (pad) {
- if (pad === 1) {
- throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
- }
- input += new Array(5 - pad).join('=')
+ try {
+ const params = await startAuthentication(this.user)
+ await this.completeAuthentication(params)
+ } catch (error) {
+ if (error instanceof NoValidCredentials) {
+ this.validCredentials = false
+ return
}
-
- return window.atob(input)
+ logger.debug(error)
}
-
- return startAuthentication(uid)
- .then(publicKey => {
- console.debug('Obtained PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
-
- if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) {
- console.debug('No credentials found.')
- throw new NoValidCredentials()
- }
-
- publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
- publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) {
- return {
- ...data,
- id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)),
- }
- })
-
- console.debug('Converted PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
- return publicKey
- })
- .catch(error => {
- console.debug('Error while obtaining data')
- throw error
- })
},
- sign(publicKey) {
- const arrayToBase64String = function(a) {
- return window.btoa(String.fromCharCode(...a))
- }
-
- const arrayToString = function(a) {
- return String.fromCharCode(...a)
- }
-
- return navigator.credentials.get({ publicKey })
- .then(data => {
- console.debug(data)
- console.debug(new Uint8Array(data.rawId))
- console.debug(arrayToBase64String(new Uint8Array(data.rawId)))
- return {
- id: data.id,
- type: data.type,
- rawId: arrayToBase64String(new Uint8Array(data.rawId)),
- response: {
- authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
- clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
- signature: arrayToBase64String(new Uint8Array(data.response.signature)),
- userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null,
- },
- }
- })
- .then(challenge => {
- console.debug(challenge)
- return challenge
- })
- .catch(error => {
- console.debug('GOT AN ERROR!')
- console.debug(error) // Example: timeout, interaction refused...
- })
+ changeUsername(username) {
+ this.user = username
+ this.$emit('update:username', this.user)
},
completeAuthentication(challenge) {
- console.debug('TIME TO COMPLETE')
-
const redirectUrl = this.redirectUrl
- return finishAuthentication(JSON.stringify(challenge))
+ return finishAuthentication(challenge)
.then(({ defaultRedirectUrl }) => {
console.debug('Logged in redirecting')
// Redirect url might be false so || should be used instead of ??.
@@ -217,16 +149,14 @@ export default {
// noop
},
},
-}
+})
</script>
<style lang="scss" scoped>
- .body-login-container.update {
- margin: 15px 0;
-
- &.form__message-box {
- width: 240px;
- margin: 5px;
- }
- }
+.password-less-login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0;
+}
</style>
diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue
index ad86281b301..fee1deacc36 100644
--- a/core/src/components/login/ResetPassword.vue
+++ b/core/src/components/login/ResetPassword.vue
@@ -1,75 +1,68 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form class="login-form" @submit.prevent="submit">
- <fieldset class="login-form__fieldset">
- <NcTextField id="user"
- :value.sync="user"
- name="user"
- autocapitalize="off"
- :label="t('core', 'Account name or email')"
- :label-visible="true"
- required
- @change="updateUsername" />
- <LoginButton :value="t('core', 'Reset password')" />
-
- <NcNoteCard v-if="message === 'send-success'"
- type="success">
- {{ t('core', 'A password reset message has been sent to the email address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }}
- <br>
- {{ t('core', 'If it is not there ask your local administrator.') }}
- </NcNoteCard>
- <NcNoteCard v-else-if="message === 'send-error'"
- type="error">
- {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
- </NcNoteCard>
- <NcNoteCard v-else-if="message === 'reset-error'"
- type="error">
- {{ t('core', 'Password cannot be changed. Please contact your administrator.') }}
- </NcNoteCard>
-
- <a class="login-form__link"
- href="#"
- @click.prevent="$emit('abort')">
- {{ t('core', 'Back to login') }}
- </a>
- </fieldset>
+ <form class="reset-password-form" @submit.prevent="submit">
+ <h2>{{ t('core', 'Reset password') }}</h2>
+
+ <NcTextField id="user"
+ :value.sync="user"
+ name="user"
+ :maxlength="255"
+ autocapitalize="off"
+ :label="t('core', 'Login or email')"
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
+ required
+ @change="updateUsername" />
+
+ <LoginButton :loading="loading" :value="t('core', 'Reset password')" />
+
+ <NcButton type="tertiary" wide @click="$emit('abort')">
+ {{ t('core', 'Back to login') }}
+ </NcButton>
+
+ <NcNoteCard v-if="message === 'send-success'"
+ type="success">
+ {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or Login, check your spam/junk folders or ask your local administration for help.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'send-error'"
+ type="error">
+ {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'reset-error'"
+ type="error">
+ {{ t('core', 'Password cannot be changed. Please contact your administrator.') }}
+ </NcNoteCard>
</form>
</template>
-<script>
-import axios from '@nextcloud/axios'
+<script lang="ts">
import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+import AuthMixin from '../../mixins/auth.js'
import LoginButton from './LoginButton.vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import logger from '../../logger.js'
-export default {
+export default defineComponent({
name: 'ResetPassword',
components: {
LoginButton,
+ NcButton,
NcNoteCard,
NcTextField,
},
+
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -80,11 +73,12 @@ export default {
required: true,
},
},
+
data() {
return {
error: false,
loading: false,
- message: undefined,
+ message: '',
user: this.username,
}
},
@@ -97,57 +91,38 @@ export default {
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+
+ async submit() {
this.loading = true
this.error = false
this.message = ''
const url = generateUrl('/lostpassword/email')
- const data = {
- user: this.user,
- }
+ try {
+ const { data } = await axios.post(url, { user: this.user })
+ if (data.status !== 'success') {
+ throw new Error(`got status ${data.status}`)
+ }
+
+ this.message = 'send-success'
+ } catch (error) {
+ logger.error('could not send reset email request', { error })
- return axios.post(url, data)
- .then(resp => resp.data)
- .then(data => {
- if (data.status !== 'success') {
- throw new Error(`got status ${data.status}`)
- }
-
- this.message = 'send-success'
- })
- .catch(e => {
- console.error('could not send reset email request', e)
-
- this.error = true
- this.message = 'send-error'
- })
- .then(() => { this.loading = false })
+ this.error = true
+ this.message = 'send-error'
+ } finally {
+ this.loading = false
+ }
},
},
-}
+})
</script>
<style lang="scss" scoped>
-.login-form {
- text-align: left;
- font-size: 1rem;
-
- &__fieldset {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: .5rem;
- }
-
- &__link {
- display: block;
- font-weight: normal !important;
- padding-bottom: 1rem;
- cursor: pointer;
- font-size: var(--default-font-size);
- text-align: center;
- padding: .5rem 1rem 1rem 1rem;
- }
+.reset-password-form {
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+ width: 100%;
}
</style>
diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue
index 36a63a6254a..b7b9ecccd0a 100644
--- a/core/src/components/login/UpdatePassword.vue
+++ b/core/src/components/login/UpdatePassword.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form @submit.prevent="submit">
@@ -31,7 +14,7 @@
name="password"
autocomplete="new-password"
autocapitalize="none"
- autocorrect="off"
+ spellcheck="false"
required
:placeholder="t('core', 'New password')">
</p>
diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
index 6b81106ff72..f2120c28402 100644
--- a/core/src/components/setup/RecommendedApps.vue
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -1,26 +1,10 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div class="guest-box">
+ <div class="guest-box" data-cy-setup-recommended-apps>
<h2>{{ t('core', 'Recommended apps') }}</h2>
<p v-if="loadingApps" class="loading text-center">
{{ t('core', 'Loading apps …') }}
@@ -28,20 +12,13 @@
<p v-else-if="loadingAppsError" class="loading-error text-center">
{{ t('core', 'Could not fetch list of apps from the App Store.') }}
</p>
- <p v-else-if="installingApps" class="text-center">
- {{ t('core', 'Installing apps …') }}
- </p>
<div v-for="app in recommendedApps" :key="app.id" class="app">
<template v-if="!isHidden(app.id)">
<img :src="customIcon(app.id)" alt="">
<div class="info">
- <h3>
- {{ customName(app) }}
- <span v-if="app.loading" class="icon icon-loading-small-dark" />
- <span v-else-if="app.active" class="icon icon-checkmark-white" />
- </h3>
- <p v-html="customDescription(app.id)" />
+ <h3>{{ customName(app) }}</h3>
+ <p v-text="customDescription(app.id)" />
<p v-if="app.installationError">
<strong>{{ t('core', 'App download or installation failed') }}</strong>
</p>
@@ -52,37 +29,42 @@
<strong>{{ t('core', 'Cannot install this app') }}</strong>
</p>
</div>
+ <NcCheckboxRadioSwitch :checked="app.isSelected || app.active"
+ :disabled="!app.isCompatible || app.active"
+ :loading="app.loading"
+ @update:checked="toggleSelect(app.id)" />
</template>
</div>
<div class="dialog-row">
- <NcButton v-if="showInstallButton"
- type="tertiary"
- role="link"
- href="defaultPageUrl"
- @click="goTo(defaultPageUrl)">
+ <NcButton v-if="showInstallButton && !installingApps"
+ data-cy-setup-recommended-apps-skip
+ :href="defaultPageUrl"
+ variant="tertiary">
{{ t('core', 'Skip') }}
</NcButton>
<NcButton v-if="showInstallButton"
- type="primary"
+ data-cy-setup-recommended-apps-install
+ :disabled="installingApps || !isAnyAppSelected"
+ variant="primary"
@click.stop.prevent="installApps">
- {{ t('core', 'Install recommended apps') }}
+ {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }}
</NcButton>
</div>
</div>
</template>
<script>
-import axios from '@nextcloud/axios'
-import { generateUrl, imagePath } from '@nextcloud/router'
+import { t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl, imagePath } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
import pLimit from 'p-limit'
-import { translate as t } from '@nextcloud/l10n'
+import logger from '../../logger.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton'
-
-import logger from '../../logger'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const recommended = {
calendar: {
@@ -98,7 +80,7 @@ const recommended = {
icon: imagePath('core', 'actions/mail.svg'),
},
spreed: {
- description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'),
+ description: t('core', 'Chatting, video calls, screen sharing, online meetings and web conferencing – in your browser and with mobile apps.'),
icon: imagePath('core', 'apps/spreed.svg'),
},
richdocuments: {
@@ -106,16 +88,20 @@ const recommended = {
description: t('core', 'Collaborative documents, spreadsheets and presentations, built on Collabora Online.'),
icon: imagePath('core', 'apps/richdocuments.svg'),
},
+ notes: {
+ description: t('core', 'Distraction free note taking app.'),
+ icon: imagePath('core', 'apps/notes.svg'),
+ },
richdocumentscode: {
hidden: true,
},
}
const recommendedIds = Object.keys(recommended)
-const defaultPageUrl = loadState('core', 'defaultPageUrl')
export default {
name: 'RecommendedApps',
components: {
+ NcCheckboxRadioSwitch,
NcButton,
},
data() {
@@ -125,20 +111,23 @@ export default {
loadingApps: true,
loadingAppsError: false,
apps: [],
- defaultPageUrl,
+ defaultPageUrl: loadState('core', 'defaultPageUrl'),
}
},
computed: {
recommendedApps() {
return this.apps.filter(app => recommendedIds.includes(app.id))
},
+ isAnyAppSelected() {
+ return this.recommendedApps.some(app => app.isSelected)
+ },
},
async mounted() {
try {
const { data } = await axios.get(generateUrl('settings/apps/list'))
logger.info(`${data.apps.length} apps fetched`)
- this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false }))
+ this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible }))
logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps })
this.showInstallButton = true
@@ -152,23 +141,24 @@ export default {
},
methods: {
installApps() {
- this.showInstallButton = false
this.installingApps = true
const limit = pLimit(1)
const installing = this.recommendedApps
- .filter(app => !app.active && app.isCompatible && app.canInstall)
- .map(app => limit(() => {
+ .filter(app => !app.active && app.isCompatible && app.canInstall && app.isSelected)
+ .map(app => limit(async () => {
logger.info(`installing ${app.id}`)
app.loading = true
return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] })
.catch(error => {
logger.error(`could not install ${app.id}`, { error })
+ app.isSelected = false
app.installationError = true
})
.then(() => {
logger.info(`installed ${app.id}`)
app.loading = false
+ app.active = true
})
}))
logger.debug(`installing ${installing.length} recommended apps`)
@@ -176,7 +166,7 @@ export default {
.then(() => {
logger.info('all recommended apps installed, redirecting …')
- window.location = defaultPageUrl
+ window.location = this.defaultPageUrl
})
.catch(error => logger.error('could not install recommended apps', { error }))
},
@@ -206,8 +196,13 @@ export default {
}
return !!recommended[appId].hidden
},
- goTo(href) {
- window.location.href = href
+ toggleSelect(appId) {
+ // disable toggle when installButton is disabled
+ if (!(appId in recommended) || !this.showInstallButton) {
+ return
+ }
+ const index = this.apps.findIndex(app => app.id === appId)
+ this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected)
},
},
}
@@ -251,16 +246,17 @@ p {
.info {
h3, p {
- text-align: left;
+ text-align: start;
}
h3 {
margin-top: 0;
}
+ }
- h3 > span.icon {
- display: inline-block;
- }
+ .checkbox-radio-switch {
+ margin-inline-start: auto;
+ padding: 0 2px;
}
}
</style>
diff --git a/core/src/eventbus.d.ts b/core/src/eventbus.d.ts
new file mode 100644
index 00000000000..4fac9bc7841
--- /dev/null
+++ b/core/src/eventbus.d.ts
@@ -0,0 +1,14 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare module '@nextcloud/event-bus' {
+ export interface NextcloudEvents {
+ // mapping of 'event name' => 'event type'
+ 'nextcloud:unified-search:reset': undefined
+ 'nextcloud:unified-search:search': { query: string }
+ }
+}
+
+export {}
diff --git a/core/src/files/client.js b/core/src/files/client.js
index 2c71fbe46e1..7c69a65161b 100644
--- a/core/src/files/client.js
+++ b/core/src/files/client.js
@@ -1,33 +1,7 @@
/**
- * Copyright (c) 2015
- *
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Michael Jobst <mjobst+github@tecratech.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Citharel <nextcloud@tcit.fr>
- * @author Tomasz Grobelny <tomasz@grobelny.net>
- * @author Vincent Petry <vincent@nextcloud.com>
- * @author Vinicius Cubas Brand <vinicius@eita.org.br>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
@@ -758,7 +732,7 @@ import escapeHTML from 'escape-html'
return promise
},
- _simpleCall: function(method, path) {
+ _simpleCall: function(method, path, headers) {
if (!path) {
throw 'Missing argument "path"'
}
@@ -769,7 +743,8 @@ import escapeHTML from 'escape-html'
this._client.request(
method,
- this._buildUrl(path)
+ this._buildUrl(path),
+ headers ? headers : {}
).then(
function(result) {
if (self._isSuccessStatus(result.status)) {
@@ -790,8 +765,8 @@ import escapeHTML from 'escape-html'
*
* @returns {Promise}
*/
- createDirectory: function(path) {
- return this._simpleCall('MKCOL', path)
+ createDirectory: function(path, headers) {
+ return this._simpleCall('MKCOL', path, headers)
},
/**
diff --git a/core/src/files/fileinfo.js b/core/src/files/fileinfo.js
index 3fe90f82ac9..7ebe06a8349 100644
--- a/core/src/files/fileinfo.js
+++ b/core/src/files/fileinfo.js
@@ -1,26 +1,7 @@
/**
- * Copyright (c) 2015
- *
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
@@ -166,7 +147,7 @@
for (const i in this.shareAttributes) {
const attr = this.shareAttributes[i]
if (attr.scope === 'permissions' && attr.key === 'download') {
- return attr.enabled
+ return attr.value === true
}
}
diff --git a/core/src/globals.js b/core/src/globals.js
index 98bb6f64db8..4b07cc17c3e 100644
--- a/core/src/globals.js
+++ b/core/src/globals.js
@@ -1,43 +1,20 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable @nextcloud/no-deprecations */
-import { initCore } from './init'
+import { initCore } from './init.js'
import _ from 'underscore'
import $ from 'jquery'
-import 'jquery-migrate/dist/jquery-migrate.min'
// TODO: switch to `jquery-ui` package and import widgets and effects individually
// `jquery-ui-dist` is used as a workaround for the issue of missing effects
-import 'jquery-ui-dist/jquery-ui'
+import 'jquery-ui-dist/jquery-ui.js'
import 'jquery-ui-dist/jquery-ui.css'
import 'jquery-ui-dist/jquery-ui.theme.css'
// END TODO
-import autosize from 'autosize'
import Backbone from 'backbone'
-import './Polyfill/tooltip'
import ClipboardJS from 'clipboard'
import { dav } from 'davclient.js'
import Handlebars from 'handlebars'
@@ -45,18 +22,18 @@ import md5 from 'blueimp-md5'
import moment from 'moment'
import 'select2'
import 'select2/select2.css'
-import 'snap.js/dist/snap'
+import 'snap.js/dist/snap.js'
import 'strengthify'
import 'strengthify/strengthify.css'
-import OC from './OC/index'
-import OCP from './OCP/index'
-import OCA from './OCA/index'
-import { getToken as getRequestToken } from './OC/requesttoken'
+import OC from './OC/index.js'
+import OCP from './OCP/index.js'
+import OCA from './OCA/index.js'
+import { getRequestToken } from './OC/requesttoken.ts'
const warnIfNotTesting = function() {
if (window.TESTING === undefined) {
- console.warn.apply(console, arguments)
+ OC.debug && console.warn.apply(console, arguments)
}
}
@@ -100,11 +77,11 @@ const setDeprecatedProp = (global, cb, msg) => {
window._ = _
setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be removed in a later versions without another warning. Please ship your own.')
-setDeprecatedProp('autosize', () => autosize, 'please ship your own, this will be removed in Nextcloud 20')
setDeprecatedProp('Backbone', () => Backbone, 'please ship your own, this will be removed in Nextcloud 20')
setDeprecatedProp(['Clipboard', 'ClipboardJS'], () => ClipboardJS, 'please ship your own, this will be removed in Nextcloud 20')
window.dav = dav
setDeprecatedProp('Handlebars', () => Handlebars, 'please ship your own, this will be removed in Nextcloud 20')
+// Global md5 only required for: apps/files/js/file-upload.js
setDeprecatedProp('md5', () => md5, 'please ship your own, this will be removed in Nextcloud 20')
setDeprecatedProp('moment', () => moment, 'please ship your own, this will be removed in Nextcloud 20')
diff --git a/core/src/icons.js b/core/src/icons.js
index 3cd685dce8c..5845b01fea1 100644
--- a/core/src/icons.js
+++ b/core/src/icons.js
@@ -1,5 +1,9 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
/* eslint-disable quote-props */
-/* eslint-disable node/no-unpublished-import */
+/* eslint-disable n/no-unpublished-import */
import path from 'path'
import fs from 'fs'
import sass from 'sass'
@@ -7,7 +11,8 @@ import sass from 'sass'
const colors = {
dark: '000',
white: 'fff',
- yellow: 'FC0',
+ // gold but for backwards compatibility called yellow
+ yellow: 'a08b00',
red: 'e9322d',
orange: 'eca700',
green: '46ba61',
@@ -94,9 +99,11 @@ const icons = {
'sound': path.join(__dirname, '../img', 'actions', 'sound.svg'),
'star': path.join(__dirname, '../img', 'actions', 'star.svg'),
'starred': path.join(__dirname, '../img', 'actions', 'star-dark.svg'),
+ 'star-rounded': path.join(__dirname, '../img', 'actions', 'star-rounded.svg'),
'tablet': path.join(__dirname, '../img', 'clients', 'tablet.svg'),
'tag': path.join(__dirname, '../img', 'actions', 'tag.svg'),
'talk': path.join(__dirname, '../img', 'apps', 'spreed.svg'),
+ 'teams': path.join(__dirname, '../img', 'apps', 'circles.svg'),
'template-add': path.join(__dirname, '../img', 'actions', 'template-add.svg'),
'timezone': path.join(__dirname, '../img', 'actions', 'timezone.svg'),
'toggle-background': path.join(__dirname, '../img', 'actions', 'toggle-background.svg'),
@@ -122,6 +129,10 @@ const icons = {
}
const iconsColor = {
+ 'add-folder-description': {
+ path: path.join(__dirname, '../img', 'actions', 'add-folder-description.svg'),
+ color: 'grey',
+ },
'settings': {
path: path.join(__dirname, '../img', 'actions', 'settings.svg'),
color: 'black',
@@ -164,6 +175,14 @@ const iconsColor = {
// TODO: replace primary ?
color: 'primary',
},
+ 'filetype-text': {
+ path: path.join(__dirname, '../img', 'filetypes', 'text.svg'),
+ color: 'grey',
+ },
+ 'file-text': {
+ path: path.join(__dirname, '../img', 'filetypes', 'text.svg'),
+ color: 'black',
+ },
}
// use this to define aliases to existing icons
@@ -207,7 +226,6 @@ const iconsAliases = {
'icon-category-security': 'icon-password-dark',
'icon-category-search': 'icon-search-dark',
'icon-category-tools': 'icon-settings-dark',
- 'icon-filetype-text': 'icon-file-grey',
'nav-icon-systemtagsfilter': 'icon-tag-dark',
}
@@ -219,7 +237,7 @@ const colorSvg = function(svg = '', color = '000') {
}
// add fill (fill is not present on black elements)
- const fillRe = /<((circle|rect|path)((?!fill)[a-z0-9 =".\-#():;,])+)\/>/gmi
+ const fillRe = /<((circle|rect|path)((?!fill=)[a-z0-9 =".\-#():;,])+)\/>/gmi
svg = svg.replace(fillRe, '<$1 fill="#' + color + '"/>')
// replace any fill or stroke colors
diff --git a/core/src/init.js b/core/src/init.js
index 867ba94483f..1bcd8218702 100644
--- a/core/src/init.js
+++ b/core/src/init.js
@@ -1,27 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author nacho <nacho@ownyourbits.com>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* globals Snap */
@@ -29,12 +8,13 @@ import _ from 'underscore'
import $ from 'jquery'
import moment from 'moment'
-import { initSessionHeartBeat } from './session-heartbeat.js'
import OC from './OC/index.js'
+import { initSessionHeartBeat } from './session-heartbeat.ts'
import { setUp as setUpContactsMenu } from './components/ContactsMenu.js'
import { setUp as setUpMainMenu } from './components/MainMenu.js'
import { setUp as setUpUserMenu } from './components/UserMenu.js'
-import PasswordConfirmation from './OC/password-confirmation.js'
+import { interceptRequests } from './utils/xhr-request.js'
+import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts'
// keep in sync with core/css/variables.scss
const breakpointMobileWidth = 1024
@@ -78,6 +58,9 @@ moment.locale(locale)
* Initializes core
*/
export const initCore = () => {
+ interceptRequests()
+ initFallbackClipboardAPI()
+
$(window).on('unload.main', () => { OC._unloadCalled = true })
$(window).on('beforeunload.main', () => {
// super-trick thanks to http://stackoverflow.com/a/4651049
@@ -160,6 +143,12 @@ export const initCore = () => {
// we need this because dragging stop triggers that
animating = false
})
+ snapper.on('open', () => {
+ $appNavigation.attr('aria-hidden', 'false')
+ })
+ snapper.on('close', () => {
+ $appNavigation.attr('aria-hidden', 'true')
+ })
// These are necessary because calling open or close
// on snapper during an animation makes it trigger an
@@ -213,6 +202,7 @@ export const initCore = () => {
// close sidebar when switching navigation entry
const $appNavigation = $('#app-navigation')
+ $appNavigation.attr('aria-hidden', 'true')
$appNavigation.delegate('a, :button', 'click', event => {
const $target = $(event.target)
// don't hide navigation when changing settings or adding things
@@ -264,6 +254,7 @@ export const initCore = () => {
const toggleSnapperOnSize = () => {
if ($(window).width() > breakpointMobileWidth) {
+ $appNavigation.attr('aria-hidden', 'false')
snapper.close()
snapper.disable()
@@ -287,5 +278,4 @@ export const initCore = () => {
}
initLiveTimestamps()
- PasswordConfirmation.init()
}
diff --git a/core/src/install.js b/core/src/install.js
deleted file mode 100644
index 9892e45f8b6..00000000000
--- a/core/src/install.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Richard Steinmetz <richard@steinmetz.cloud>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import $ from 'jquery'
-import { translate as t } from '@nextcloud/l10n'
-import { getToken } from './OC/requesttoken'
-import getURLParameter from './Util/get-url-parameter'
-
-import './jquery/showpassword'
-
-import 'jquery-ui/ui/widgets/button'
-import 'jquery-ui/themes/base/theme.css'
-import 'jquery-ui/themes/base/button.css'
-
-import './Polyfill/tooltip'
-
-import 'strengthify'
-import 'strengthify/strengthify.css'
-
-window.addEventListener('DOMContentLoaded', function() {
- const dbtypes = {
- sqlite: !!$('#hasSQLite').val(),
- mysql: !!$('#hasMySQL').val(),
- postgresql: !!$('#hasPostgreSQL').val(),
- oracle: !!$('#hasOracle').val(),
- }
-
- $('#selectDbType').buttonset()
- // change links inside an info box back to their default appearance
- $('#selectDbType p.info a').button('destroy')
-
- if ($('#hasSQLite').val()) {
- $('#use_other_db').hide()
- $('#use_oracle_db').hide()
- } else {
- $('#sqliteInformation').hide()
- }
- $('#adminlogin').change(function() {
- $('#adminlogin').val($.trim($('#adminlogin').val()))
- })
- $('#sqlite').click(function() {
- $('#use_other_db').slideUp(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').show()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#mysql,#pgsql').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#oci').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').show(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-.]+')
- })
-
- $('#showAdvanced').click(function(e) {
- e.preventDefault()
- $('#datadirContent').slideToggle(250)
- $('#databaseBackend').slideToggle(250)
- $('#databaseField').slideToggle(250)
- })
- $('form').submit(function() {
- // Save form parameters
- const post = $(this).serializeArray()
-
- // Show spinner while finishing setup
- $('.float-spinner').show(250)
-
- // Disable inputs
- $('input[type="submit"]').attr('disabled', 'disabled').val($('input[type="submit"]').data('finishing'))
- $('input', this).addClass('ui-state-disabled').attr('disabled', 'disabled')
- // only disable buttons if they are present
- if ($('#selectDbType').find('.ui-button').length > 0) {
- $('#selectDbType').buttonset('disable')
- }
- $('.strengthify-wrapper, .tipsy')
- .css('filter', 'alpha(opacity=30)')
- .css('opacity', 0.3)
-
- // Create the form
- const form = $('<form>')
- form.attr('action', $(this).attr('action'))
- form.attr('method', 'POST')
-
- for (let i = 0; i < post.length; i++) {
- const input = $('<input type="hidden">')
- input.attr(post[i])
- form.append(input)
- }
-
- // Add redirect_url
- const redirectURL = getURLParameter('redirect_url')
- if (redirectURL) {
- const redirectURLInput = $('<input type="hidden">')
- redirectURLInput.attr({
- name: 'redirect_url',
- value: redirectURL,
- })
- form.append(redirectURLInput)
- }
-
- // Submit the form
- form.appendTo(document.body)
- form.submit()
- return false
- })
-
- // Expand latest db settings if page was reloaded on error
- const currentDbType = $('input[type="radio"]:checked').val()
-
- if (currentDbType === undefined) {
- $('input[type="radio"]').first().click()
- }
-
- if (
- currentDbType === 'sqlite'
- || (dbtypes.sqlite && currentDbType === undefined)
- ) {
- $('#datadirContent').hide(250)
- $('#databaseBackend').hide(250)
- $('#databaseField').hide(250)
- $('.float-spinner').hide(250)
- }
-
- $('#adminpass').strengthify({
- zxcvbn: OC.linkTo('core', 'vendor/zxcvbn/dist/zxcvbn.js'),
- titles: [
- t('core', 'Very weak password'),
- t('core', 'Weak password'),
- t('core', 'So-so password'),
- t('core', 'Good password'),
- t('core', 'Strong password'),
- ],
- drawTitles: true,
- nonce: btoa(getToken()),
- })
-
- $('#dbpass').showPassword().keyup()
- $('.toggle-password').click(function(event) {
- event.preventDefault()
- const currentValue = $(this).parent().children('input').attr('type')
- if (currentValue === 'password') {
- $(this).parent().children('input').attr('type', 'text')
- } else {
- $(this).parent().children('input').attr('type', 'password')
- }
- })
-})
diff --git a/core/src/install.ts b/core/src/install.ts
new file mode 100644
index 00000000000..4ef608ec2bd
--- /dev/null
+++ b/core/src/install.ts
@@ -0,0 +1,43 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import Setup from './views/Setup.vue'
+
+type Error = {
+ error: string
+ hint: string
+}
+
+export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci'
+
+export type SetupConfig = {
+ adminlogin: string
+ adminpass: string
+ directory: string
+ dbuser: string
+ dbpass: string
+ dbname: string
+ dbtablespace: string
+ dbhost: string
+ dbtype: DbType | ''
+
+ databases: Partial<Record<DbType, string>>
+
+ hasAutoconfig: boolean
+ htaccessWorking: boolean
+ serverRoot: string
+
+ errors: string[]|Error[]
+}
+
+export type SetupLinks = {
+ adminInstall: string
+ adminSourceInstall: string
+ adminDBConfiguration: string
+}
+
+const SetupVue = Vue.extend(Setup)
+new SetupVue().$mount('#content')
diff --git a/core/src/jquery/avatar.js b/core/src/jquery/avatar.js
index 12fcc7264ae..3851a26ce31 100644
--- a/core/src/jquery/avatar.js
+++ b/core/src/jquery/avatar.js
@@ -1,30 +1,12 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
import $ from 'jquery'
-import OC from '../OC'
-
/**
* This plugin inserts the right avatar for the user, depending on, whether a
* custom avatar is uploaded - which it uses then - or not, and display a
@@ -108,8 +90,8 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) {
let url
// If this is our own avatar we have to use the version attribute
- if (user === OC.getCurrentUser().uid) {
- url = OC.generateUrl(
+ if (user === getCurrentUser()?.uid) {
+ url = generateUrl(
'/avatar/{user}/{size}?v={version}',
{
user,
@@ -117,7 +99,7 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) {
version: oc_userconfig.avatar.version,
})
} else {
- url = OC.generateUrl(
+ url = generateUrl(
'/avatar/{user}/{size}',
{
user,
diff --git a/core/src/jquery/contactsmenu.js b/core/src/jquery/contactsmenu.js
index 2cf2daec570..fba014c364e 100644
--- a/core/src/jquery/contactsmenu.js
+++ b/core/src/jquery/contactsmenu.js
@@ -1,30 +1,11 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import OC from '../OC'
+import { generateUrl } from '@nextcloud/router'
import { isA11yActivation } from '../Util/a11y.js'
const LIST = ''
@@ -70,7 +51,7 @@ $.fn.contactsMenu = function(shareWith, shareType, appendTo) {
}
$list.addClass('loaded')
- $.ajax(OC.generateUrl('/contactsmenu/findOne'), {
+ $.ajax(generateUrl('/contactsmenu/findOne'), {
method: 'POST',
data: {
shareType,
diff --git a/core/src/jquery/css/jquery-ui-fixes.scss b/core/src/jquery/css/jquery-ui-fixes.scss
index cb52ecdb2d7..637f4bfe14b 100644
--- a/core/src/jquery/css/jquery-ui-fixes.scss
+++ b/core/src/jquery/css/jquery-ui-fixes.scss
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
/* Component containers
----------------------------------*/
.ui-widget-content {
@@ -5,14 +9,17 @@
background: var(--color-main-background) none;
color: var(--color-main-text);
}
+
.ui-widget-content a {
color: var(--color-main-text);
}
+
.ui-widget-header {
border: none;
color: var(--color-main-text);
background-image: none;
}
+
.ui-widget-header a {
color: var(--color-main-text);
}
@@ -27,11 +34,13 @@
font-weight: bold;
color: #555;
}
+
.ui-state-default a,
.ui-state-default a:link,
.ui-state-default a:visited {
color: #555;
}
+
.ui-state-hover,
.ui-widget-content .ui-state-hover,
.ui-widget-header .ui-state-hover,
@@ -43,20 +52,23 @@
font-weight: bold;
color: var(--color-main-text);
}
+
.ui-state-hover a,
.ui-state-hover a:hover,
.ui-state-hover a:link,
.ui-state-hover a:visited {
color: var(--color-main-text);
}
+
.ui-state-active,
.ui-widget-content .ui-state-active,
.ui-widget-header .ui-state-active {
- border: 1px solid var(--color-primary);
+ border: 1px solid var(--color-primary-element);
background: var(--color-main-background) none;
font-weight: bold;
color: var(--color-main-text);
}
+
.ui-state-active a,
.ui-state-active a:link,
.ui-state-active a:visited {
@@ -73,11 +85,13 @@
color: var(--color-text-light);
font-weight: 600;
}
+
.ui-state-highlight a,
.ui-widget-content .ui-state-highlight a,
.ui-widget-header .ui-state-highlight a {
color: var(--color-text-lighter);
}
+
.ui-state-error,
.ui-widget-content .ui-state-error,
.ui-widget-header .ui-state-error {
@@ -85,11 +99,13 @@
background: var(--color-error) none;
color: #ffffff;
}
+
.ui-state-error a,
.ui-widget-content .ui-state-error a,
.ui-widget-header .ui-state-error a {
color: #ffffff;
}
+
.ui-state-error-text,
.ui-widget-content .ui-state-error-text,
.ui-widget-header .ui-state-error-text {
@@ -101,20 +117,25 @@
.ui-state-default .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-hover .ui-icon,
.ui-state-focus .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-active .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-highlight .ui-icon {
background-image: url('images/ui-icons_ffffff_256x240.png');
}
+
.ui-state-error .ui-icon,
.ui-state-error-text .ui-icon {
background-image: url('images/ui-icons_ffd27a_256x240.png');
}
+
.ui-icon.ui-icon-none {
display: none;
}
@@ -126,6 +147,7 @@
background: #666666 url('images/ui-bg_diagonals-thick_20_666666_40x40.png') 50% 50% repeat;
opacity: .5;
}
+
.ui-widget-shadow {
margin: -5px 0 0 -5px;
padding: 5px;
@@ -139,8 +161,8 @@
border: none;
.ui-tabs-nav.ui-corner-all {
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
+ border-end-start-radius: 0;
+ border-end-end-radius: 0;
}
.ui-tabs-nav {
@@ -185,7 +207,8 @@
.ui-menu-item a {
color: var(--color-text-lighter);
display: block;
- padding: 4px 4px 4px 14px;
+ padding: 4px;
+ padding-inline-start: 14px;
&.ui-state-focus, &.ui-state-active {
box-shadow: inset 4px 0 var(--color-primary-element);
@@ -201,8 +224,8 @@
&.ui-corner-all {
border-radius: 0;
- border-bottom-left-radius: var(--border-radius);
- border-bottom-right-radius: var(--border-radius);
+ border-end-start-radius: var(--border-radius);
+ border-end-end-radius: var(--border-radius);
}
.ui-state-hover, .ui-widget-content .ui-state-hover,
@@ -223,9 +246,9 @@
}
.ui-button.primary {
- background-color: var(--color-primary);
- color: var(--color-primary-text);
- border: 1px solid var(--color-primary-text);
+ background-color: var(--color-primary-element);
+ color: var(--color-primary-element-text);
+ border: 1px solid var(--color-primary-element-text);
}
// fix ui-buttons on hover
diff --git a/core/src/jquery/css/jquery.ocdialog.scss b/core/src/jquery/css/jquery.ocdialog.scss
index 8755e1d7a74..b950d98c381 100644
--- a/core/src/jquery/css/jquery.ocdialog.scss
+++ b/core/src/jquery/css/jquery.ocdialog.scss
@@ -1,28 +1,34 @@
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
.oc-dialog {
background: var(--color-main-background);
color: var(--color-text-light);
border-radius: var(--border-radius-large);
box-shadow: 0 0 30px var(--color-box-shadow);
padding: 24px;
- z-index: 10000;
+ z-index: 100001;
font-size: 100%;
box-sizing: border-box;
min-width: 200px;
top: 50%;
- left: 50%;
+ inset-inline-start: 50%;
transform: translate(-50%, -50%);
max-height: calc(100% - 20px);
max-width: calc(100% - 20px);
overflow: auto;
}
+
.oc-dialog-title {
background: var(--color-main-background);
}
+
.oc-dialog-buttonrow {
position: relative;
display: flex;
background: transparent;
- right: 0;
+ inset-inline-end: 0;
bottom: 0;
padding: 0;
padding-top: 10px;
@@ -50,8 +56,10 @@
.oc-dialog-close {
position: absolute;
- top: 0;
- right: 0;
+ width: 44px !important;
+ height: 44px !important;
+ top: 4px;
+ inset-inline-end: 4px;
padding: 25px;
background: var(--icon-close-dark) no-repeat center;
opacity: .5;
@@ -67,10 +75,10 @@
.oc-dialog-dim {
background-color: #000;
opacity: .2;
- z-index: 9999;
+ z-index: 100001;
position: fixed;
top: 0;
- left: 0;
+ inset-inline-start: 0;
width: 100%;
height: 100%;
}
diff --git a/core/src/jquery/exists.js b/core/src/jquery/exists.js
index 0f545cc8107..8a8efdb5a63 100644
--- a/core/src/jquery/exists.js
+++ b/core/src/jquery/exists.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
diff --git a/core/src/jquery/filterattr.js b/core/src/jquery/filterattr.js
index 44aad5b8ea2..f577e55e4e0 100644
--- a/core/src/jquery/filterattr.js
+++ b/core/src/jquery/filterattr.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
diff --git a/core/src/jquery/index.js b/core/src/jquery/index.js
index 305b038ce37..f285ba19449 100644
--- a/core/src/jquery/index.js
+++ b/core/src/jquery/index.js
@@ -1,40 +1,21 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import './avatar'
-import './contactsmenu'
-import './exists'
-import './filterattr'
-import './ocdialog'
-import './octemplate'
-import './placeholder'
-import './requesttoken'
-import './selectrange'
-import './showpassword'
-import './ui-fixes'
+import './avatar.js'
+import './contactsmenu.js'
+import './exists.js'
+import './filterattr.js'
+import './ocdialog.js'
+import './octemplate.js'
+import './placeholder.js'
+import './requesttoken.js'
+import './selectrange.js'
+import './showpassword.js'
+import './ui-fixes.js'
import './css/jquery-ui-fixes.scss'
import './css/jquery.ocdialog.scss'
diff --git a/core/src/jquery/ocdialog.js b/core/src/jquery/ocdialog.js
index c8ea065d3c0..a5f588ec659 100644
--- a/core/src/jquery/ocdialog.js
+++ b/core/src/jquery/ocdialog.js
@@ -1,30 +1,11 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Gary Kim <gary@garykim.dev>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import { isA11yActivation } from '../Util/a11y'
+import { createFocusTrap } from 'focus-trap'
+import { isA11yActivation } from '../Util/a11y.js'
$.widget('oc.ocdialog', {
options: {
@@ -52,6 +33,7 @@ $.widget('oc.ocdialog', {
// Setting tabIndex makes the div focusable
tabIndex: -1,
role: 'dialog',
+ 'aria-modal': true,
})
.insertBefore(this.element)
this.$dialog.append(this.element.detach())
@@ -114,9 +96,9 @@ $.widget('oc.ocdialog', {
this._setOptions(this.options)
this._createOverlay()
+ this._useFocusTrap()
},
_init() {
- this.$dialog.focus()
this._trigger('open')
},
_setOption(key, value) {
@@ -177,7 +159,8 @@ $.widget('oc.ocdialog', {
break
case 'closeButton':
if (value) {
- const $closeButton = $('<a class="oc-dialog-close" tabindex="0"></a>')
+ const $closeButton = $('<button class="oc-dialog-close"></button>')
+ $closeButton.attr('aria-label', t('core', 'Close "{dialogTitle}" dialog', { dialogTitle: this.$title || this.options.title }))
this.$dialog.prepend($closeButton)
$closeButton.on('click keydown', function(event) {
if (isA11yActivation(event)) {
@@ -231,7 +214,7 @@ $.widget('oc.ocdialog', {
}
this.overlay = $('<div>')
.addClass('oc-dialog-dim')
- .appendTo(contentDiv)
+ .insertBefore(this.$dialog)
this.overlay.on('click keydown keyup', function(event) {
if (event.target !== self.$dialog.get(0) && self.$dialog.find($(event.target)).length === 0) {
event.preventDefault()
@@ -251,6 +234,23 @@ $.widget('oc.ocdialog', {
this.overlay = null
}
},
+ _useFocusTrap() {
+ // Create global stack if undefined
+ Object.assign(window, { _nc_focus_trap: window._nc_focus_trap || [] })
+
+ const dialogElement = this.$dialog[0]
+ this.focusTrap = createFocusTrap(dialogElement, {
+ allowOutsideClick: true,
+ trapStack: window._nc_focus_trap,
+ fallbackFocus: dialogElement,
+ })
+
+ this.focusTrap.activate()
+ },
+ _clearFocusTrap() {
+ this.focusTrap?.deactivate()
+ this.focusTrap = null
+ },
widget() {
return this.$dialog
},
@@ -261,6 +261,7 @@ $.widget('oc.ocdialog', {
this.enterCallback = null
},
close() {
+ this._clearFocusTrap()
this._destroyOverlay()
const self = this
// Ugly hack to catch remaining keyup events.
diff --git a/core/src/jquery/octemplate.js b/core/src/jquery/octemplate.js
index 7bf9f8dcb19..cecbe880aa6 100644
--- a/core/src/jquery/octemplate.js
+++ b/core/src/jquery/octemplate.js
@@ -1,25 +1,6 @@
/**
- * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -108,7 +89,7 @@ const Template = {
function(a, b) {
const r = o[b]
return typeof r === 'string' || typeof r === 'number' ? r : a
- }
+ },
)
} catch (e) {
console.error(e, 'data:', data)
diff --git a/core/src/jquery/placeholder.js b/core/src/jquery/placeholder.js
index 03647059131..e57951af5e4 100644
--- a/core/src/jquery/placeholder.js
+++ b/core/src/jquery/placeholder.js
@@ -1,27 +1,7 @@
/**
- * @copyright 2016-2018 John Molakvoæ <skjnldsv@protonmail.com>
- * @copyright 2013 Morris Jobke <morris.jobke@gmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Sergey Shliakhov <husband.sergey@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2013-2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
@@ -140,7 +120,7 @@ const toRgb = (s) => {
}
String.prototype.toRgb = function() {
- console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.')
+ OC.debug && console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.')
return toRgb(this)
}
diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js
index 07524966994..1e9e06515a6 100644
--- a/core/src/jquery/requesttoken.js
+++ b/core/src/jquery/requesttoken.js
@@ -1,33 +1,15 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import { getToken } from '../OC/requesttoken'
+import { getRequestToken } from '../OC/requesttoken.ts'
$(document).on('ajaxSend', function(elm, xhr, settings) {
if (settings.crossDomain === false) {
- xhr.setRequestHeader('requesttoken', getToken())
+ xhr.setRequestHeader('requesttoken', getRequestToken())
xhr.setRequestHeader('OCS-APIREQUEST', 'true')
}
})
diff --git a/core/src/jquery/selectrange.js b/core/src/jquery/selectrange.js
index 713fd1edf8d..a4d8f49ce43 100644
--- a/core/src/jquery/selectrange.js
+++ b/core/src/jquery/selectrange.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
diff --git a/core/src/jquery/showpassword.js b/core/src/jquery/showpassword.js
index a00a57cc867..8d938d7853b 100644
--- a/core/src/jquery/showpassword.js
+++ b/core/src/jquery/showpassword.js
@@ -1,33 +1,16 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/** @typedef {import('jquery')} jQuery */
import $ from 'jquery'
/**
* @name Show Password
* @description
* @version 1.3.0
- * @requires Jquery 1.5
+ * @requires jQuery 1.5
*
* @author Jan Jarfalk <jan.jarfalk@unwrongest.com>
* author-website http://www.unwrongest.com
diff --git a/core/src/jquery/ui-fixes.js b/core/src/jquery/ui-fixes.js
index ab4235d9b53..e23464b2f9d 100644
--- a/core/src/jquery/ui-fixes.js
+++ b/core/src/jquery/ui-fixes.js
@@ -1,24 +1,6 @@
/**
- * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
diff --git a/core/src/legacy-unified-search.js b/core/src/legacy-unified-search.js
new file mode 100644
index 00000000000..59ee462fbf5
--- /dev/null
+++ b/core/src/legacy-unified-search.js
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/LegacyUnifiedSearch.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+const logger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
+
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+export default new Vue({
+ el: '#unified-search',
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
+})
diff --git a/core/src/logger.js b/core/src/logger.js
index 593cc071850..78d51a798e4 100644
--- a/core/src/logger.js
+++ b/core/src/logger.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
@@ -36,3 +19,8 @@ const getLogger = user => {
}
export default getLogger(getCurrentUser())
+
+export const unifiedSearchLogger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
diff --git a/core/src/login.js b/core/src/login.js
index 6757c5e7bdd..29affcda762 100644
--- a/core/src/login.js
+++ b/core/src/login.js
@@ -1,34 +1,14 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
// eslint-disable-next-line no-unused-vars
-import OC from './OC/index' // TODO: Not needed but L10n breaks if removed
+import OC from './OC/index.js' // TODO: Not needed but L10n breaks if removed
import LoginView from './views/Login.vue'
-import Nextcloud from './mixins/Nextcloud'
+import Nextcloud from './mixins/Nextcloud.js'
Vue.mixin(Nextcloud)
diff --git a/core/src/main.js b/core/src/main.js
index ec23171b6ea..2d88f15562b 100644
--- a/core/src/main.js
+++ b/core/src/main.js
@@ -1,32 +1,10 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import $ from 'jquery'
-import 'core-js/stable'
-import 'regenerator-runtime/runtime'
-import './Polyfill/index.js'
+import 'core-js/stable/index.js'
+import 'regenerator-runtime/runtime.js'
// If you remove the line below, tests won't pass
// eslint-disable-next-line no-unused-vars
@@ -36,6 +14,12 @@ import './globals.js'
import './jquery/index.js'
import { initCore } from './init.js'
import { registerAppsSlideToggle } from './OC/apps.js'
+import { getCSPNonce } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
+import Axios from '@nextcloud/axios'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
window.addEventListener('DOMContentLoaded', function() {
initCore()
@@ -45,6 +29,23 @@ window.addEventListener('DOMContentLoaded', function() {
if (window.history.pushState) {
window.onpopstate = _.bind(OC.Util.History._onPopState, OC.Util.History)
} else {
- $(window).on('hashchange', _.bind(OC.Util.History._onPopState, OC.Util.History))
+ window.onhashchange = _.bind(OC.Util.History._onPopState, OC.Util.History)
+ }
+})
+
+// Fix error "CSRF check failed"
+document.addEventListener('DOMContentLoaded', function() {
+ const form = document.getElementById('password-input-form')
+ if (form) {
+ form.addEventListener('submit', async function(event) {
+ event.preventDefault()
+ const requestToken = document.getElementById('requesttoken')
+ if (requestToken) {
+ const url = generateUrl('/csrftoken')
+ const resp = await Axios.get(url)
+ requestToken.value = resp.data.token
+ }
+ form.submit()
+ })
}
})
diff --git a/core/src/maintenance.js b/core/src/maintenance.js
index 3c13a45894c..e66b14a88f5 100644
--- a/core/src/maintenance.js
+++ b/core/src/maintenance.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Axios from '@nextcloud/axios'
diff --git a/core/src/mixins/Nextcloud.js b/core/src/mixins/Nextcloud.js
index 7fda65d3d9d..3a94f85d2c6 100644
--- a/core/src/mixins/Nextcloud.js
+++ b/core/src/mixins/Nextcloud.js
@@ -1,27 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import L10n from '../OC/l10n'
-import OC from '../OC/index'
+import L10n from '../OC/l10n.js'
+import OC from '../OC/index.js'
export default {
data() {
diff --git a/core/src/mixins/auth.js b/core/src/mixins/auth.js
new file mode 100644
index 00000000000..f5b9365516e
--- /dev/null
+++ b/core/src/mixins/auth.js
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default {
+
+ computed: {
+ userNameInputLengthIs255() {
+ return this.user.length >= 255
+ },
+ userInputHelperText() {
+ if (this.userNameInputLengthIs255) {
+ return t('core', 'Email length is at max (255)')
+ }
+ return undefined
+ },
+ },
+}
diff --git a/core/src/profile.js b/core/src/profile.js
deleted file mode 100644
index 79465c6a28d..00000000000
--- a/core/src/profile.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
-import { translate as t } from '@nextcloud/l10n'
-import VTooltip from 'v-tooltip'
-
-import logger from './logger.js'
-
-import Profile from './views/Profile.vue'
-import ProfileSections from './profile/ProfileSections.js'
-
-__webpack_nonce__ = btoa(getRequestToken())
-
-if (!window.OCA) {
- window.OCA = {}
-}
-
-if (!window.OCA.Core) {
- window.OCA.Core = {}
-}
-Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() })
-
-Vue.use(VTooltip)
-
-Vue.mixin({
- props: {
- logger,
- },
- methods: {
- t,
- },
-})
-
-const View = Vue.extend(Profile)
-
-window.addEventListener('DOMContentLoaded', () => {
- new View().$mount('#vue-profile')
-})
diff --git a/core/src/profile/ProfileSections.js b/core/src/profile/ProfileSections.js
deleted file mode 100644
index 4091c8332d6..00000000000
--- a/core/src/profile/ProfileSections.js
+++ /dev/null
@@ -1,43 +0,0 @@
-
-/**
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-export default class ProfileSections {
-
- _sections
-
- constructor() {
- this._sections = []
- }
-
- /**
- * @param {registerSectionCallback} section To be called to mount the section to the profile page
- */
- registerSection(section) {
- this._sections.push(section)
- }
-
- getSections() {
- return this._sections
- }
-
-}
diff --git a/core/src/public-page-menu.ts b/core/src/public-page-menu.ts
new file mode 100644
index 00000000000..b290d1d03e9
--- /dev/null
+++ b/core/src/public-page-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageMenu from './views/PublicPageMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageMenu)
+const instance = new View()
+instance.$mount('#public-page-menu')
diff --git a/core/src/public-page-user-menu.ts b/core/src/public-page-user-menu.ts
new file mode 100644
index 00000000000..25024271fb5
--- /dev/null
+++ b/core/src/public-page-user-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageUserMenu from './views/PublicPageUserMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageUserMenu)
+const instance = new View()
+instance.$mount('#public-page-user-menu')
diff --git a/core/src/public.ts b/core/src/public.ts
new file mode 100644
index 00000000000..ce4af8aa2ac
--- /dev/null
+++ b/core/src/public.ts
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+const body = document.body
+const footer = document.querySelector('footer')
+let prevHeight = footer?.offsetHeight
+
+const onResize: ResizeObserverCallback = (entries) => {
+ for (const entry of entries) {
+ const height = entry.contentRect.height
+ if (height === prevHeight) {
+ return
+ }
+ prevHeight = height
+ body.style.setProperty('--footer-height', `${height}px`)
+ }
+}
+
+if (footer) {
+ new ResizeObserver(onResize)
+ .observe(footer, {
+ box: 'border-box', // <footer> is border-box
+ })
+}
diff --git a/core/src/recommendedapps.js b/core/src/recommendedapps.js
index 89e8b930827..13f16436ed3 100644
--- a/core/src/recommendedapps.js
+++ b/core/src/recommendedapps.js
@@ -1,34 +1,17 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
-import logger from './logger'
-import RecommendedApps from './components/setup/RecommendedApps'
+import logger from './logger.js'
+import RecommendedApps from './components/setup/RecommendedApps.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
Vue.mixin({
methods: {
diff --git a/core/src/services/BrowserStorageService.js b/core/src/services/BrowserStorageService.js
index 3ecf52f423d..b7d34bf1716 100644
--- a/core/src/services/BrowserStorageService.js
+++ b/core/src/services/BrowserStorageService.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getBuilder } from '@nextcloud/browser-storage'
diff --git a/core/src/services/BrowsersListService.js b/core/src/services/BrowsersListService.js
index 5027489e8e9..77f217a86ac 100644
--- a/core/src/services/BrowsersListService.js
+++ b/core/src/services/BrowsersListService.js
@@ -1,30 +1,13 @@
/**
- * @copyright 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { getUserAgentRegExp } from 'browserslist-useragent-regexp'
-// eslint-disable-next-line node/no-extraneous-import
+import { getUserAgentRegex } from 'browserslist-useragent-regexp'
+// eslint-disable-next-line n/no-extraneous-import
import browserslist from 'browserslist'
import browserslistConfig from '@nextcloud/browserslist-config'
// Generate a regex that matches user agents to detect incompatible browsers
-export const supportedBrowsersRegExp = getUserAgentRegExp({ allowHigherVersions: true, browsers: browserslistConfig })
+export const supportedBrowsersRegExp = getUserAgentRegex({ allowHigherVersions: true, browsers: browserslistConfig })
export const supportedBrowsers = browserslist(browserslistConfig)
diff --git a/core/src/services/LegacyUnifiedSearchService.js b/core/src/services/LegacyUnifiedSearchService.js
new file mode 100644
index 00000000000..5b79c09b8b2
--- /dev/null
+++ b/core/src/services/LegacyUnifiedSearchService.js
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateOcsUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+
+export const defaultLimit = loadState('unified-search', 'limit-default')
+export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
+export const enableLiveSearch = loadState('unified-search', 'live-search', true)
+
+export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
+export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
+
+/**
+ * Create a cancel token
+ *
+ * @return {import('axios').CancelTokenSource}
+ */
+const createCancelToken = () => axios.CancelToken.source()
+
+/**
+ * Get the list of available search providers
+ *
+ * @return {Promise<Array>}
+ */
+export async function getTypes() {
+ try {
+ const { data } = await axios.get(generateOcsUrl('search/providers'), {
+ params: {
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+ if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
+ // Providers are sorted by the api based on their order key
+ return data.ocs.data
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ return []
+}
+
+/**
+ * Get the list of available search providers
+ *
+ * @param {object} options destructuring object
+ * @param {string} options.type the type to search
+ * @param {string} options.query the search
+ * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @return {object} {request: Promise, cancel: Promise}
+ */
+export function search({ type, query, cursor }) {
+ /**
+ * Generate an axios cancel token
+ */
+ const cancelToken = createCancelToken()
+
+ const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
+ cancelToken: cancelToken.token,
+ params: {
+ term: query,
+ cursor,
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+
+ return {
+ request,
+ cancel: cancelToken.cancel,
+ }
+}
diff --git a/core/src/services/UnifiedSearchService.js b/core/src/services/UnifiedSearchService.js
index 3c673479771..7067c994c90 100644
--- a/core/src/services/UnifiedSearchService.js
+++ b/core/src/services/UnifiedSearchService.js
@@ -1,38 +1,11 @@
/**
- * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { generateOcsUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-
-export const defaultLimit = loadState('unified-search', 'limit-default')
-export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
-export const enableLiveSearch = loadState('unified-search', 'live-search', true)
-
-export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
-export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
+import { getCurrentUser } from '@nextcloud/auth'
/**
* Create a cancel token
@@ -46,7 +19,7 @@ const createCancelToken = () => axios.CancelToken.source()
*
* @return {Promise<Array>}
*/
-export async function getTypes() {
+export async function getProviders() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
@@ -71,9 +44,14 @@ export async function getTypes() {
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @param {string} options.since the search
+ * @param {string} options.until the search
+ * @param {string} options.limit the search
+ * @param {string} options.person the search
+ * @param {object} options.extraQueries additional queries to filter search results
* @return {object} {request: Promise, cancel: Promise}
*/
-export function search({ type, query, cursor }) {
+export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) {
/**
* Generate an axios cancel token
*/
@@ -84,8 +62,13 @@ export function search({ type, query, cursor }) {
params: {
term: query,
cursor,
+ since,
+ until,
+ limit,
+ person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ ...extraQueries,
},
})
@@ -94,3 +77,32 @@ export function search({ type, query, cursor }) {
cancel: cancelToken.cancel,
}
}
+
+/**
+ * Get the list of active contacts
+ *
+ * @param {object} filter filter contacts by string
+ * @param {string} filter.searchTerm the query
+ * @return {object} {request: Promise}
+ */
+export async function getContacts({ searchTerm }) {
+ const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ /*
+ * Add authenticated user to list of contacts for search filter
+ * If authtenicated user is searching/filtering, do not add them to the list
+ */
+ if (!searchTerm) {
+ let authenticatedUser = getCurrentUser()
+ authenticatedUser = {
+ id: authenticatedUser.uid,
+ fullName: authenticatedUser.displayName,
+ emailAddresses: [],
+ }
+ contacts.unshift(authenticatedUser)
+ return contacts
+ }
+
+ return contacts
+}
diff --git a/core/src/services/WebAuthnAuthenticationService.js b/core/src/services/WebAuthnAuthenticationService.js
deleted file mode 100644
index 3eabceef5e4..00000000000
--- a/core/src/services/WebAuthnAuthenticationService.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import Axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-
-/**
- * @param {any} loginName -
- */
-export function startAuthentication(loginName) {
- const url = generateUrl('/login/webauthn/start')
-
- return Axios.post(url, { loginName })
- .then(resp => resp.data)
-}
-
-/**
- * @param {any} data -
- */
-export function finishAuthentication(data) {
- const url = generateUrl('/login/webauthn/finish')
-
- return Axios.post(url, { data })
- .then(resp => resp.data)
-}
diff --git a/core/src/services/WebAuthnAuthenticationService.ts b/core/src/services/WebAuthnAuthenticationService.ts
new file mode 100644
index 00000000000..df1837254ad
--- /dev/null
+++ b/core/src/services/WebAuthnAuthenticationService.ts
@@ -0,0 +1,42 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
+
+import { startAuthentication as startWebauthnAuthentication } from '@simplewebauthn/browser'
+import { generateUrl } from '@nextcloud/router'
+
+import Axios from '@nextcloud/axios'
+import logger from '../logger'
+
+export class NoValidCredentials extends Error {}
+
+/**
+ * Start webautn authentication
+ * This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server.
+ *
+ * @param loginName Name to login
+ */
+export async function startAuthentication(loginName: string) {
+ const url = generateUrl('/login/webauthn/start')
+
+ const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName })
+ if (!data.allowCredentials || data.allowCredentials.length === 0) {
+ logger.error('No valid credentials returned for webauthn')
+ throw new NoValidCredentials()
+ }
+ return await startWebauthnAuthentication({ optionsJSON: data })
+}
+
+/**
+ * Verify webauthn authentication
+ * @param authData The authentication data to sent to the server
+ */
+export async function finishAuthentication(authData: AuthenticationResponseJSON) {
+ const url = generateUrl('/login/webauthn/finish')
+
+ const { data } = await Axios.post(url, { data: JSON.stringify(authData) })
+ return data
+}
diff --git a/core/src/session-heartbeat.js b/core/src/session-heartbeat.js
deleted file mode 100644
index 4a15d7d2de9..00000000000
--- a/core/src/session-heartbeat.js
+++ /dev/null
@@ -1,185 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import $ from 'jquery'
-import { emit } from '@nextcloud/event-bus'
-import { loadState } from '@nextcloud/initial-state'
-import { getCurrentUser } from '@nextcloud/auth'
-import { generateUrl } from '@nextcloud/router'
-
-import OC from './OC'
-import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken'
-
-let config = null
-/**
- * The legacy jsunit tests overwrite OC.config before calling initCore
- * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat
- */
-const loadConfig = () => {
- try {
- config = loadState('core', 'config')
- } catch (e) {
- // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls
- config = OC.config
- }
-}
-
-/**
- * session heartbeat (defaults to enabled)
- *
- * @return {boolean}
- */
-const keepSessionAlive = () => {
- return config.session_keepalive === undefined
- || !!config.session_keepalive
-}
-
-/**
- * get interval in seconds
- *
- * @return {number}
- */
-const getInterval = () => {
- let interval = NaN
- if (config.session_lifetime) {
- interval = Math.floor(config.session_lifetime / 2)
- }
-
- // minimum one minute, max 24 hours, default 15 minutes
- return Math.min(
- 24 * 3600,
- Math.max(
- 60,
- isNaN(interval) ? 900 : interval
- )
- )
-}
-
-const getToken = async () => {
- const url = generateUrl('/csrftoken')
-
- // Not using Axios here as Axios is not stubbable with the sinon fake server
- // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises
- // see js/tests/specs/coreSpec.js for the tests
- const resp = await $.get(url)
-
- return resp.token
-}
-
-const poll = async () => {
- try {
- const token = await getToken()
- setRequestToken(token)
- } catch (e) {
- console.error('session heartbeat failed', e)
- }
-}
-
-const startPolling = () => {
- const interval = setInterval(poll, getInterval() * 1000)
-
- console.info('session heartbeat polling started')
-
- return interval
-}
-
-const registerAutoLogout = () => {
- if (!config.auto_logout || !getCurrentUser()) {
- return
- }
-
- let lastActive = Date.now()
- window.addEventListener('mousemove', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('touchstart', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('storage', e => {
- if (e.key !== 'lastActive') {
- return
- }
- lastActive = e.newValue
- })
-
- setInterval(function() {
- const timeout = Date.now() - config.session_lifetime * 1000
- if (lastActive < timeout) {
- console.info('Inactivity timout reached, logging out')
- const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
- window.location = logoutUrl
- }
- }, 1000)
-}
-
-/**
- * Calls the server periodically to ensure that session and CSRF
- * token doesn't expire
- */
-export const initSessionHeartBeat = () => {
- loadConfig()
-
- registerAutoLogout()
-
- if (!keepSessionAlive()) {
- console.info('session heartbeat disabled')
- return
- }
- let interval = startPolling()
-
- window.addEventListener('online', async () => {
- console.info('browser is online again, resuming heartbeat')
- interval = startPolling()
- try {
- await poll()
- console.info('session token successfully updated after resuming network')
-
- // Let apps know we're online and requests will have the new token
- emit('networkOnline', {
- success: true,
- })
- } catch (e) {
- console.error('could not update session token after resuming network', e)
-
- // Let apps know we're online but requests might have an outdated token
- emit('networkOnline', {
- success: false,
- })
- }
- })
- window.addEventListener('offline', () => {
- console.info('browser is offline, stopping heartbeat')
-
- // Let apps know we're offline
- emit('networkOffline', {})
-
- clearInterval(interval)
- console.info('session heartbeat polling stopped')
- })
-}
diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts
new file mode 100644
index 00000000000..42a9bfccef7
--- /dev/null
+++ b/core/src/session-heartbeat.ts
@@ -0,0 +1,158 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
+import {
+ fetchRequestToken,
+ getRequestToken,
+} from './OC/requesttoken.ts'
+import logger from './logger.js'
+
+interface OcJsConfig {
+ auto_logout: boolean
+ session_keepalive: boolean
+ session_lifetime: number
+}
+
+// This is always set, exception would be e.g. error pages where this is undefined
+const {
+ auto_logout: autoLogout,
+ session_keepalive: keepSessionAlive,
+ session_lifetime: sessionLifetime,
+} = loadState<Partial<OcJsConfig>>('core', 'config', {})
+
+/**
+ * Calls the server periodically to ensure that session and CSRF
+ * token doesn't expire
+ */
+export function initSessionHeartBeat() {
+ registerAutoLogout()
+
+ if (!keepSessionAlive) {
+ logger.info('Session heartbeat disabled')
+ return
+ }
+
+ let interval = startPolling()
+ window.addEventListener('online', async () => {
+ logger.info('Browser is online again, resuming heartbeat')
+
+ interval = startPolling()
+ try {
+ await poll()
+ logger.info('Session token successfully updated after resuming network')
+
+ // Let apps know we're online and requests will have the new token
+ emit('networkOnline', {
+ success: true,
+ })
+ } catch (error) {
+ logger.error('could not update session token after resuming network', { error })
+
+ // Let apps know we're online but requests might have an outdated token
+ emit('networkOnline', {
+ success: false,
+ })
+ }
+ })
+
+ window.addEventListener('offline', () => {
+ logger.info('Browser is offline, stopping heartbeat')
+
+ // Let apps know we're offline
+ emit('networkOffline', {})
+
+ clearInterval(interval)
+ logger.info('Session heartbeat polling stopped')
+ })
+}
+
+/**
+ * Get interval in seconds
+ */
+function getInterval(): number {
+ const interval = sessionLifetime
+ ? Math.floor(sessionLifetime / 2)
+ : 900
+
+ // minimum one minute, max 24 hours, default 15 minutes
+ return Math.min(
+ 24 * 3600,
+ Math.max(
+ 60,
+ interval,
+ ),
+ )
+}
+
+/**
+ * Poll the CSRF token for changes.
+ * This will also extend the current session if needed.
+ */
+async function poll() {
+ try {
+ await fetchRequestToken()
+ } catch (error) {
+ logger.error('session heartbeat failed', { error })
+ }
+}
+
+/**
+ * Start an window interval with the polling as the callback.
+ *
+ * @return The interval id
+ */
+function startPolling(): number {
+ const interval = window.setInterval(poll, getInterval() * 1000)
+
+ logger.info('session heartbeat polling started')
+ return interval
+}
+
+/**
+ * If enabled this will register event listeners to track if a user is active.
+ * If not the user will be automatically logged out after the configured IDLE time.
+ */
+function registerAutoLogout() {
+ if (!autoLogout || !getCurrentUser()) {
+ return
+ }
+
+ let lastActive = Date.now()
+ window.addEventListener('mousemove', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('touchstart', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('storage', (event) => {
+ if (event.key !== 'lastActive') {
+ return
+ }
+ if (event.newValue === null) {
+ return
+ }
+ lastActive = JSON.parse(event.newValue)
+ })
+
+ let intervalId = 0
+ const logoutCheck = () => {
+ const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000
+ if (lastActive < timeout) {
+ clearTimeout(intervalId)
+ logger.info('Inactivity timout reached, logging out')
+ const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
+ window.location.href = logoutUrl
+ }
+ }
+ intervalId = window.setInterval(logoutCheck, 1000)
+}
diff --git a/core/src/store/unified-search-external-filters.js b/core/src/store/unified-search-external-filters.js
new file mode 100644
index 00000000000..55de34b8b2a
--- /dev/null
+++ b/core/src/store/unified-search-external-filters.js
@@ -0,0 +1,17 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { defineStore } from 'pinia'
+
+export const useSearchStore = defineStore('search', {
+ state: () => ({
+ externalFilters: [],
+ }),
+
+ actions: {
+ registerExternalFilter({ id, appId, searchFrom, label, callback, icon }) {
+ this.externalFilters.push({ id, appId, searchFrom, name: label, callback, icon, isPluginFilter: true })
+ },
+ },
+})
diff --git a/core/src/systemtags/merged-systemtags.js b/core/src/systemtags/merged-systemtags.js
index 87b0a7da378..e4ccb1d3802 100644
--- a/core/src/systemtags/merged-systemtags.js
+++ b/core/src/systemtags/merged-systemtags.js
@@ -1,23 +1,7 @@
/**
- * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import './systemtags.js'
diff --git a/core/src/systemtags/systemtagmodel.js b/core/src/systemtags/systemtagmodel.js
index 72f2d6f0915..1d2cd3ae57d 100644
--- a/core/src/systemtags/systemtagmodel.js
+++ b/core/src/systemtags/systemtagmodel.js
@@ -1,75 +1,58 @@
/**
- * Copyright (c) 2015
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Michael Jobst <mjobst+github@tecratech.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * @param {object} OC The OC namespace
*/
(function(OC) {
+ if (OC?.Files?.Client) {
+ _.extend(OC.Files.Client, {
+ PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
+ PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
+ PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
+ PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
+ PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
+ })
- _.extend(OC.Files.Client, {
- PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
- PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
- PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
- PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
- PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
- })
-
- /**
- * @class OCA.SystemTags.SystemTagsCollection
- * @classdesc
- *
- * System tag
- *
- */
- const SystemTagModel = OC.Backbone.Model.extend(
- /** @lends OCA.SystemTags.SystemTagModel.prototype */ {
- sync: OC.Backbone.davSync,
+ /**
+ * @class OCA.SystemTags.SystemTagsCollection
+ * @classdesc
+ *
+ * System tag
+ *
+ */
+ const SystemTagModel = OC.Backbone.Model.extend(
+ /** @lends OCA.SystemTags.SystemTagModel.prototype */ {
+ sync: OC.Backbone.davSync,
- defaults: {
- userVisible: true,
- userAssignable: true,
- canAssign: true,
- },
+ defaults: {
+ userVisible: true,
+ userAssignable: true,
+ canAssign: true,
+ },
- davProperties: {
- id: OC.Files.Client.PROPERTY_FILEID,
- name: OC.Files.Client.PROPERTY_DISPLAYNAME,
- userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
- userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
- // read-only, effective permissions computed by the server,
- canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
- },
+ davProperties: {
+ id: OC.Files.Client.PROPERTY_FILEID,
+ name: OC.Files.Client.PROPERTY_DISPLAYNAME,
+ userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
+ userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
+ // read-only, effective permissions computed by the server,
+ canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
+ },
- parse(data) {
- return {
- id: data.id,
- name: data.name,
- userVisible: data.userVisible === true || data.userVisible === 'true',
- userAssignable: data.userAssignable === true || data.userAssignable === 'true',
- canAssign: data.canAssign === true || data.canAssign === 'true',
- }
- },
- })
+ parse(data) {
+ return {
+ id: data.id,
+ name: data.name,
+ userVisible: data.userVisible === true || data.userVisible === 'true',
+ userAssignable: data.userAssignable === true || data.userAssignable === 'true',
+ canAssign: data.canAssign === true || data.canAssign === 'true',
+ }
+ },
+ })
- OC.SystemTags = OC.SystemTags || {}
- OC.SystemTags.SystemTagModel = SystemTagModel
+ OC.SystemTags = OC.SystemTags || {}
+ OC.SystemTags.SystemTagModel = SystemTagModel
+ }
})(OC)
diff --git a/core/src/systemtags/systemtags.js b/core/src/systemtags/systemtags.js
index 90b415d1557..ceb4652fe1c 100644
--- a/core/src/systemtags/systemtags.js
+++ b/core/src/systemtags/systemtags.js
@@ -1,27 +1,7 @@
/**
- * Copyright (c) 2016
- *
- * @author Gary Kim <gary@garykim.dev>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
diff --git a/core/src/systemtags/systemtagscollection.js b/core/src/systemtags/systemtagscollection.js
index b123ef30fe4..960d26ed36e 100644
--- a/core/src/systemtags/systemtagscollection.js
+++ b/core/src/systemtags/systemtagscollection.js
@@ -1,24 +1,7 @@
/**
- * Copyright (c) 2015
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
diff --git a/core/src/systemtags/systemtagsinputfield.js b/core/src/systemtags/systemtagsinputfield.js
index 5f298577386..b31d24dd0b5 100644
--- a/core/src/systemtags/systemtagsinputfield.js
+++ b/core/src/systemtags/systemtagsinputfield.js
@@ -1,26 +1,7 @@
/**
- * Copyright (c) 2015
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
@@ -177,7 +158,7 @@ import templateSelection from './templates/selection.handlebars'
var $item = $(ev.target).closest('.systemtags-item')
var tagId = $item.attr('data-id')
this.collection.get(tagId).destroy()
- $(ev.target).tooltip('hide')
+ $(ev.target).tooltip('option', 'hide')
$item.closest('.select2-result').remove()
// TODO: spinner
return false
diff --git a/core/src/systemtags/systemtagsmappingcollection.js b/core/src/systemtags/systemtagsmappingcollection.js
index f7e9e9cfe40..78c23ff67f0 100644
--- a/core/src/systemtags/systemtagsmappingcollection.js
+++ b/core/src/systemtags/systemtagsmappingcollection.js
@@ -1,25 +1,7 @@
/**
- * Copyright (c) 2015
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateRemoteUrl } from '@nextcloud/router'
diff --git a/core/src/tests/.eslintrc.js b/core/src/tests/.eslintrc.js
index b44ea2c697d..598fc5c28b4 100644
--- a/core/src/tests/.eslintrc.js
+++ b/core/src/tests/.eslintrc.js
@@ -1,25 +1,7 @@
/**
- * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
module.exports = {
globals: {
jsdom: true,
diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js
deleted file mode 100644
index 741dc65746b..00000000000
--- a/core/src/tests/OC/requesttoken.spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author François Freitag <mail@franek.fr>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-
-import { manageToken, setToken } from '../../OC/requesttoken'
-
-describe('request token', () => {
-
- let emit
- let manager
- const token = 'abc123'
-
- beforeEach(() => {
- emit = jest.fn()
- const head = window.document.getElementsByTagName('head')[0]
- head.setAttribute('data-requesttoken', token)
-
- manager = manageToken(window.document, emit)
- })
-
- test('reads the token from the document', () => {
- expect(manager.getToken()).toBe('abc123')
- })
-
- test('remembers the updated token', () => {
- manager.setToken('bca321')
-
- expect(manager.getToken()).toBe('bca321')
- })
-
- describe('@nextcloud/auth integration', () => {
- let listener
-
- beforeEach(() => {
- listener = jest.fn()
-
- subscribe('csrf-token-update', listener)
- })
-
- afterEach(() => {
- unsubscribe('csrf-token-update', listener)
- })
-
- test('fires off an event for @nextcloud/auth', () => {
- setToken('123')
-
- expect(listener).toHaveBeenCalledWith({ token: '123' })
- })
- })
-
-})
diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts
new file mode 100644
index 00000000000..8f92dbed153
--- /dev/null
+++ b/core/src/tests/OC/requesttoken.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { setupServer } from 'msw/node'
+import { http, HttpResponse } from 'msw'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts'
+
+const eventbus = vi.hoisted(() => ({ emit: vi.fn() }))
+vi.mock('@nextcloud/event-bus', () => eventbus)
+
+const server = setupServer()
+
+describe('getRequestToken', () => {
+ it('can read the token from DOM', () => {
+ mockToken('tokenmock-123')
+ expect(getRequestToken()).toBe('tokenmock-123')
+ })
+
+ it('can handle missing token', () => {
+ mockToken(undefined)
+ expect(getRequestToken()).toBeUndefined()
+ })
+})
+
+describe('setRequestToken', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('does emit an event on change', () => {
+ setRequestToken('new-token')
+ expect(eventbus.emit).toBeCalledTimes(1)
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('does set the new token to the DOM', () => {
+ setRequestToken('new-token')
+ expect(document.head.dataset.requesttoken).toBe('new-token')
+ })
+
+ it('does remember the new token', () => {
+ mockToken('old-token')
+ setRequestToken('new-token')
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('throws if the token is not a string', () => {
+ // @ts-expect-error mocking
+ expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given')
+ })
+
+ it('throws if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ })
+
+ it('does not emit an event if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ expect(eventbus.emit).not.toBeCalled()
+ })
+})
+
+describe('fetchRequestToken', () => {
+ const successfullCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json({ token: 'new-token' })
+ })
+ const forbiddenCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 403 })
+ })
+ const serverErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 500 })
+ })
+ const networkErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return new HttpResponse(null, { type: 'error' })
+ })
+
+ beforeAll(() => {
+ server.listen()
+ })
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('correctly parses response', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ const token = await fetchRequestToken()
+ expect(token).toBe('new-token')
+ })
+
+ it('sets the token', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ await fetchRequestToken()
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('does emit an event', async () => {
+ server.use(successfullCsrf)
+
+ await fetchRequestToken()
+ expect(eventbus.emit).toHaveBeenCalledOnce()
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('handles 403 error due to invalid cookies', async () => {
+ server.use(forbiddenCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles server error', async () => {
+ server.use(serverErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles network error', async () => {
+ server.use(networkErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrow()
+ expect(getRequestToken()).toBe('oldToken')
+ })
+})
+
+/**
+ * Mock the request token directly so we can test reading it.
+ *
+ * @param token - The CSRF token to mock
+ */
+function mockToken(token?: string) {
+ if (token === undefined) {
+ delete document.head.dataset.requesttoken
+ } else {
+ document.head.dataset.requesttoken = token
+ }
+}
diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts
new file mode 100644
index 00000000000..61b82d92887
--- /dev/null
+++ b/core/src/tests/OC/session-heartbeat.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const requestToken = vi.hoisted(() => ({
+ fetchRequestToken: vi.fn<() => Promise<string>>(),
+ setRequestToken: vi.fn<(token: string) => void>(),
+}))
+vi.mock('../../OC/requesttoken.ts', () => requestToken)
+
+const initialState = vi.hoisted(() => ({ loadState: vi.fn() }))
+vi.mock('@nextcloud/initial-state', () => initialState)
+
+describe('Session heartbeat', () => {
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
+
+ beforeEach(() => {
+ vi.clearAllTimers()
+ vi.resetModules()
+ vi.resetAllMocks()
+ })
+
+ it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // reach past half, one call
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // almost there to the next, still one
+ await vi.advanceTimersByTimeAsync(135 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // past it, second call
+ await vi.advanceTimersByTimeAsync(5 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(2)
+ })
+
+ it('does not send heartbeat when heartbeat disabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: false,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // more than one, still nothing
+ await vi.advanceTimersByTimeAsync(300 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 55,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 30 / 55 seconds
+ await vi.advanceTimersByTimeAsync(30 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // 59 / 55 seconds should not be called except it does not limit
+ await vi.advanceTimersByTimeAsync(29 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // now one minute has passed
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 50 * 60 * 60,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 23 hours
+ await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // one day - it should be called now
+ await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+})
diff --git a/core/src/tests/components/ContactsMenu/Contact.spec.js b/core/src/tests/components/ContactsMenu/Contact.spec.js
new file mode 100644
index 00000000000..e83f75bfd15
--- /dev/null
+++ b/core/src/tests/components/ContactsMenu/Contact.spec.js
@@ -0,0 +1,44 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+
+import Contact from '../../../components/ContactsMenu/Contact.vue'
+
+describe('Contact', function() {
+ it('links to the top action', () => {
+ const view = shallowMount(Contact, {
+ propsData: {
+ contact: {
+ id: null,
+ fullName: 'Acosta Lancaster',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:deboraoliver%40centrexin.com',
+ },
+ emailAddresses: [],
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:mathisholland%40virxo.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: '',
+ },
+ },
+ })
+
+ expect(view.find('li a').exists()).toBe(true)
+ expect(view.find('li a').attributes('href')).toBe('mailto:deboraoliver%40centrexin.com')
+ })
+})
diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js
new file mode 100644
index 00000000000..084c3215e47
--- /dev/null
+++ b/core/src/tests/views/ContactsMenu.spec.js
@@ -0,0 +1,143 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mount, shallowMount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import ContactsMenu from '../../views/ContactsMenu.vue'
+
+const axios = vi.hoisted(() => ({
+ post: vi.fn(),
+}))
+vi.mock('@nextcloud/axios', () => ({ default: axios }))
+
+vi.mock('@nextcloud/auth', () => ({
+ getCurrentUser: () => ({ uid: 'user', isAdmin: false, displayName: 'User' }),
+}))
+
+describe('ContactsMenu', function() {
+ it('is closed by default', () => {
+ const view = shallowMount(ContactsMenu)
+
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe(undefined)
+ })
+
+ it('shows a loading text', async () => {
+ const view = shallowMount(ContactsMenu)
+ axios.post.mockResolvedValue({
+ data: {
+ contacts: [],
+ contactsAppEnabled: false,
+ },
+ })
+
+ const opening = view.vm.handleOpen()
+
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe('Loading your contacts …')
+ await opening
+ })
+
+ it('shows error view when contacts can not be loaded', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValue({})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ try {
+ await view.vm.handleOpen()
+
+ throw new Error('should not be reached')
+ } catch (error) {
+ expect(console.error).toHaveBeenCalled()
+ console.error.mockRestore()
+ expect(view.vm.error).toBe(true)
+ expect(view.vm.contacts).toEqual([])
+ expect(view.text()).toContain('Could not load your contacts')
+ }
+ })
+
+ it('shows text when there are no contacts', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValueOnce({
+ data: {
+ contacts: [],
+ contactsAppEnabled: false,
+ },
+ })
+
+ await view.vm.handleOpen()
+
+ expect(view.vm.error).toBe(false)
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe(undefined)
+ expect(view.text()).toContain('No contacts found')
+ })
+
+ it('shows contacts', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValue({
+ data: {
+ contacts: [
+ {
+ id: null,
+ fullName: 'Acosta Lancaster',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:deboraoliver%40centrexin.com',
+ },
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:mathisholland%40virxo.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: '',
+ emailAddresses: [],
+ },
+ {
+ id: null,
+ fullName: 'Adeline Snider',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:ceciliasoto%40essensia.com',
+ },
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:pearliesellers%40inventure.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: 'cu',
+ emailAddresses: [],
+ },
+ ],
+ contactsAppEnabled: true,
+ },
+ })
+
+ await view.vm.handleOpen()
+
+ expect(view.vm.error).toBe(false)
+ expect(view.vm.contacts.length).toBe(2)
+ expect(view.text()).toContain('Acosta Lancaster')
+ expect(view.text()).toContain('Adeline Snider')
+ expect(view.text()).toContain('Show all contacts')
+ })
+})
diff --git a/core/src/twofactor-request-token.ts b/core/src/twofactor-request-token.ts
new file mode 100644
index 00000000000..868ceec01e9
--- /dev/null
+++ b/core/src/twofactor-request-token.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { onRequestTokenUpdate } from '@nextcloud/auth'
+import { getBaseUrl } from '@nextcloud/router'
+
+document.addEventListener('DOMContentLoaded', () => {
+ onRequestTokenUpdate((token) => {
+ const cancelLink = window.document.getElementById('cancel-login')
+ if (!cancelLink) {
+ return
+ }
+
+ const href = cancelLink.getAttribute('href')
+ if (!href) {
+ return
+ }
+
+ const parsedHref = new URL(href, getBaseUrl())
+ parsedHref.searchParams.set('requesttoken', token)
+ cancelLink.setAttribute('href', parsedHref.pathname + parsedHref.search)
+ })
+})
diff --git a/core/src/types/navigation.d.ts b/core/src/types/navigation.d.ts
new file mode 100644
index 00000000000..5698aab205e
--- /dev/null
+++ b/core/src/types/navigation.d.ts
@@ -0,0 +1,30 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/** See NavigationManager */
+export interface INavigationEntry {
+ /** Navigation id */
+ id: string
+ /** If this is the currently active app */
+ active: boolean
+ /** Order where this entry should be shown */
+ order: number
+ /** Target of the navigation entry */
+ href: string
+ /** The icon used for the naviation entry */
+ icon: string
+ /** Type of the navigation entry ('link' vs 'settings') */
+ type: 'link' | 'settings'
+ /** Localized name of the navigation entry */
+ name: string
+ /** Whether this is the default app */
+ default?: boolean
+ /** App that registered this navigation entry (not necessarly the same as the id) */
+ app?: string
+ /** If this app has unread notification */
+ unread: number
+ /** True when the link should be opened in a new tab */
+ target?: boolean
+}
diff --git a/core/src/unified-search.js b/core/src/unified-search.js
deleted file mode 100644
index cc390c0d6e7..00000000000
--- a/core/src/unified-search.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { getRequestToken } from '@nextcloud/auth'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import Vue from 'vue'
-
-import UnifiedSearch from './views/UnifiedSearch.vue'
-
-// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-
-const logger = getLoggerBuilder()
- .setApp('unified-search')
- .detectUser()
- .build()
-
-Vue.mixin({
- data() {
- return {
- logger,
- }
- },
- methods: {
- t,
- n,
- },
-})
-
-export default new Vue({
- el: '#unified-search',
- // eslint-disable-next-line vue/match-component-file-name
- name: 'UnifiedSearchRoot',
- render: h => h(UnifiedSearch),
-})
diff --git a/core/src/unified-search.ts b/core/src/unified-search.ts
new file mode 100644
index 00000000000..a13b1036da1
--- /dev/null
+++ b/core/src/unified-search.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { createPinia, PiniaVuePlugin } from 'pinia'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/UnifiedSearch.vue'
+import { useSearchStore } from '../src/store/unified-search-external-filters.js'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+const logger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
+
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+// Define type structure for unified searc action
+interface UnifiedSearchAction {
+ id: string;
+ appId: string;
+ searchFrom: string;
+ label: string;
+ icon: string;
+ callback: () => void;
+}
+
+// Register the add/register filter action API globally
+window.OCA = window.OCA || {}
+window.OCA.UnifiedSearch = {
+ registerFilterAction: ({ id, appId, searchFrom, label, callback, icon }: UnifiedSearchAction) => {
+ const searchStore = useSearchStore()
+ searchStore.registerExternalFilter({ id, appId, searchFrom, label, callback, icon })
+ },
+}
+
+Vue.use(PiniaVuePlugin)
+const pinia = createPinia()
+
+export default new Vue({
+ el: '#unified-search',
+ pinia,
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
+})
diff --git a/core/src/unsupported-browser-redirect.js b/core/src/unsupported-browser-redirect.js
index 5ea64221a04..64620afa085 100644
--- a/core/src/unsupported-browser-redirect.js
+++ b/core/src/unsupported-browser-redirect.js
@@ -1,26 +1,16 @@
/**
- * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { testSupportedBrowser } from './utils/RedirectUnsupportedBrowsers.js'
+import { getCSPNonce } from '@nextcloud/auth'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
if (!window.TESTING && !OC?.config?.no_unsupported_browser_warning) {
- testSupportedBrowser()
+ window.addEventListener('DOMContentLoaded', async function() {
+ const { testSupportedBrowser } = await import('./utils/RedirectUnsupportedBrowsers.js')
+ testSupportedBrowser()
+ })
}
diff --git a/core/src/unsupported-browser.js b/core/src/unsupported-browser.js
index a9b44b666fc..d54b1c8fb24 100644
--- a/core/src/unsupported-browser.js
+++ b/core/src/unsupported-browser.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
diff --git a/core/src/utils/ClipboardFallback.ts b/core/src/utils/ClipboardFallback.ts
new file mode 100644
index 00000000000..b374f9d0a44
--- /dev/null
+++ b/core/src/utils/ClipboardFallback.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { t } from '@nextcloud/l10n'
+
+/**
+ *
+ * @param text
+ */
+function unsecuredCopyToClipboard(text) {
+ const textArea = document.createElement('textarea')
+ const textAreaContent = document.createTextNode(text)
+ textArea.appendChild(textAreaContent)
+ document.body.appendChild(textArea)
+
+ textArea.focus({ preventScroll: true })
+ textArea.select()
+
+ try {
+ // This is a fallback for browsers that do not support the Clipboard API
+ // execCommand is deprecated, but it is the only way to copy text to the clipboard in some browsers
+ document.execCommand('copy')
+ } catch (err) {
+ window.prompt(t('core', 'Clipboard not available, please copy manually'), text)
+ console.error('[ERROR] core: files Unable to copy to clipboard', err)
+ }
+
+ document.body.removeChild(textArea)
+}
+
+/**
+ *
+ */
+function initFallbackClipboardAPI() {
+ if (!window.navigator?.clipboard?.writeText) {
+ console.info('[INFO] core: Clipboard API not available, using fallback')
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: {
+ writeText: unsecuredCopyToClipboard,
+ },
+ writable: false,
+ })
+ }
+}
+
+export { initFallbackClipboardAPI }
diff --git a/core/src/utils/RedirectUnsupportedBrowsers.js b/core/src/utils/RedirectUnsupportedBrowsers.js
index 16076a9afd3..2880d051ca2 100644
--- a/core/src/utils/RedirectUnsupportedBrowsers.js
+++ b/core/src/utils/RedirectUnsupportedBrowsers.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
diff --git a/core/src/utils/xhr-request.js b/core/src/utils/xhr-request.js
new file mode 100644
index 00000000000..7f074a857a6
--- /dev/null
+++ b/core/src/utils/xhr-request.js
@@ -0,0 +1,133 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.js'
+
+/**
+ *
+ * @param {string} url the URL to check
+ * @return {boolean}
+ */
+const isRelativeUrl = (url) => {
+ return !url.startsWith('https://') && !url.startsWith('http://')
+}
+
+/**
+ * @param {string} url The URL to check
+ * @return {boolean} true if the URL points to this nextcloud instance
+ */
+const isNextcloudUrl = (url) => {
+ const nextcloudBaseUrl = window.location.protocol + '//' + window.location.host + getRootUrl()
+ // if the URL is absolute and starts with the baseUrl+rootUrl
+ // OR if the URL is relative and starts with rootUrl
+ return url.startsWith(nextcloudBaseUrl)
+ || (isRelativeUrl(url) && url.startsWith(getRootUrl()))
+}
+
+/**
+ * Check if a user was logged in but is now logged-out.
+ * If this is the case then the user will be forwarded to the login page.
+ * @return {Promise<void>}
+ */
+async function checkLoginStatus() {
+ // skip if no logged in user
+ if (getCurrentUser() === null) {
+ return
+ }
+
+ // skip if already running
+ if (checkLoginStatus.running === true) {
+ return
+ }
+
+ // only run one request in parallel
+ checkLoginStatus.running = true
+
+ try {
+ // We need to check this as a 401 in the first place could also come from other reasons
+ const { status } = await window.fetch(generateUrl('/apps/files'))
+ if (status === 401) {
+ console.warn('User session was terminated, forwarding to login page.')
+ await wipeBrowserStorages()
+ window.location = generateUrl('/login?redirect_url={url}', {
+ url: window.location.pathname + window.location.search + window.location.hash,
+ })
+ }
+ } catch (error) {
+ console.warn('Could not check login-state')
+ } finally {
+ delete checkLoginStatus.running
+ }
+}
+
+/**
+ * Clear all Browser storages connected to current origin.
+ * @return {Promise<void>}
+ */
+export async function wipeBrowserStorages() {
+ try {
+ window.localStorage.clear()
+ window.sessionStorage.clear()
+ const indexedDBList = await window.indexedDB.databases()
+ for (const indexedDB of indexedDBList) {
+ await window.indexedDB.deleteDatabase(indexedDB.name)
+ }
+ logger.debug('Browser storages cleared')
+ } catch (error) {
+ logger.error('Could not clear browser storages', { error })
+ }
+}
+
+/**
+ * Intercept XMLHttpRequest and fetch API calls to add X-Requested-With header
+ *
+ * This is also done in @nextcloud/axios but not all requests pass through that
+ */
+export const interceptRequests = () => {
+ XMLHttpRequest.prototype.open = (function(open) {
+ return function(method, url, async) {
+ open.apply(this, arguments)
+ if (isNextcloudUrl(url)) {
+ if (!this.getResponseHeader('X-Requested-With')) {
+ this.setRequestHeader('X-Requested-With', 'XMLHttpRequest')
+ }
+ this.addEventListener('loadend', function() {
+ if (this.status === 401) {
+ checkLoginStatus()
+ }
+ })
+ }
+ }
+ })(XMLHttpRequest.prototype.open)
+
+ window.fetch = (function(fetch) {
+ return async (resource, options) => {
+ // fetch allows the `input` to be either a Request object or any stringifyable value
+ if (!isNextcloudUrl(resource.url ?? resource.toString())) {
+ return await fetch(resource, options)
+ }
+ if (!options) {
+ options = {}
+ }
+ if (!options.headers) {
+ options.headers = new Headers()
+ }
+
+ if (options.headers instanceof Headers && !options.headers.has('X-Requested-With')) {
+ options.headers.append('X-Requested-With', 'XMLHttpRequest')
+ } else if (options.headers instanceof Object && !options.headers['X-Requested-With']) {
+ options.headers['X-Requested-With'] = 'XMLHttpRequest'
+ }
+
+ const response = await fetch(resource, options)
+ if (response.status === 401) {
+ checkLoginStatus()
+ }
+ return response
+ }
+ })(window.fetch)
+}
diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue
new file mode 100644
index 00000000000..5b7ead636bd
--- /dev/null
+++ b/core/src/views/AccountMenu.vue
@@ -0,0 +1,247 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="user-menu"
+ class="account-menu"
+ is-nav
+ :aria-label="t('core', 'Settings menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
+ <NcAvatar :key="String(showUserStatus)"
+ class="account-menu__avatar"
+ disable-menu
+ disable-tooltip
+ :show-user-status="showUserStatus"
+ :user="currentUserId"
+ :preloaded-user-status="userStatus" />
+ </template>
+ <ul class="account-menu__list">
+ <AccountMenuProfileEntry :id="profileEntry.id"
+ :name="profileEntry.name"
+ :href="profileEntry.href"
+ :active="profileEntry.active" />
+ <AccountMenuEntry v-for="entry in otherEntries"
+ :id="entry.id"
+ :key="entry.id"
+ :name="entry.name"
+ :href="entry.href"
+ :active="entry.active"
+ :icon="entry.icon" />
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import { getCurrentUser } from '@nextcloud/auth'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { defineComponent } from 'vue'
+import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
+
+import axios from '@nextcloud/axios'
+import logger from '../logger.js'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+interface ISettingsNavigationEntry {
+ /**
+ * id of the entry, used as HTML ID, for example, "settings"
+ */
+ id: string
+ /**
+ * Label of the entry, for example, "Personal Settings"
+ */
+ name: string
+ /**
+ * Icon of the entry, for example, "/apps/settings/img/personal.svg"
+ */
+ icon: string
+ /**
+ * Type of the entry
+ */
+ type: 'settings'|'link'|'guest'
+ /**
+ * Link of the entry, for example, "/settings/user"
+ */
+ href: string
+ /**
+ * Whether the entry is active
+ */
+ active: boolean
+ /**
+ * Order of the entry
+ */
+ order: number
+ /**
+ * Number of unread pf this items
+ */
+ unread: number
+ /**
+ * Classes for custom styling
+ */
+ classes: string
+}
+
+const USER_DEFINABLE_STATUSES = getAllStatusOptions()
+
+export default defineComponent({
+ name: 'AccountMenu',
+
+ components: {
+ AccountMenuEntry,
+ AccountMenuProfileEntry,
+ NcAvatar,
+ NcHeaderMenu,
+ },
+
+ setup() {
+ const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {})
+ const { profile: profileEntry, ...otherEntries } = settingsNavEntries
+
+ return {
+ currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid,
+ currentUserId: getCurrentUser()!.uid,
+
+ profileEntry,
+ otherEntries,
+
+ t,
+ }
+ },
+
+ data() {
+ return {
+ showUserStatus: false,
+ userStatus: {
+ status: null,
+ icon: null,
+ message: null,
+ },
+ }
+ },
+
+ computed: {
+ translatedUserStatus() {
+ return {
+ ...this.userStatus,
+ status: this.translateStatus(this.userStatus.status),
+ }
+ },
+
+ avatarDescription() {
+ const description = [
+ t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }),
+ ...Object.values(this.translatedUserStatus).filter(Boolean),
+ ].join(' — ')
+ return description
+ },
+ },
+
+ async created() {
+ if (!getCapabilities()?.user_status?.enabled) {
+ return
+ }
+
+ const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
+ try {
+ const response = await axios.get(url)
+ const { status, icon, message } = response.data.ocs.data
+ this.userStatus = { status, icon, message }
+ } catch (e) {
+ logger.error('Failed to load user status')
+ }
+ this.showUserStatus = true
+ },
+
+ mounted() {
+ subscribe('user_status:status.updated', this.handleUserStatusUpdated)
+ emit('core:user-menu:mounted')
+ },
+
+ methods: {
+ handleUserStatusUpdated(state) {
+ if (this.currentUserId === state.userId) {
+ this.userStatus = {
+ status: state.status,
+ icon: state.icon,
+ message: state.message,
+ }
+ }
+ },
+
+ translateStatus(status) {
+ const statusMap = Object.fromEntries(
+ USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]),
+ )
+ if (statusMap[status]) {
+ return statusMap[status]
+ }
+ return status
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(#header-menu-user-menu) {
+ padding: 0 !important;
+}
+
+.account-menu {
+ &__avatar {
+ --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%);
+ outline: var(--account-menu-outline);
+ position: fixed;
+ // do not apply the alpha mask on the avatar div
+ mask: none !important;
+
+ &:hover {
+ --account-menu-outline: none;
+ // Add hover styles similar to the focus-visible style
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ padding-inline: 0 var(--default-grid-baseline);
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ :deep(button) {
+ // Normally header menus are slightly translucent when not active
+ // this is generally ok but for the avatar this is weird so fix the opacity
+ opacity: 1 !important;
+
+ // The avatar is just the "icon" of the button
+ // So we add the focus-visible manually
+ &:focus-visible {
+ .account-menu__avatar {
+ --account-menu-outline: none;
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+ }
+}
+</style>
diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue
index f03652bb477..924ddcea56b 100644
--- a/core/src/views/ContactsMenu.vue
+++ b/core/src/views/ContactsMenu.vue
@@ -1,198 +1,233 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcHeaderMenu id="contactsmenu"
+ class="contactsmenu"
:aria-label="t('core', 'Search contacts')"
@open="handleOpen">
<template #trigger>
- <Contacts :size="20" />
+ <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
</template>
- <div id="contactsmenu-menu" />
+ <div class="contactsmenu__menu">
+ <div class="contactsmenu__menu__input-wrapper">
+ <NcTextField id="contactsmenu__menu__search"
+ ref="contactsMenuInput"
+ :value.sync="searchTerm"
+ trailing-button-icon="close"
+ :label="t('core', 'Search contacts')"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="searchTerm !== ''"
+ :placeholder="t('core', 'Search contacts …')"
+ class="contactsmenu__menu__search"
+ @input="onInputDebounced"
+ @trailing-button-click="onReset" />
+ </div>
+ <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="loadingText" :name="loadingText">
+ <template #icon>
+ <NcLoadingIcon />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <div v-else class="contactsmenu__menu__content">
+ <div id="contactsmenu-contacts">
+ <ul>
+ <Contact v-for="contact in contacts" :key="contact.id" :contact="contact" />
+ </ul>
+ </div>
+ <div v-if="contactsAppEnabled" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppURL">
+ {{ t('core', 'Show all contacts') }}
+ </NcButton>
+ </div>
+ <div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppMgmtURL">
+ {{ t('core', 'Install the Contacts app') }}
+ </NcButton>
+ </div>
+ </div>
+ </div>
</NcHeaderMenu>
</template>
<script>
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-
-import Contacts from 'vue-material-design-icons/Contacts.vue'
-
-import OC from '../OC/index.js'
+import { mdiContacts, mdiMagnify } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Contact from '../components/ContactsMenu/Contact.vue'
+import logger from '../logger.js'
+import Nextcloud from '../mixins/Nextcloud.js'
export default {
name: 'ContactsMenu',
components: {
- Contacts,
+ Contact,
+ NcButton,
+ NcEmptyContent,
NcHeaderMenu,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ NcTextField,
},
- data() {
+ mixins: [Nextcloud],
+
+ setup() {
return {
- contactsMenu: null,
+ mdiContacts,
+ mdiMagnify,
}
},
- mounted() {
- // eslint-disable-next-line no-new
- this.contactsMenu = new OC.ContactsMenu({
- el: '#contactsmenu-menu',
- })
+ data() {
+ const user = getCurrentUser()
+ return {
+ contactsAppEnabled: false,
+ contactsAppURL: generateUrl('/apps/contacts'),
+ contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'),
+ canInstallApp: user.isAdmin,
+ contacts: [],
+ loadingText: undefined,
+ error: false,
+ searchTerm: '',
+ }
},
methods: {
- handleOpen() {
- this.contactsMenu?.loadContacts()
+ async handleOpen() {
+ await this.getContacts('')
},
+ async getContacts(searchTerm) {
+ if (searchTerm === '') {
+ this.loadingText = t('core', 'Loading your contacts …')
+ } else {
+ this.loadingText = t('core', 'Looking for {term} …', {
+ term: searchTerm,
+ })
+ }
+
+ // Let the user try a different query if the previous one failed
+ this.error = false
+
+ try {
+ const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ this.contacts = contacts
+ this.contactsAppEnabled = contactsAppEnabled
+ this.loadingText = undefined
+ } catch (error) {
+ logger.error('could not load contacts', {
+ error,
+ searchTerm,
+ })
+ this.error = true
+ }
+ },
+ onInputDebounced: debounce(function() {
+ this.getContacts(this.searchTerm)
+ }, 500),
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ this.searchTerm = ''
+ this.contacts = []
+ this.focusInput()
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.contactsMenuInput.focus()
+ this.$refs.contactsMenuInput.select()
+ })
+ },
+
},
}
</script>
<style lang="scss" scoped>
-#contactsmenu-menu {
- /* show 2.5 to 4.5 entries depending on the screen height */
- height: calc(100vh - 50px * 3);
- max-height: calc(50px * 6 + 2px);
- min-height: calc(50px * 3.5);
- width: 350px;
-
- &:deep {
- .emptycontent {
- margin-top: 5vh !important;
- margin-bottom: 1.5vh;
- .icon-loading,
- .icon-search {
- display: inline-block;
- }
+.contactsmenu {
+ overflow-y: hidden;
+
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__menu {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: calc(50px * 6 + 2px + 26px);
+ max-height: inherit;
+
+ label[for="contactsmenu__menu__search"] {
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
}
- #contactsmenu-search {
- width: calc(100% - 16px);
- margin: 8px;
+ &__input-wrapper {
+ padding: 10px;
+ z-index: 2;
+ top: 0;
+ }
+
+ &__search {
+ width: 100%;
height: 34px;
+ margin-top: 0!important;
}
- .content {
- /* fixed max height of the parent container without the search input */
- height: calc(100vh - 50px * 3 - 50px);
- max-height: calc(50px * 5);
- min-height: calc(50px * 3.5 - 50px);
+ &__content {
overflow-y: auto;
+ margin-top: 10px;
+ flex: 1 1 auto;
- .footer {
- text-align: center;
-
- a {
- display: block;
- width: 100%;
- padding: 12px 0;
- opacity: .5;
- }
+ &__footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
}
}
a {
- padding: 2px;
-
&:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-main-text) !important; // override rule in core/css/headers.scss #header a:focus-visible
}
}
+ }
- .contact {
- display: flex;
- position: relative;
- align-items: center;
- padding: 3px 3px 3px 10px;
-
- .avatar {
- height: 32px;
- width: 32px;
- display: inline-block;
- }
-
- .body {
- flex-grow: 1;
- padding-left: 8px;
-
- div {
- position: relative;
- width: 100%;
- }
-
- .full-name, .last-message {
- /* TODO: don't use fixed width */
- max-width: 204px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .last-message, .email-address {
- color: var(--color-text-maxcontrast);
- }
- }
-
- .top-action, .second-action, .other-actions {
- width: 16px;
- height: 16px;
- opacity: .5;
- cursor: pointer;
-
- &:not(button) {
- padding: 14px;
- }
- img {
- filter: var(--background-invert-if-dark);
- }
-
- &:hover,
- &:active,
- &:focus {
- opacity: 1;
- }
- }
-
- button.other-actions {
- width: 44px;
-
- &:focus {
- border-color: transparent;
- box-shadow: 0 0 0 2px var(--color-main-text);
- }
-
- &:focus-visible {
- border-radius: var(--border-radius-pill);
- }
- }
-
- /* actions menu */
- .menu {
- top: 47px;
- margin-right: 13px;
- }
- .popovermenu::after {
- right: 2px;
- }
- }
+ :deep(.empty-content) {
+ margin: 0 !important;
}
}
</style>
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
new file mode 100644
index 00000000000..1277970ba0e
--- /dev/null
+++ b/core/src/views/LegacyUnifiedSearch.vue
@@ -0,0 +1,848 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="unified-search"
+ class="unified-search"
+ :exclude-click-outside-selectors="['.popover']"
+ :open.sync="open"
+ :aria-label="ariaLabel"
+ @open="onOpen"
+ @close="onClose">
+ <!-- Header icon -->
+ <template #trigger>
+ <Magnify class="unified-search__trigger-icon" :size="20" />
+ </template>
+
+ <!-- Search form & filters wrapper -->
+ <div class="unified-search__input-wrapper">
+ <div class="unified-search__input-row">
+ <NcTextField ref="input"
+ :value.sync="query"
+ trailing-button-icon="close"
+ :label="ariaLabel"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="query !== ''"
+ aria-describedby="unified-search-desc"
+ class="unified-search__form-input"
+ :class="{'unified-search__form-input--with-reset': !!query}"
+ :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
+ @trailing-button-click="onReset"
+ @input="onInputDebounced" />
+ <p id="unified-search-desc" class="hidden-visually">
+ {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
+ </p>
+
+ <!-- Search filters -->
+ <NcActions v-if="availableFilters.length > 1"
+ class="unified-search__filters"
+ placement="bottom-end"
+ container=".unified-search__input-wrapper">
+ <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
+ <NcActionButton v-for="filter in availableFilters"
+ :key="filter"
+ icon="icon-filter"
+ @click.stop="onClickFilter(`in:${filter}`)">
+ {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+
+ <template v-if="!hasResults">
+ <!-- Loading placeholders -->
+ <SearchResultPlaceholders v-if="isLoading" />
+
+ <NcEmptyContent v-else-if="isValidQuery"
+ :title="validQueryTitle">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else-if="!isLoading || isShortQuery"
+ :title="t('core', 'Start typing to search')"
+ :description="shortQueryDescription">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+ </template>
+
+ <!-- Grouped search results -->
+ <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
+ <h2 :key="type" class="unified-search__results-header">
+ {{ typesMap[type] }}
+ </h2>
+ <ul :key="type"
+ class="unified-search__results"
+ :class="`unified-search__results-${type}`"
+ :aria-label="typesMap[type]">
+ <!-- Search results -->
+ <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+ <SearchResult v-bind="result"
+ :query="query"
+ :focused="focused === 0 && typesIndex === 0 && index === 0"
+ @focus="setFocusedIndex" />
+ </li>
+
+ <!-- Load more button -->
+ <li>
+ <SearchResult v-if="!reached[type]"
+ class="unified-search__result-more"
+ :title="loading[type]
+ ? t('core', 'Loading more results …')
+ : t('core', 'Load more results')"
+ :icon-class="loading[type] ? 'icon-loading-small' : ''"
+ @click.prevent.stop="loadMore(type)"
+ @focus="setFocusedIndex" />
+ </li>
+ </ul>
+ </template>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import debounce from 'debounce'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
+
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+ name: 'LegacyUnifiedSearch',
+
+ components: {
+ Magnify,
+ NcActionButton,
+ NcActions,
+ NcEmptyContent,
+ NcHeaderMenu,
+ SearchResult,
+ SearchResultPlaceholders,
+ NcTextField,
+ },
+
+ data() {
+ return {
+ types: [],
+
+ // Cursors per types
+ cursors: {},
+ // Various search limits per types
+ limits: {},
+ // Loading types
+ loading: {},
+ // Reached search types
+ reached: {},
+ // Pending cancellable requests
+ requests: [],
+ // List of all results
+ results: {},
+
+ query: '',
+ focused: null,
+ triggered: false,
+
+ defaultLimit,
+ minSearchLength,
+ enableLiveSearch,
+
+ open: false,
+ }
+ },
+
+ computed: {
+ typesIDs() {
+ return this.types.map(type => type.id)
+ },
+ typesNames() {
+ return this.types.map(type => type.name)
+ },
+ typesMap() {
+ return this.types.reduce((prev, curr) => {
+ prev[curr.id] = curr.name
+ return prev
+ }, {})
+ },
+
+ ariaLabel() {
+ return t('core', 'Search')
+ },
+
+ /**
+ * Is there any result to display
+ *
+ * @return {boolean}
+ */
+ hasResults() {
+ return Object.keys(this.results).length !== 0
+ },
+
+ /**
+ * Return ordered results
+ *
+ * @return {Array}
+ */
+ orderedResults() {
+ return this.typesIDs
+ .filter(type => type in this.results)
+ .map(type => ({
+ type,
+ list: this.results[type],
+ }))
+ },
+
+ /**
+ * Available filters
+ * We only show filters that are available on the results
+ *
+ * @return {string[]}
+ */
+ availableFilters() {
+ return Object.keys(this.results)
+ },
+
+ /**
+ * Applied filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersIn() {
+ let match
+ const filters = []
+ while ((match = regexFilterIn.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Applied anti filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersNot() {
+ let match
+ const filters = []
+ while ((match = regexFilterNot.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Valid query empty content title
+ *
+ * @return {string}
+ */
+ validQueryTitle() {
+ return this.triggered
+ ? t('core', 'No results for {query}', { query: this.query })
+ : t('core', 'Press Enter to start searching')
+ },
+
+ /**
+ * Short query empty content description
+ *
+ * @return {string}
+ */
+ shortQueryDescription() {
+ if (!this.isShortQuery) {
+ return ''
+ }
+
+ return n('core',
+ 'Please enter {minSearchLength} character or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
+ this.minSearchLength,
+ { minSearchLength: this.minSearchLength })
+ },
+
+ /**
+ * Is the current search too short
+ *
+ * @return {boolean}
+ */
+ isShortQuery() {
+ return this.query && this.query.trim().length < minSearchLength
+ },
+
+ /**
+ * Is the current search valid
+ *
+ * @return {boolean}
+ */
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && !this.isShortQuery
+ },
+
+ /**
+ * Have we reached the end of all types searches
+ *
+ * @return {boolean}
+ */
+ isDoneSearching() {
+ return Object.values(this.reached).every(state => state === false)
+ },
+
+ /**
+ * Is there any search in progress
+ *
+ * @return {boolean}
+ */
+ isLoading() {
+ return Object.values(this.loading).some(state => state === true)
+ },
+ },
+
+ async created() {
+ this.types = await getTypes()
+ this.logger.debug('Unified Search initialized with the following providers', this.types)
+ },
+
+ beforeDestroy() {
+ unsubscribe('files:navigation:changed', this.onNavigationChange)
+ },
+
+ mounted() {
+ // subscribe in mounted, as onNavigationChange relys on $el
+ subscribe('files:navigation:changed', this.onNavigationChange)
+
+ if (OCP.Accessibility.disableKeyboardShortcuts()) {
+ return
+ }
+
+ document.addEventListener('keydown', (event) => {
+ // if not already opened, allows us to trigger default browser on second keydown
+ if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
+ event.preventDefault()
+ this.open = true
+ } else if (event.ctrlKey && event.key === 'f' && this.open) {
+ // User wants to use the native browser search, so we close ours again
+ this.open = false
+ }
+
+ // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+ if (this.open) {
+ // If arrow down, focus next result
+ if (event.key === 'ArrowDown') {
+ this.focusNext(event)
+ }
+
+ // If arrow up, focus prev result
+ if (event.key === 'ArrowUp') {
+ this.focusPrev(event)
+ }
+ }
+ })
+ },
+
+ methods: {
+ async onOpen() {
+ // Update types list in the background
+ this.types = await getTypes()
+ },
+ onClose() {
+ emit('nextcloud:unified-search.close')
+ },
+
+ onNavigationChange() {
+ this.$el?.querySelector?.('form[role="search"]')?.reset?.()
+ },
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ emit('nextcloud:unified-search.reset')
+ this.logger.debug('Search reset')
+ this.query = ''
+ this.resetState()
+ this.focusInput()
+ },
+ async resetState() {
+ this.cursors = {}
+ this.limits = {}
+ this.reached = {}
+ this.results = {}
+ this.focused = null
+ this.triggered = false
+ await this.cancelPendingRequests()
+ },
+
+ /**
+ * Cancel any ongoing searches
+ */
+ async cancelPendingRequests() {
+ // Cloning so we can keep processing other requests
+ const requests = this.requests.slice(0)
+ this.requests = []
+
+ // Cancel all pending requests
+ await Promise.all(requests.map(cancel => cancel()))
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ this.$refs.input.select()
+ })
+ },
+
+ /**
+ * If we have results already, open first one
+ * If not, trigger the search again
+ */
+ onInputEnter() {
+ if (this.hasResults) {
+ const results = this.getResultsList()
+ results[0].click()
+ return
+ }
+ this.onInput()
+ },
+
+ /**
+ * Start searching on input
+ */
+ async onInput() {
+ // emit the search query
+ emit('nextcloud:unified-search.search', { query: this.query })
+
+ // Do not search if not long enough
+ if (this.query.trim() === '' || this.isShortQuery) {
+ for (const type of this.typesIDs) {
+ this.$delete(this.results, type)
+ }
+ return
+ }
+
+ let types = this.typesIDs
+ let query = this.query
+
+ // Filter out types
+ if (this.usedFiltersNot.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+ }
+
+ // Only use those filters if any and check if they are valid
+ if (this.usedFiltersIn.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+ }
+
+ // Remove any filters from the query
+ query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+ // Reset search if the query changed
+ await this.resetState()
+ this.triggered = true
+
+ if (!types.length) {
+ // no results since no types were selected
+ this.logger.error('No types to search in')
+ return
+ }
+
+ this.$set(this.loading, 'all', true)
+ this.logger.debug(`Searching ${query} in`, types)
+
+ Promise.all(types.map(async type => {
+ try {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.$set(this.results, type, data.ocs.data.entries)
+ } else {
+ this.$delete(this.results, type)
+ }
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ } else if (!data.ocs.data.isPaginated) {
+ // If no cursor and no pagination, we save the default amount
+ // provided by server's initial state `defaultLimit`
+ this.$set(this.limits, type, this.defaultLimit)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+
+ // If none already focused, focus the first rendered result
+ if (this.focused === null) {
+ this.focused = 0
+ }
+ return REQUEST_OK
+ } catch (error) {
+ this.$delete(this.results, type)
+
+ // If this is not a cancelled throw
+ if (error.response && error.response.status) {
+ this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+ showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+ return REQUEST_FAILED
+ }
+ return REQUEST_CANCELED
+ }
+ })).then(results => {
+ // Do not declare loading finished if the request have been cancelled
+ // This means another search was triggered and we're therefore still loading
+ if (results.some(result => result === REQUEST_CANCELED)) {
+ return
+ }
+ // We finished all searches
+ this.loading = {}
+ })
+ },
+ onInputDebounced: enableLiveSearch
+ ? debounce(function(e) {
+ this.onInput(e)
+ }, 500)
+ : function() {
+ this.triggered = false
+ },
+
+ /**
+ * Load more results for the provided type
+ *
+ * @param {string} type type
+ */
+ async loadMore(type) {
+ // If already loading, ignore
+ if (this.loading[type]) {
+ return
+ }
+
+ if (this.cursors[type]) {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ }
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.results[type].push(...data.ocs.data.entries)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+ } else {
+ // If no cursor, we might have all the results already,
+ // let's fake pagination and show the next xxx entries
+ if (this.limits[type] && this.limits[type] >= 0) {
+ this.limits[type] += this.defaultLimit
+
+ // Check if we reached end of pagination
+ if (this.limits[type] >= this.results[type].length) {
+ this.$set(this.reached, type, true)
+ }
+ }
+ }
+
+ // Focus result after render
+ if (this.focused !== null) {
+ this.$nextTick(() => {
+ this.focusIndex(this.focused)
+ })
+ }
+ },
+
+ /**
+ * Return a subset of the array if the search provider
+ * doesn't supports pagination
+ *
+ * @param {Array} list the results
+ * @param {string} type the type
+ * @return {Array}
+ */
+ limitIfAny(list, type) {
+ if (type in this.limits) {
+ return list.slice(0, this.limits[type])
+ }
+ return list
+ },
+
+ getResultsList() {
+ return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+ },
+
+ /**
+ * Focus the first result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusFirst(event) {
+ const results = this.getResultsList()
+ if (results && results.length > 0) {
+ if (event) {
+ event.preventDefault()
+ }
+ this.focused = 0
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the next result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusNext(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the last, focus the next one
+ if (results && results.length > 0 && this.focused + 1 < results.length) {
+ event.preventDefault()
+ this.focused++
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the previous result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusPrev(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the first, focus the previous one
+ if (results && results.length > 0 && this.focused > 0) {
+ event.preventDefault()
+ this.focused--
+ this.focusIndex(this.focused)
+ }
+
+ },
+
+ /**
+ * Focus the specified result index if it exists
+ *
+ * @param {number} index the result index
+ */
+ focusIndex(index) {
+ const results = this.getResultsList()
+ if (results && results[index]) {
+ results[index].focus()
+ }
+ },
+
+ /**
+ * Set the current focused element based on the target
+ *
+ * @param {Event} event the focus event
+ */
+ setFocusedIndex(event) {
+ const entry = event.target
+ const results = this.getResultsList()
+ const index = [...results].findIndex(search => search === entry)
+ if (index > -1) {
+ // let's not use focusIndex as the entry is already focused
+ this.focused = index
+ }
+ },
+
+ onClickFilter(filter) {
+ this.query = `${this.query} ${filter}`
+ .replace(/ {2}/g, ' ')
+ .trim()
+ this.onInput()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 10px;
+
+.unified-search {
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__input-wrapper {
+ position: sticky;
+ // above search results
+ z-index: 2;
+ top: 0;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ background-color: var(--color-main-background);
+
+ label[for="unified-search__input"] {
+ align-self: flex-start;
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
+ }
+ }
+
+ &__input-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ }
+
+ &__filters {
+ margin-block: $margin;
+ margin-inline: math.div($margin, 2) 0;
+ padding-top: 5px;
+ ul {
+ display: inline-flex;
+ justify-content: space-between;
+ }
+ }
+
+ &__form {
+ position: relative;
+ width: 100%;
+ margin: $margin 0;
+
+ // Loading spinner
+ &::after {
+ inset-inline-start: auto $input-padding;
+ }
+
+ &-input,
+ &-reset {
+ margin: math.div($input-padding, 2);
+ }
+
+ &-input {
+ width: 100%;
+ height: $input-height;
+ padding: $input-padding;
+
+ &:focus,
+ &:focus-visible,
+ &:active {
+ border-color: 2px solid var(--color-main-text) !important;
+ box-shadow: 0 0 0 2px var(--color-main-background) !important;
+ }
+
+ &,
+ &[placeholder],
+ &::placeholder {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // Hide webkit clear search
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ }
+ }
+
+ &-reset,
+ &-submit {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 4px;
+ width: $input-height - $input-padding;
+ height: $input-height - $input-padding;
+ min-height: 30px;
+ padding: 0;
+ opacity: .5;
+ border: none;
+ background-color: transparent;
+ margin-inline-end: 0;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+
+ &-submit {
+ inset-inline-end: 28px;
+ }
+ }
+
+ &__results {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ &-header {
+ display: block;
+ margin: $margin;
+ margin-bottom: $margin - 4px;
+ margin-inline-start: 13px;
+ color: var(--color-primary-element);
+ font-size: 19px;
+ font-weight: bold;
+ }
+ }
+
+ :deep(.unified-search__result-more) {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .empty-content {
+ margin: 10vh 0;
+
+ :deep(.empty-content__title) {
+ font-weight: normal;
+ font-size: var(--default-font-size);
+ text-align: center;
+ }
+ }
+}
+
+</style>
diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue
index 4afa81d0dfa..a6fe8442779 100644
--- a/core/src/views/Login.vue
+++ b/core/src/views/Login.vue
@@ -1,29 +1,13 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div class="guest-box login-box">
- <div v-if="!hideLoginForm || directLogin">
+ <template v-if="!hideLoginForm || directLogin">
<transition name="fade" mode="out-in">
- <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''">
+ <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper">
<LoginForm :username.sync="user"
:redirect-url="redirectUrl"
:direct-login="directLogin"
@@ -31,54 +15,47 @@
:errors="errors"
:throttle-delay="throttleDelay"
:auto-complete-allowed="autoCompleteAllowed"
+ :email-states="emailStates"
@submit="loading = true" />
- <a v-if="canResetPassword && resetPasswordLink !== ''"
+ <NcButton v-if="hasPasswordless"
+ type="tertiary"
+ wide
+ @click.prevent="passwordlessLogin = true">
+ {{ t('core', 'Log in with a device') }}
+ </NcButton>
+ <NcButton v-if="canResetPassword && resetPasswordLink !== ''"
id="lost-password"
- class="login-box__link"
- :href="resetPasswordLink">
+ :href="resetPasswordLink"
+ type="tertiary-no-background"
+ wide>
{{ t('core', 'Forgot password?') }}
- </a>
- <a v-else-if="canResetPassword && !resetPassword"
+ </NcButton>
+ <NcButton v-else-if="canResetPassword && !resetPassword"
id="lost-password"
- class="login-box__link"
- :href="resetPasswordLink"
+ type="tertiary"
+ wide
@click.prevent="resetPassword = true">
{{ t('core', 'Forgot password?') }}
- </a>
- <template v-if="hasPasswordless">
- <div v-if="countAlternativeLogins"
- class="alternative-logins">
- <a v-if="hasPasswordless"
- class="button"
- :class="{ 'single-alt-login-option': countAlternativeLogins }"
- href="#"
- @click.prevent="passwordlessLogin = true">
- {{ t('core', 'Log in with a device') }}
- </a>
- </div>
- <a v-else
- href="#"
- @click.prevent="passwordlessLogin = true">
- {{ t('core', 'Log in with a device') }}
- </a>
- </template>
+ </NcButton>
</div>
<div v-else-if="!loading && passwordlessLogin"
- key="reset"
- class="login-additional">
+ key="reset-pw-less"
+ class="login-additional login-box__wrapper">
<PasswordLessLoginForm :username.sync="user"
:redirect-url="redirectUrl"
:auto-complete-allowed="autoCompleteAllowed"
:is-https="isHttps"
:is-localhost="isLocalhost"
- :has-public-key-credential="hasPublicKeyCredential"
@submit="loading = true" />
- <a href="#" class="login-box__link" @click.prevent="passwordlessLogin = false">
+ <NcButton type="tertiary"
+ :aria-label="t('core', 'Back to login form')"
+ :wide="true"
+ @click="passwordlessLogin = false">
{{ t('core', 'Back') }}
- </a>
+ </NcButton>
</div>
<div v-else-if="!loading && canResetPassword"
- key="reset"
+ key="reset-can-reset"
class="login-additional">
<div class="lost-password-container">
<ResetPassword v-if="resetPassword"
@@ -93,16 +70,16 @@
@done="passwordResetFinished" />
</div>
</transition>
- </div>
- <div v-else>
+ </template>
+ <template v-else>
<transition name="fade" mode="out-in">
- <NcNoteCard type="warning" :title="t('core', 'Login form is disabled.')">
- {{ t('core', 'Please contact your administrator.') }}
+ <NcNoteCard type="info" :title="t('core', 'Login form is disabled.')">
+ {{ t('core', 'The Nextcloud login form is disabled. Use another login option if available or contact your administration.') }}
</NcNoteCard>
</transition>
- </div>
+ </template>
- <div id="alternative-logins" class="alternative-logins">
+ <div id="alternative-logins" class="login-box__alternative-logins">
<NcButton v-for="(alternativeLogin, index) in alternativeLogins"
:key="index"
type="secondary"
@@ -118,24 +95,21 @@
<script>
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+
import queryString from 'query-string'
import LoginForm from '../components/login/LoginForm.vue'
import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import { wipeBrowserStorages } from '../utils/xhr-request.js'
const query = queryString.parse(location.search)
if (query.clear === '1') {
- try {
- window.localStorage.clear()
- window.sessionStorage.clear()
- console.debug('Browser storage cleared')
- } catch (e) {
- console.error('Could not clear browser storage', e)
- }
+ wipeBrowserStorages()
}
export default {
@@ -173,50 +147,43 @@ export default {
alternativeLogins: loadState('core', 'alternativeLogins', []),
isHttps: window.location.protocol === 'https:',
isLocalhost: window.location.hostname === 'localhost',
- hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
hideLoginForm: loadState('core', 'hideLoginForm', false),
+ emailStates: loadState('core', 'emailStates', []),
}
},
methods: {
passwordResetFinished() {
- this.resetPasswordTarget = ''
- this.directLogin = true
+ window.location.href = generateUrl('login')
},
},
}
</script>
-<style lang="scss">
-body {
- font-size: var(--default-font-size);
-}
-
+<style scoped lang="scss">
.login-box {
- width: 300px;
-
- &__link {
- display: block;
- padding: 1rem;
- font-size: var(--default-font-size);
- text-align: center;
- font-weight: normal !important;
+ // Same size as dashboard panels
+ width: 320px;
+ box-sizing: border-box;
+
+ &__wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: calc(2 * var(--default-grid-baseline));
+ }
+
+ &__alternative-logins {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
}
}
+
.fade-enter-active, .fade-leave-active {
transition: opacity .3s;
}
+
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
-
-.alternative-logins {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-
- .button-vue {
- box-sizing: border-box;
- }
-}
</style>
diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue
deleted file mode 100644
index c7571fff148..00000000000
--- a/core/src/views/Profile.vue
+++ /dev/null
@@ -1,604 +0,0 @@
-<!--
- - @copyright Copyright (c) 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<template>
- <div class="profile">
- <div class="profile__header">
- <div class="profile__header__container">
- <div class="profile__header__container__placeholder" />
- <h2 class="profile__header__container__displayname">
- {{ displayname || userId }}
- <a v-if="isCurrentUser"
- class="primary profile__header__container__edit-button"
- :href="settingsUrl">
- <PencilIcon class="pencil-icon"
- :size="16" />
- {{ t('core', 'Edit Profile') }}
- </a>
- </h2>
- <div v-if="status.icon || status.message"
- class="profile__header__container__status-text"
- :class="{ interactive: isCurrentUser }"
- @click.prevent.stop="openStatusModal">
- {{ status.icon }} {{ status.message }}
- </div>
- </div>
- </div>
-
- <div class="profile__wrapper">
- <div class="profile__content">
- <div class="profile__sidebar">
- <NcAvatar class="avatar"
- :class="{ interactive: isCurrentUser }"
- :user="userId"
- :size="180"
- :show-user-status="true"
- :show-user-status-compact="false"
- :disable-menu="true"
- :disable-tooltip="true"
- :is-no-user="!isUserAvatarVisible"
- @click.native.prevent.stop="openStatusModal" />
-
- <div class="user-actions">
- <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action -->
- <PrimaryActionButton v-if="primaryAction"
- class="user-actions__primary"
- :href="primaryAction.target"
- :icon="primaryAction.icon"
- :target="primaryAction.id === 'phone' ? '_self' :'_blank'">
- {{ primaryAction.title }}
- </PrimaryActionButton>
- <div class="user-actions__other">
- <!-- FIXME Remove inline styles after https://github.com/nextcloud/nextcloud-vue/issues/2315 is fixed -->
- <NcActions v-for="action in middleActions"
- :key="action.id"
- :default-icon="action.icon"
- style="
- background-position: 14px center;
- background-size: 16px;
- background-repeat: no-repeat;"
- :style="{
- backgroundImage: `url(${action.icon})`,
- ...(colorMainBackground === '#181818' && { filter: 'invert(1)' })
- }">
- <NcActionLink :close-after-click="true"
- :icon="action.icon"
- :href="action.target"
- :target="action.id === 'phone' ? '_self' :'_blank'">
- {{ action.title }}
- </NcActionLink>
- </NcActions>
- <template v-if="otherActions">
- <NcActions :force-menu="true">
- <NcActionLink v-for="action in otherActions"
- :key="action.id"
- :class="{ 'icon-invert': colorMainBackground === '#181818' }"
- :close-after-click="true"
- :icon="action.icon"
- :href="action.target"
- :target="action.id === 'phone' ? '_self' :'_blank'">
- {{ action.title }}
- </NcActionLink>
- </NcActions>
- </template>
- </div>
- </div>
- </div>
-
- <div class="profile__blocks">
- <div v-if="organisation || role || address" class="profile__blocks-details">
- <div v-if="organisation || role" class="detail">
- <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p>
- </div>
- <div v-if="address" class="detail">
- <p>
- <MapMarkerIcon class="map-icon"
- :size="16" />
- {{ address }}
- </p>
- </div>
- </div>
- <template v-if="headline || biography || sections.length > 0">
- <div v-if="headline" class="profile__blocks-headline">
- <h3>{{ headline }}</h3>
- </div>
- <div v-if="biography" class="profile__blocks-biography">
- <p>{{ biography }}</p>
- </div>
-
- <!-- additional entries, use it with cautious -->
- <div v-for="(section, index) in sections"
- :ref="'section-' + index"
- :key="index"
- class="profile__additionalContent">
- <component :is="section($refs['section-'+index], userId)" :userId="userId" />
- </div>
- </template>
- <template v-else>
- <div class="profile__blocks-empty-info">
- <AccountIcon :size="60"
- fill-color="var(--color-text-maxcontrast)" />
- <h3>{{ emptyProfileMessage }}</h3>
- <p>{{ t('core', 'The headline and about sections will show up here') }}</p>
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import { getCurrentUser } from '@nextcloud/auth'
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { loadState } from '@nextcloud/initial-state'
-import { generateUrl } from '@nextcloud/router'
-import { showError } from '@nextcloud/dialogs'
-
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink'
-import MapMarkerIcon from 'vue-material-design-icons/MapMarker'
-import PencilIcon from 'vue-material-design-icons/Pencil'
-import AccountIcon from 'vue-material-design-icons/Account'
-
-import PrimaryActionButton from '../components/Profile/PrimaryActionButton'
-
-const status = loadState('core', 'status', {})
-const {
- userId,
- displayname,
- address,
- organisation,
- role,
- headline,
- biography,
- actions,
- isUserAvatarVisible,
-} = loadState('core', 'profileParameters', {
- userId: null,
- displayname: null,
- address: null,
- organisation: null,
- role: null,
- headline: null,
- biography: null,
- actions: [],
- isUserAvatarVisible: false,
-})
-
-export default {
- name: 'Profile',
-
- components: {
- AccountIcon,
- NcActionLink,
- NcActions,
- NcAvatar,
- MapMarkerIcon,
- PencilIcon,
- PrimaryActionButton,
- },
-
- data() {
- return {
- status,
- userId,
- displayname,
- address,
- organisation,
- role,
- headline,
- biography,
- actions,
- isUserAvatarVisible,
- sections: OCA.Core.ProfileSections.getSections(),
- }
- },
-
- computed: {
- isCurrentUser() {
- return getCurrentUser()?.uid === this.userId
- },
-
- allActions() {
- return this.actions
- },
-
- primaryAction() {
- if (this.allActions.length) {
- return this.allActions[0]
- }
- return null
- },
-
- middleActions() {
- if (this.allActions.slice(1, 4).length) {
- return this.allActions.slice(1, 4)
- }
- return null
- },
-
- otherActions() {
- if (this.allActions.slice(4).length) {
- return this.allActions.slice(4)
- }
- return null
- },
-
- settingsUrl() {
- return generateUrl('/settings/user')
- },
-
- colorMainBackground() {
- // For some reason the returned string has prepended whitespace
- return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim()
- },
-
- emptyProfileMessage() {
- return this.isCurrentUser
- ? t('core', 'You have not added any info yet')
- : t('core', '{user} has not added any info yet', { user: (this.displayname || this.userId) })
- },
- },
-
- mounted() {
- // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end
- document.title = `${this.displayname || this.userId} - ${document.title}`
- subscribe('user_status:status.updated', this.handleStatusUpdate)
- },
-
- beforeDestroy() {
- unsubscribe('user_status:status.updated', this.handleStatusUpdate)
- },
-
- methods: {
- handleStatusUpdate(status) {
- if (this.isCurrentUser && status.userId === this.userId) {
- this.status = status
- }
- },
-
- openStatusModal() {
- const statusMenuItem = document.querySelector('.user-status-menu-item__toggle')
- // Changing the user status is only enabled if you are the current user
- if (this.isCurrentUser) {
- if (statusMenuItem) {
- statusMenuItem.click()
- } else {
- showError(t('core', 'Error opening the user status modal, try hard refreshing the page'))
- }
- }
- },
- },
-}
-</script>
-
-<style lang="scss">
-// Override header styles
-#header {
- background-color: transparent !important;
- background-image: none !important;
-}
-
-#content {
- padding-top: 0px;
-}
-</style>
-
-<style lang="scss" scoped>
-$profile-max-width: 1024px;
-$content-max-width: 640px;
-
-.profile {
- width: 100%;
- overflow-y: auto;
-
- &__header {
- position: sticky;
- height: 190px;
- top: -40px;
- background-color: var(--color-main-background-blur);
- backdrop-filter: var(--filter-background-blur);
- -webkit-backdrop-filter: var(--filter-background-blur);
-
- &__container {
- align-self: flex-end;
- width: 100%;
- max-width: $profile-max-width;
- margin: 0 auto;
- display: grid;
- grid-template-rows: max-content max-content;
- grid-template-columns: 240px 1fr;
- justify-content: center;
-
- &__placeholder {
- grid-row: 1 / 3;
- }
-
- &__displayname, &__status-text {
- color: var(--color-main-text);
- }
-
- &__displayname {
- width: $content-max-width;
- height: 45px;
- margin-top: 128px;
- // Override the global style declaration
- margin-bottom: 0;
- font-size: 30px;
- display: flex;
- align-items: center;
- cursor: text;
-
- &:not(:last-child) {
- margin-top: 100px;
- margin-bottom: 4px;
- }
- }
-
- &__edit-button {
- border: none;
- margin-left: 18px;
- margin-top: 2px;
- color: var(--color-primary-element);
- background-color: var(--color-primary-text);
- box-shadow: 0 0 0 2px var(--color-primary-text);
- border-radius: var(--border-radius-pill);
- padding: 0 18px;
- font-size: var(--default-font-size);
- height: 44px;
- line-height: 44px;
- font-weight: bold;
-
- &:hover,
- &:focus,
- &:active {
- color: var(--color-primary-element);
- background-color: var(--color-primary-element-light);
- }
-
- .pencil-icon {
- display: inline-block;
- vertical-align: middle;
- margin-top: 2px;
- }
- }
-
- &__status-text {
- width: max-content;
- max-width: $content-max-width;
- padding: 5px 10px;
- margin-left: -12px;
- margin-top: 2px;
-
- &.interactive {
- cursor: pointer;
-
- &:hover,
- &:focus,
- &:active {
- background-color: var(--color-main-background);
- color: var(--color-main-text);
- border-radius: var(--border-radius-pill);
- font-weight: bold;
- box-shadow: 0 3px 6px var(--color-box-shadow);
- }
- }
- }
- }
- }
-
- &__sidebar {
- position: sticky;
- top: var(--header-height);
- align-self: flex-start;
- padding-top: 20px;
- min-width: 220px;
- margin: -150px 20px 0 0;
-
- // Specificity hack is needed to override Avatar component styles
- &::v-deep .avatar.avatardiv, h2 {
- text-align: center;
- margin: auto;
- display: block;
- padding: 8px;
- }
-
- &::v-deep .avatar.avatardiv:not(.avatardiv--unknown) {
- background-color: var(--color-main-background) !important;
- box-shadow: none;
- }
-
- &::v-deep .avatar.avatardiv {
- .avatardiv__user-status {
- right: 14px;
- bottom: 14px;
- width: 34px;
- height: 34px;
- background-size: 28px;
- border: none;
- // Styles when custom status icon and status text are set
- background-color: var(--color-main-background);
- line-height: 34px;
- font-size: 20px;
- }
- }
-
- &::v-deep .avatar.interactive.avatardiv {
- .avatardiv__user-status {
- cursor: pointer;
-
- &:hover,
- &:focus,
- &:active {
- box-shadow: 0 3px 6px var(--color-box-shadow);
- }
- }
- }
- }
-
- &__wrapper {
- background-color: var(--color-main-background);
- min-height: 100%;
- }
-
- &__content {
- max-width: $profile-max-width;
- margin: 0 auto;
- display: flex;
- width: 100%;
- }
-
- &__blocks {
- margin: 18px 0 80px 0;
- display: grid;
- gap: 16px 0;
- width: $content-max-width;
-
- p, h3 {
- overflow-wrap: anywhere;
- }
-
- &-details {
- display: flex;
- flex-direction: column;
- gap: 2px 0;
-
- .detail {
- display: inline-block;
- color: var(--color-text-maxcontrast);
-
- p .map-icon {
- display: inline-block;
- vertical-align: middle;
- }
- }
- }
-
- &-headline {
- margin-top: 10px;
-
- h3 {
- font-weight: bold;
- font-size: 20px;
- margin: 0;
- }
- }
-
- &-biography {
- white-space: pre-line;
- }
-
- h3, p {
- cursor: text;
- }
-
- &-empty-info {
- margin-top: 80px;
- margin-right: 100px;
- display: flex;
- flex-direction: column;
- text-align: center;
-
- h3 {
- font-weight: bold;
- font-size: 18px;
- margin: 8px 0;
- }
- }
- }
-}
-
-@media only screen and (max-width: 1024px) {
- .profile {
- &__header {
- height: 250px;
- position: unset;
-
- &__container {
- grid-template-columns: unset;
-
- &__displayname {
- margin: 100px 20px 0px;
- width: unset;
- display: unset;
- text-align: center;
- }
-
- &__edit-button {
- width: fit-content;
- display: block;
- margin: 30px auto;
- }
- }
- }
-
- &__content {
- display: block;
- }
-
- &__blocks {
- width: unset;
- max-width: 600px;
- margin: 0 auto;
- padding: 20px 50px 50px 50px;
-
- &-empty-info {
- margin: 0;
- }
- }
-
- &__sidebar {
- margin: unset;
- position: unset;
- }
- }
-}
-
-.user-actions {
- display: flex;
- flex-direction: column;
- gap: 8px 0;
- margin-top: 20px;
-
- &__primary {
- margin: 0 auto;
- }
-
- &__other {
- display: flex;
- justify-content: center;
- gap: 0 4px;
- a {
- filter: var(--background-invert-if-dark);
- }
- }
-}
-
-.icon-invert {
- &::v-deep .action-link__icon {
- filter: invert(1);
- }
-}
-</style>
diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue
new file mode 100644
index 00000000000..a05f3a6b889
--- /dev/null
+++ b/core/src/views/PublicPageMenu.vue
@@ -0,0 +1,131 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <div class="public-page-menu__wrapper">
+ <NcButton v-if="primaryAction"
+ id="public-page-menu--primary"
+ class="public-page-menu__primary"
+ :href="primaryAction.href"
+ type="primary"
+ @click="openDialogIfNeeded">
+ <template v-if="primaryAction.icon" #icon>
+ <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" />
+ </template>
+ {{ primaryAction.label }}
+ </NcButton>
+
+ <NcHeaderMenu v-if="secondaryActions.length > 0"
+ id="public-page-menu"
+ :aria-label="t('core', 'More actions')"
+ :open.sync="showMenu">
+ <template #trigger>
+ <IconMore :size="20" />
+ </template>
+ <ul :aria-label="t('core', 'More actions')"
+ class="public-page-menu"
+ role="menu">
+ <component :is="getComponent(entry)"
+ v-for="entry, index in secondaryActions"
+ :key="index"
+ v-bind="entry"
+ @click="showMenu = false" />
+ </ul>
+ </NcHeaderMenu>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
+import { computed, ref, type Ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import IconMore from 'vue-material-design-icons/DotsHorizontal.vue'
+import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue'
+import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue'
+import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue'
+import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue'
+import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue'
+
+interface IPublicPageMenu {
+ id: string
+ label: string
+ href: string
+ icon?: string
+ html?: string
+ details?: string
+}
+
+const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu')
+
+/** used to conditionally close the menu when clicking entry */
+const showMenu = ref(false)
+
+const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>>
+/** The primary menu action - only showed when not on mobile */
+const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0])
+/** All other secondary actions (including primary action on mobile) */
+const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1))
+
+/**
+ * Get the render component for an entry
+ * @param entry The entry to get the component for
+ */
+function getComponent(entry: IPublicPageMenu) {
+ if ('html' in entry) {
+ return PublicPageMenuCustomEntry
+ }
+ switch (entry.id) {
+ case 'save':
+ return PublicPageMenuExternalEntry
+ case 'directLink':
+ return PublicPageMenuLinkEntry
+ default:
+ return PublicPageMenuEntry
+ }
+}
+
+/**
+ * Open the "federated share" dialog if needed
+ */
+function openDialogIfNeeded() {
+ if (primaryAction.value?.id !== 'save') {
+ return
+ }
+ spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label })
+}
+</script>
+
+<style scoped lang="scss">
+.public-page-menu {
+ box-sizing: border-box;
+
+ > :deep(*) {
+ box-sizing: border-box;
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: var(--default-grid-baseline);
+ }
+
+ &__primary {
+ height: var(--default-clickable-area);
+ margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2);
+
+ // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image))
+ &:focus-visible {
+ border-color: var(--color-background-plain-text) !important;
+ }
+ }
+
+ &__primary-icon {
+ filter: var(--primary-invert-if-bright);
+ }
+}
+</style>
diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue
new file mode 100644
index 00000000000..7bd6521e7aa
--- /dev/null
+++ b/core/src/views/PublicPageUserMenu.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcHeaderMenu id="public-page-user-menu"
+ class="public-page-user-menu"
+ is-nav
+ :aria-label="t('core', 'User menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <NcAvatar class="public-page-user-menu__avatar"
+ disable-menu
+ disable-tooltip
+ is-guest
+ :user="displayName || '?'" />
+ </template>
+
+ <!-- Privacy notice -->
+ <NcNoteCard class="public-page-user-menu__list-note"
+ :text="privacyNotice"
+ type="info" />
+
+ <ul class="public-page-user-menu__list">
+ <!-- Nickname dialog -->
+ <AccountMenuEntry id="set-nickname"
+ :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')"
+ href="#"
+ @click.prevent.stop="setNickname">
+ <template #icon>
+ <IconAccount />
+ </template>
+ </AccountMenuEntry>
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import type { NextcloudUser } from '@nextcloud/auth'
+
+import '@nextcloud/dialogs/style.css'
+import { defineComponent } from 'vue'
+import { getGuestUser } from '@nextcloud/auth'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import IconAccount from 'vue-material-design-icons/AccountOutline.vue'
+
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+export default defineComponent({
+ name: 'PublicPageUserMenu',
+ components: {
+ AccountMenuEntry,
+ IconAccount,
+ NcAvatar,
+ NcHeaderMenu,
+ NcNoteCard,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ displayName: getGuestUser().displayName,
+ }
+ },
+
+ computed: {
+ avatarDescription(): string {
+ return t('core', 'User menu')
+ },
+
+ privacyNotice(): string {
+ return this.displayName
+ ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName })
+ : t('core', 'You are currently not identified.')
+ },
+ },
+
+ mounted() {
+ subscribe('user:info:changed', (user: NextcloudUser) => {
+ this.displayName = user.displayName || ''
+ })
+ },
+
+ methods: {
+ setNickname() {
+ showGuestUserPrompt({
+ nickname: this.displayName,
+ cancellable: true,
+ })
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.public-page-user-menu {
+ &, * {
+ box-sizing: border-box;
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ &__list-note {
+ padding-block: 5px !important;
+ padding-inline: 5px !important;
+ max-width: 300px;
+ margin: 5px !important;
+ margin-bottom: 0 !important;
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ width: 100%;
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+}
+</style>
diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts
new file mode 100644
index 00000000000..f252801c4d8
--- /dev/null
+++ b/core/src/views/Setup.cy.ts
@@ -0,0 +1,369 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { SetupConfig, SetupLinks } from '../install'
+import SetupView from './Setup.vue'
+
+import '../../css/guest.css'
+
+const defaultConfig = Object.freeze({
+ adminlogin: '',
+ adminpass: '',
+ dbuser: '',
+ dbpass: '',
+ dbname: '',
+ dbtablespace: '',
+ dbhost: '',
+ dbtype: '',
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ },
+ directory: '',
+ hasAutoconfig: false,
+ htaccessWorking: true,
+ serverRoot: '/var/www/html',
+ errors: [],
+}) as SetupConfig
+
+const links = {
+ adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
+ adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
+ adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
+} as SetupLinks
+
+describe('Default setup page', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders default config', () => {
+ cy.mockInitialState('core', 'config', defaultConfig)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form]').scrollIntoView()
+ cy.get('[data-cy-setup-form]').should('be.visible')
+
+ // Single note is the footer help
+ cy.get('[data-cy-setup-form-note]')
+ .should('have.length', 1)
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation')
+
+ // DB radio selectors
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('exist')
+ .find('input')
+ .should('be.checked')
+
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist')
+
+ // Sqlite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+
+ // admin login, password, data directory and 3 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 6)
+ })
+
+ it('Renders single DB sqlite', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only sqlite
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Two warnings: sqlite and single db support
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+
+ // Admin login, password and data directory
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 3)
+ })
+
+ it('Renders single DB mysql', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ mysql: 'MySQL/MariaDB',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only mysql
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Single db support warning
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+ .invoke('html')
+ .should('contains', links.adminSourceInstall)
+
+ // No SQLite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('not.exist')
+
+ // Admin login, password, data directory, db user,
+ // db password, db name and db host
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+ })
+
+ it('Changes fields from sqlite to mysql then oci', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ oci: 'Oracle',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // SQLite selected
+ cy.get('[data-cy-setup-form-field="dbtype-sqlite"]')
+ .should('be.visible')
+ .find('input')
+ .should('be.checked')
+
+ // Admin login, password, data directory and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+
+ // Change to MySQL
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 11)
+
+ // Change to Oracle
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db table space, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 12)
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('be.visible')
+ })
+})
+
+describe('Setup page with errors and warning', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders error from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders errors from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ 'Error message 1',
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 2)
+ cy.get('[data-cy-setup-form-note="error"]').eq(0)
+ .should('contain', 'Error message 1')
+ cy.get('[data-cy-setup-form-note="error"]').eq(1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders all the submitted fields on error', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('input[data-cy-setup-form-field="adminlogin"]')
+ .should('have.value', 'admin')
+ cy.get('input[data-cy-setup-form-field="adminpass"]')
+ .should('have.value', 'password')
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input')
+ .should('be.checked')
+ cy.get('input[data-cy-setup-form-field="dbname"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbuser"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbpass"]')
+ .should('have.value', 'password')
+ cy.get('input[data-cy-setup-form-field="dbhost"]')
+ .should('have.value', 'localhost')
+ cy.get('input[data-cy-setup-form-field="directory"]')
+ .should('have.value', '/var/www/html/nextcloud')
+ })
+
+ it('Renders the htaccess warning', () => {
+ const config = {
+ ...defaultConfig,
+ htaccessWorking: false,
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form-note="htaccess"]')
+ .should('be.visible')
+ .should('contain', 'Security warning')
+ .invoke('html')
+ .should('contains', links.adminInstall)
+ })
+})
+
+describe('Setup page with autoconfig', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders autoconfig', () => {
+ const config = {
+ ...defaultConfig,
+ hasAutoconfig: true,
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Autoconfig info note
+ cy.get('[data-cy-setup-form-note="autoconfig"]')
+ .should('be.visible')
+ .should('contain', 'Autoconfig file detected')
+
+ // Database and storage section is hidden as already set in autoconfig
+ cy.get('[data-cy-setup-form-advanced-config]').should('be.visible')
+ .invoke('attr', 'open')
+ .should('equal', undefined)
+
+ // Oracle tablespace is hidden
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('not.exist')
+ })
+})
+
+describe('Submit a full form sends the data', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Submits a full form', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ dbtablespace: 'tablespace',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+
+ cy.intercept('POST', '**', {
+ delay: 2000,
+ }).as('setup')
+
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Not chaining breaks the test as the POST prevents the element from being retrieved twice
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.get('[data-cy-setup-form-submit]')
+ .click()
+ .invoke('attr', 'disabled')
+ .should('equal', 'disabled', { timeout: 500 })
+
+ cy.wait('@setup')
+ .its('request.body')
+ .should('deep.equal', new URLSearchParams({
+ adminlogin: 'admin',
+ adminpass: 'password',
+ directory: '/var/www/html/nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbname: 'nextcloud',
+ dbhost: 'localhost',
+ }).toString())
+ })
+})
diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue
new file mode 100644
index 00000000000..50ec0da9035
--- /dev/null
+++ b/core/src/views/Setup.vue
@@ -0,0 +1,460 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <form ref="form"
+ class="setup-form"
+ :class="{ 'setup-form--loading': loading }"
+ action=""
+ data-cy-setup-form
+ method="POST"
+ @submit="onSubmit">
+ <!-- Autoconfig info -->
+ <NcNoteCard v-if="config.hasAutoconfig"
+ :heading="t('core', 'Autoconfig file detected')"
+ data-cy-setup-form-note="autoconfig"
+ type="success">
+ {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
+ </NcNoteCard>
+
+ <!-- Htaccess warning -->
+ <NcNoteCard v-if="config.htaccessWorking === false"
+ :heading="t('core', 'Security warning')"
+ data-cy-setup-form-note="htaccess"
+ type="warning">
+ <p v-html="htaccessWarning" />
+ </NcNoteCard>
+
+ <!-- Various errors -->
+ <NcNoteCard v-for="(error, index) in errors"
+ :key="index"
+ :heading="error.heading"
+ data-cy-setup-form-note="error"
+ type="error">
+ {{ error.message }}
+ </NcNoteCard>
+
+ <!-- Admin creation -->
+ <fieldset class="setup-form__administration">
+ <legend>{{ t('core', 'Create administration account') }}</legend>
+
+ <!-- Username -->
+ <NcTextField v-model="config.adminlogin"
+ :label="t('core', 'Administration account name')"
+ data-cy-setup-form-field="adminlogin"
+ name="adminlogin"
+ required />
+
+ <!-- Password -->
+ <NcPasswordField v-model="config.adminpass"
+ :label="t('core', 'Administration account password')"
+ data-cy-setup-form-field="adminpass"
+ name="adminpass"
+ required />
+
+ <!-- Password entropy -->
+ <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType">
+ {{ passwordHelperText }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Autoconfig toggle -->
+ <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config>
+ <summary>{{ t('core', 'Storage & database') }}</summary>
+
+ <!-- Data folder -->
+ <fieldset class="setup-form__data-folder">
+ <NcTextField v-model="config.directory"
+ :label="t('core', 'Data folder')"
+ :placeholder="config.serverRoot + '/data'"
+ required
+ autocomplete="off"
+ autocapitalize="none"
+ data-cy-setup-form-field="directory"
+ name="directory"
+ spellcheck="false" />
+ </fieldset>
+
+ <!-- Database -->
+ <fieldset class="setup-form__database">
+ <legend>{{ t('core', 'Database configuration') }}</legend>
+
+ <!-- Database type select -->
+ <fieldset class="setup-form__database-type">
+ <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
+ <NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
+ :key="db"
+ v-model="config.dbtype"
+ :button-variant="true"
+ :data-cy-setup-form-field="`dbtype-${db}`"
+ :value="db"
+ :button-variant-grouped="DBTypeGroupDirection"
+ name="dbtype"
+ type="radio">
+ {{ name }}
+ </NcCheckboxRadioSwitch>
+ </p>
+
+ <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning">
+ {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br>
+ {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
+ <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
+ {{ t('core', 'For more details check out the documentation.') }} ↗
+ </a>
+ </NcNoteCard>
+
+ <NcNoteCard v-if="config.dbtype === 'sqlite'"
+ :heading="t('core', 'Performance warning')"
+ data-cy-setup-form-db-note="sqlite"
+ type="warning">
+ {{ t('core', 'You chose SQLite as database.') }}<br>
+ {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
+ {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Database configuration -->
+ <fieldset v-if="config.dbtype !== 'sqlite'">
+ <NcTextField v-model="config.dbuser"
+ :label="t('core', 'Database user')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbuser"
+ name="dbuser"
+ spellcheck="false"
+ required />
+
+ <NcPasswordField v-model="config.dbpass"
+ :label="t('core', 'Database password')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbpass"
+ name="dbpass"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-model="config.dbname"
+ :label="t('core', 'Database name')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbname"
+ name="dbname"
+ pattern="[0-9a-zA-Z\$_\-]+"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-if="config.dbtype === 'oci'"
+ v-model="config.dbtablespace"
+ :label="t('core', 'Database tablespace')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbtablespace"
+ name="dbtablespace"
+ spellcheck="false" />
+
+ <NcTextField v-model="config.dbhost"
+ :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')"
+ :label="t('core', 'Database host')"
+ :placeholder="t('core', 'localhost')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbhost"
+ name="dbhost"
+ spellcheck="false" />
+ </fieldset>
+ </fieldset>
+ </details>
+
+ <!-- Submit -->
+ <NcButton class="setup-form__button"
+ :class="{ 'setup-form__button--loading': loading }"
+ :disabled="loading"
+ :loading="loading"
+ :wide="true"
+ alignment="center-reverse"
+ data-cy-setup-form-submit
+ native-type="submit"
+ type="primary">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconArrowRight v-else />
+ </template>
+ {{ loading ? t('core', 'Installing …') : t('core', 'Install') }}
+ </NcButton>
+
+ <!-- Help note -->
+ <NcNoteCard data-cy-setup-form-note="help" type="info">
+ {{ t('core', 'Need help?') }}
+ <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a>
+ </NcNoteCard>
+ </form>
+</template>
+<script lang="ts">
+import type { DbType, SetupConfig, SetupLinks } from '../install'
+
+import { defineComponent } from 'vue'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import DomPurify from 'dompurify'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+
+enum PasswordStrength {
+ VeryWeak,
+ Weak,
+ Moderate,
+ Strong,
+ VeryStrong,
+ ExtremelyStrong,
+}
+
+const checkPasswordEntropy = (password: string = ''): PasswordStrength => {
+ const uniqueCharacters = new Set(password)
+ const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
+ if (entropy < 16) {
+ return PasswordStrength.VeryWeak
+ } else if (entropy < 31) {
+ return PasswordStrength.Weak
+ } else if (entropy < 46) {
+ return PasswordStrength.Moderate
+ } else if (entropy < 61) {
+ return PasswordStrength.Strong
+ } else if (entropy < 76) {
+ return PasswordStrength.VeryStrong
+ }
+
+ return PasswordStrength.ExtremelyStrong
+}
+
+export default defineComponent({
+ name: 'Setup',
+
+ components: {
+ IconArrowRight,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ NcNoteCard,
+ NcPasswordField,
+ NcTextField,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ config: {} as SetupConfig,
+ links: {} as SetupLinks,
+ isValidAutoconfig: false,
+ loading: false,
+ }
+ },
+
+ computed: {
+ passwordHelperText(): string {
+ if (this.config?.adminpass === '') {
+ return ''
+ }
+
+ const passwordStrength = checkPasswordEntropy(this.config?.adminpass)
+ switch (passwordStrength) {
+ case PasswordStrength.VeryWeak:
+ return t('core', 'Password is too weak')
+ case PasswordStrength.Weak:
+ return t('core', 'Password is weak')
+ case PasswordStrength.Moderate:
+ return t('core', 'Password is average')
+ case PasswordStrength.Strong:
+ return t('core', 'Password is strong')
+ case PasswordStrength.VeryStrong:
+ return t('core', 'Password is very strong')
+ case PasswordStrength.ExtremelyStrong:
+ return t('core', 'Password is extremely strong')
+ }
+
+ return t('core', 'Unknown password strength')
+ },
+ passwordHelperType() {
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) {
+ return 'error'
+ }
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) {
+ return 'warning'
+ }
+ return 'success'
+ },
+
+ firstAndOnlyDatabase(): string|null {
+ const dbNames = Object.values(this.config?.databases || {})
+ if (dbNames.length === 1) {
+ return dbNames[0]
+ }
+
+ return null
+ },
+
+ DBTypeGroupDirection() {
+ const databases = Object.keys(this.config?.databases || {})
+ // If we have more than 3 databases, we want to display them vertically
+ if (databases.length > 3) {
+ return 'vertical'
+ }
+ return 'horizontal'
+ },
+
+ htaccessWarning(): string {
+ // We use v-html, let's make sure we're safe
+ const message = [
+ t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
+ t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
+ linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
+ linkEnd: '</a>',
+ }, { escape: false }),
+ ].join('<br>')
+ return DomPurify.sanitize(message)
+ },
+
+ errors() {
+ return (this.config?.errors || []).map(error => {
+ if (typeof error === 'string') {
+ return {
+ heading: '',
+ message: error,
+ }
+ }
+
+ // f no hint is set, we don't want to show a heading
+ if (error.hint === '') {
+ return {
+ heading: '',
+ message: error.error,
+ }
+ }
+
+ return {
+ heading: error.error,
+ message: error.hint,
+ }
+ })
+ },
+ },
+
+ beforeMount() {
+ // Needs to only read the state once we're mounted
+ // for Cypress to be properly initialized.
+ this.config = loadState<SetupConfig>('core', 'config')
+ this.links = loadState<SetupLinks>('core', 'links')
+
+ },
+
+ mounted() {
+ // Set the first database type as default if none is set
+ if (this.config.dbtype === '') {
+ this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
+ }
+
+ // Validate the legitimacy of the autoconfig
+ if (this.config.hasAutoconfig) {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.removeAttribute('required')
+ })
+
+ if (form.checkValidity() && this.config.errors.length === 0) {
+ this.isValidAutoconfig = true
+ } else {
+ this.isValidAutoconfig = false
+ }
+
+ // Restore the required attribute
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.setAttribute('required', 'true')
+ })
+ }
+ },
+
+ methods: {
+ async onSubmit() {
+ this.loading = true
+ },
+ },
+})
+</script>
+<style lang="scss">
+form {
+ padding: calc(3 * var(--default-grid-baseline));
+ color: var(--color-main-text);
+ border-radius: var(--border-radius-container);
+ background-color: var(--color-main-background-blur);
+ box-shadow: 0 0 10px var(--color-box-shadow);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+
+ max-width: 300px;
+ margin-bottom: 30px;
+
+ > fieldset:first-child,
+ > .notecard:first-child {
+ margin-top: 0;
+ }
+
+ > .notecard:last-child {
+ margin-bottom: 0;
+ }
+
+ fieldset,
+ details {
+ margin-block: 1rem;
+ }
+
+ .setup-form__button:not(.setup-form__button--loading) {
+ .material-design-icon {
+ transition: all linear var(--animation-quick);
+ }
+
+ &:hover .material-design-icon {
+ transform: translateX(0.2em);
+ }
+ }
+
+ // Db select required styling
+ .setup-form__database-type-select {
+ display: flex;
+ &--vertical {
+ flex-direction: column;
+ }
+ }
+
+}
+
+code {
+ background-color: var(--color-background-dark);
+ margin-top: 1rem;
+ padding: 0 0.3em;
+ border-radius: var(--border-radius);
+}
+
+// Various overrides
+.input-field {
+ margin-block-start: 1rem !important;
+}
+
+.notecard__heading {
+ font-size: inherit !important;
+}
+</style>
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
index 62b5d034038..103e47b0425 100644
--- a/core/src/views/UnifiedSearch.vue
+++ b/core/src/views/UnifiedSearch.vue
@@ -1,866 +1,182 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <NcHeaderMenu id="unified-search"
- class="unified-search"
- exclude-click-outside-classes="popover"
- :open.sync="open"
- :aria-label="ariaLabel"
- @open="onOpen"
- @close="onClose">
- <!-- Header icon -->
- <template #trigger>
- <Magnify class="unified-search__trigger"
- :size="22/* fit better next to other 20px icons */"
- fill-color="var(--color-primary-text)" />
- </template>
-
- <!-- Search form & filters wrapper -->
- <div class="unified-search__input-wrapper">
- <label for="unified-search__input">{{ ariaLabel }}</label>
- <div class="unified-search__input-row">
- <form class="unified-search__form"
- role="search"
- :class="{'icon-loading-small': isLoading}"
- @submit.prevent.stop="onInputEnter"
- @reset.prevent.stop="onReset">
- <!-- Search input -->
- <input ref="input"
- id="unified-search__input"
- v-model="query"
- class="unified-search__form-input"
- type="search"
- :class="{'unified-search__form-input--with-reset': !!query}"
- :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
- aria-describedby="unified-search-desc"
- @input="onInputDebounced"
- @keypress.enter.prevent.stop="onInputEnter">
- <p id="unified-search-desc" class="hidden-visually">
- {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
- </p>
-
- <!-- Reset search button -->
- <input v-if="!!query && !isLoading"
- type="reset"
- class="unified-search__form-reset icon-close"
- :aria-label="t('core','Reset search')"
- value="">
-
- <input v-if="!!query && !isLoading && !enableLiveSearch"
- type="submit"
- class="unified-search__form-submit icon-confirm"
- :aria-label="t('core','Start search')"
- value="">
- </form>
-
- <!-- Search filters -->
- <NcActions v-if="availableFilters.length > 1"
- class="unified-search__filters"
- placement="bottom"
- container=".unified-search__input-wrapper">
- <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
- <NcActionButton v-for="type in availableFilters"
- :key="type"
- icon="icon-filter"
- :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
- @click.stop="onClickFilter(`in:${type}`)">
- {{ `in:${type}` }}
- </NcActionButton>
- </NcActions>
- </div>
- </div>
-
- <template v-if="!hasResults">
- <!-- Loading placeholders -->
- <SearchResultPlaceholders v-if="isLoading" />
-
- <NcEmptyContent v-else-if="isValidQuery">
- <NcHighlight v-if="triggered" :text="t('core', 'No results for {query}', { query })" :search="query" />
- <div v-else>
- {{ t('core', 'Press enter to start searching') }}
- </div>
- <template #icon>
- <Magnify />
- </template>
- </NcEmptyContent>
-
- <NcEmptyContent v-else-if="!isLoading || isShortQuery">
- {{ t('core', 'Start typing to search') }}
- <template #icon>
- <Magnify />
- </template>
- <template v-if="isShortQuery" #desc>
- {{ n('core',
- 'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
- minSearchLength,
- {minSearchLength}) }}
- </template>
- </NcEmptyContent>
- </template>
-
- <!-- Grouped search results -->
- <template v-else>
- <ul v-for="({list, type}, typesIndex) in orderedResults"
- :key="type"
- class="unified-search__results"
- :class="`unified-search__results-${type}`"
- :aria-label="typesMap[type]">
- <h2 class="unified-search__results-header">
- {{ typesMap[type] }}
- </h2>
-
- <!-- Search results -->
- <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
- <SearchResult v-bind="result"
- :query="query"
- :focused="focused === 0 && typesIndex === 0 && index === 0"
- @focus="setFocusedIndex" />
- </li>
-
- <!-- Load more button -->
- <li>
- <SearchResult v-if="!reached[type]"
- class="unified-search__result-more"
- :title="loading[type]
- ? t('core', 'Loading more results …')
- : t('core', 'Load more results')"
- :icon-class="loading[type] ? 'icon-loading-small' : ''"
- @click.stop="loadMore(type)"
- @focus="setFocusedIndex" />
- </li>
- </ul>
- </template>
- </NcHeaderMenu>
+ <div class="unified-search-menu">
+ <NcHeaderButton v-show="!showLocalSearch"
+ :aria-label="t('core', 'Unified search')"
+ @click="toggleUnifiedSearch">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcHeaderButton>
+ <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch"
+ :open.sync="showLocalSearch"
+ :query.sync="queryText"
+ @global-search="openModal" />
+ <UnifiedSearchModal :local-search="supportsLocalSearch"
+ :query.sync="queryText"
+ :open.sync="showUnifiedSearch" />
+ </div>
</template>
-<script>
+<script lang="ts">
+import { mdiMagnify } from '@mdi/js'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+import { useBrowserLocation } from '@vueuse/core'
import debounce from 'debounce'
-import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { showError } from '@nextcloud/dialogs'
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
-
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-
-import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
-import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
-
-import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
-
-const REQUEST_FAILED = 0
-const REQUEST_OK = 1
-const REQUEST_CANCELED = 2
-
-export default {
+import { defineComponent } from 'vue'
+import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue'
+import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue'
+import logger from '../logger.js'
+
+export default defineComponent({
name: 'UnifiedSearch',
components: {
- Magnify,
- NcActionButton,
- NcActions,
- NcEmptyContent,
- NcHeaderMenu,
- NcHighlight,
- SearchResult,
- SearchResultPlaceholders,
+ NcHeaderButton,
+ NcIconSvgWrapper,
+ UnifiedSearchModal,
+ UnifiedSearchLocalSearchBar,
},
- data() {
- return {
- types: [],
-
- // Cursors per types
- cursors: {},
- // Various search limits per types
- limits: {},
- // Loading types
- loading: {},
- // Reached search types
- reached: {},
- // Pending cancellable requests
- requests: [],
- // List of all results
- results: {},
+ setup() {
+ const currentLocation = useBrowserLocation()
- query: '',
- focused: null,
- triggered: false,
+ return {
+ currentLocation,
- defaultLimit,
- minSearchLength,
- enableLiveSearch,
+ mdiMagnify,
+ t,
+ }
+ },
- open: false,
+ data() {
+ return {
+ /** The current search query */
+ queryText: '',
+ /** Open state of the modal */
+ showUnifiedSearch: false,
+ /** Open state of the local search bar */
+ showLocalSearch: false,
}
},
computed: {
- typesIDs() {
- return this.types.map(type => type.id)
- },
- typesNames() {
- return this.types.map(type => type.name)
- },
- typesMap() {
- return this.types.reduce((prev, curr) => {
- prev[curr.id] = curr.name
- return prev
- }, {})
- },
-
- ariaLabel() {
- return t('core', 'Search')
- },
-
/**
- * Is there any result to display
- *
- * @return {boolean}
+ * Debounce emitting the search query by 250ms
*/
- hasResults() {
- return Object.keys(this.results).length !== 0
+ debouncedQueryUpdate() {
+ return debounce(this.emitUpdatedQuery, 250)
},
/**
- * Return ordered results
- *
- * @return {Array}
+ * Current page (app) supports local in-app search
*/
- orderedResults() {
- return this.typesIDs
- .filter(type => type in this.results)
- .map(type => ({
- type,
- list: this.results[type],
- }))
- },
-
- /**
- * Available filters
- * We only show filters that are available on the results
- *
- * @return {string[]}
- */
- availableFilters() {
- return Object.keys(this.results)
- },
-
- /**
- * Applied filters
- *
- * @return {string[]}
- */
- usedFiltersIn() {
- let match
- const filters = []
- while ((match = regexFilterIn.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Applied anti filters
- *
- * @return {string[]}
- */
- usedFiltersNot() {
- let match
- const filters = []
- while ((match = regexFilterNot.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Is the current search too short
- *
- * @return {boolean}
- */
- isShortQuery() {
- return this.query && this.query.trim().length < minSearchLength
- },
-
- /**
- * Is the current search valid
- *
- * @return {boolean}
- */
- isValidQuery() {
- return this.query && this.query.trim() !== '' && !this.isShortQuery
- },
-
- /**
- * Have we reached the end of all types searches
- *
- * @return {boolean}
- */
- isDoneSearching() {
- return Object.values(this.reached).every(state => state === false)
+ supportsLocalSearch() {
+ // TODO: Make this an API
+ const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
+ return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
},
+ },
+ watch: {
/**
- * Is there any search in progress
- *
- * @return {boolean}
+ * Emit the updated query as eventbus events
+ * (This is debounced)
*/
- isLoading() {
- return Object.values(this.loading).some(state => state === true)
+ queryText() {
+ this.debouncedQueryUpdate()
},
},
- async created() {
- subscribe('files:navigation:changed', this.resetForm)
- this.types = await getTypes()
- this.logger.debug('Unified Search initialized with the following providers', this.types)
- },
-
- beforeDestroy() {
- unsubscribe('files:navigation:changed', this.resetForm)
- },
-
mounted() {
- if (OCP.Accessibility.disableKeyboardShortcuts()) {
- return
+ // register keyboard listener for search shortcut
+ if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) {
+ window.addEventListener('keydown', this.onKeyDown)
}
- document.addEventListener('keydown', (event) => {
- // if not already opened, allows us to trigger default browser on second keydown
- if (event.ctrlKey && event.key === 'f' && !this.open) {
- event.preventDefault()
- this.open = true
- }
-
- // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
- if (this.open) {
- // If arrow down, focus next result
- if (event.key === 'ArrowDown') {
- this.focusNext(event)
- }
-
- // If arrow up, focus prev result
- if (event.key === 'ArrowUp') {
- this.focusPrev(event)
- }
- }
+ // Allow external reset of the search / close local search
+ subscribe('nextcloud:unified-search:reset', () => {
+ this.showLocalSearch = false
+ this.queryText = ''
})
- },
-
- methods: {
- async onOpen() {
- // Update types list in the background
- this.types = await getTypes()
- },
- onClose() {
- emit('nextcloud:unified-search.close')
- },
-
- resetForm() {
- this.$el.querySelector('form[role="search"]').reset()
- },
-
- /**
- * Reset the search state
- */
- onReset() {
- emit('nextcloud:unified-search.reset')
- this.logger.debug('Search reset')
- this.query = ''
- this.resetState()
- this.focusInput()
- },
- async resetState() {
- this.cursors = {}
- this.limits = {}
- this.reached = {}
- this.results = {}
- this.focused = null
- this.triggered = false
- await this.cancelPendingRequests()
- },
-
- /**
- * Cancel any ongoing searches
- */
- async cancelPendingRequests() {
- // Cloning so we can keep processing other requests
- const requests = this.requests.slice(0)
- this.requests = []
-
- // Cancel all pending requests
- await Promise.all(requests.map(cancel => cancel()))
- },
-
- /**
- * Focus the search input on next tick
- */
- focusInput() {
- this.$nextTick(() => {
- this.$refs.input.focus()
- this.$refs.input.select()
- })
- },
-
- /**
- * If we have results already, open first one
- * If not, trigger the search again
- */
- onInputEnter() {
- if (this.hasResults) {
- const results = this.getResultsList()
- results[0].click()
- return
- }
- this.onInput()
- },
-
- /**
- * Start searching on input
- */
- async onInput() {
- // emit the search query
- emit('nextcloud:unified-search.search', { query: this.query })
-
- // Do not search if not long enough
- if (this.query.trim() === '' || this.isShortQuery) {
- for (const type of this.typesIDs) {
- this.$delete(this.results, type)
- }
- return
- }
-
- let types = this.typesIDs
- let query = this.query
-
- // Filter out types
- if (this.usedFiltersNot.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
- }
-
- // Only use those filters if any and check if they are valid
- if (this.usedFiltersIn.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
- }
-
- // Remove any filters from the query
- query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
-
- // Reset search if the query changed
- await this.resetState()
- this.triggered = true
-
- if (!types.length) {
- // no results since no types were selected
- this.logger.error('No types to search in')
- return
- }
-
- this.$set(this.loading, 'all', true)
- this.logger.debug(`Searching ${query} in`, types)
-
- Promise.all(types.map(async type => {
- try {
- // Init cancellable request
- const { request, cancel } = search({ type, query })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.$set(this.results, type, data.ocs.data.entries)
- } else {
- this.$delete(this.results, type)
- }
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- } else if (!data.ocs.data.isPaginated) {
- // If no cursor and no pagination, we save the default amount
- // provided by server's initial state `defaultLimit`
- this.$set(this.limits, type, this.defaultLimit)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
-
- // If none already focused, focus the first rendered result
- if (this.focused === null) {
- this.focused = 0
- }
- return REQUEST_OK
- } catch (error) {
- this.$delete(this.results, type)
-
- // If this is not a cancelled throw
- if (error.response && error.response.status) {
- this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
- showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
- return REQUEST_FAILED
- }
- return REQUEST_CANCELED
- }
- })).then(results => {
- // Do not declare loading finished if the request have been cancelled
- // This means another search was triggered and we're therefore still loading
- if (results.some(result => result === REQUEST_CANCELED)) {
- return
- }
- // We finished all searches
- this.loading = {}
- })
- },
- onInputDebounced: enableLiveSearch
- ? debounce(function(e) {
- this.onInput(e)
- }, 500)
- : function() {
- this.triggered = false
- },
-
- /**
- * Load more results for the provided type
- *
- * @param {string} type type
- */
- async loadMore(type) {
- // If already loading, ignore
- if (this.loading[type]) {
- return
- }
- if (this.cursors[type]) {
- // Init cancellable request
- const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- }
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.results[type].push(...data.ocs.data.entries)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
- } else
-
- // If no cursor, we might have all the results already,
- // let's fake pagination and show the next xxx entries
- if (this.limits[type] && this.limits[type] >= 0) {
- this.limits[type] += this.defaultLimit
-
- // Check if we reached end of pagination
- if (this.limits[type] >= this.results[type].length) {
- this.$set(this.reached, type, true)
- }
- }
-
- // Focus result after render
- if (this.focused !== null) {
- this.$nextTick(() => {
- this.focusIndex(this.focused)
- })
- }
- },
+ // Deprecated events to be removed
+ subscribe('nextcloud:unified-search:reset', () => {
+ emit('nextcloud:unified-search.reset', { query: '' })
+ })
+ subscribe('nextcloud:unified-search:search', ({ query }) => {
+ emit('nextcloud:unified-search.search', { query })
+ })
- /**
- * Return a subset of the array if the search provider
- * doesn't supports pagination
- *
- * @param {Array} list the results
- * @param {string} type the type
- * @return {Array}
- */
- limitIfAny(list, type) {
- if (type in this.limits) {
- return list.slice(0, this.limits[type])
- }
- return list
- },
+ // all done
+ logger.debug('Unified search initialized!')
+ },
- getResultsList() {
- return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
- },
+ beforeDestroy() {
+ // keep in mind to remove the event listener
+ window.removeEventListener('keydown', this.onKeyDown)
+ },
+ methods: {
/**
- * Focus the first result if any
- *
- * @param {Event} event the keydown event
+ * Handle the key down event to open search on `ctrl + F`
+ * @param event The keyboard event
*/
- focusFirst(event) {
- const results = this.getResultsList()
- if (results && results.length > 0) {
- if (event) {
+ onKeyDown(event: KeyboardEvent) {
+ if (event.ctrlKey && event.key === 'f') {
+ // only handle search if not already open - in this case the browser native search should be used
+ if (!this.showLocalSearch && !this.showUnifiedSearch) {
event.preventDefault()
}
- this.focused = 0
- this.focusIndex(this.focused)
+ this.toggleUnifiedSearch()
}
},
/**
- * Focus the next result if any
- *
- * @param {Event} event the keydown event
+ * Toggle the local search if available - otherwise open the unified search modal
*/
- focusNext(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the last, focus the next one
- if (results && results.length > 0 && this.focused + 1 < results.length) {
- event.preventDefault()
- this.focused++
- this.focusIndex(this.focused)
+ toggleUnifiedSearch() {
+ if (this.supportsLocalSearch) {
+ this.showLocalSearch = !this.showLocalSearch
+ } else {
+ this.showUnifiedSearch = !this.showUnifiedSearch
+ this.showLocalSearch = false
}
},
/**
- * Focus the previous result if any
- *
- * @param {Event} event the keydown event
+ * Open the unified search modal
*/
- focusPrev(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the first, focus the previous one
- if (results && results.length > 0 && this.focused > 0) {
- event.preventDefault()
- this.focused--
- this.focusIndex(this.focused)
- }
-
+ openModal() {
+ this.showUnifiedSearch = true
+ this.showLocalSearch = false
},
/**
- * Focus the specified result index if it exists
- *
- * @param {number} index the result index
+ * Emit the updated search query as eventbus events
*/
- focusIndex(index) {
- const results = this.getResultsList()
- if (results && results[index]) {
- results[index].focus()
+ emitUpdatedQuery() {
+ if (this.queryText === '') {
+ emit('nextcloud:unified-search:reset')
+ } else {
+ emit('nextcloud:unified-search:search', { query: this.queryText })
}
},
-
- /**
- * Set the current focused element based on the target
- *
- * @param {Event} event the focus event
- */
- setFocusedIndex(event) {
- const entry = event.target
- const results = this.getResultsList()
- const index = [...results].findIndex(search => search === entry)
- if (index > -1) {
- // let's not use focusIndex as the entry is already focused
- this.focused = index
- }
- },
-
- onClickFilter(filter) {
- this.query = `${this.query} ${filter}`
- .replace(/ {2}/g, ' ')
- .trim()
- this.onInput()
- },
},
-}
+})
</script>
<style lang="scss" scoped>
-@use "sass:math";
-
-$margin: 10px;
-$input-height: 34px;
-$input-padding: 6px;
-
-.unified-search {
- &__trigger {
- filter: var(--background-image-invert-if-bright);
- }
-
- &__input-wrapper {
- position: sticky;
- // above search results
- z-index: 2;
- top: 0;
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- width: 100%;
- background-color: var(--color-main-background);
-
- label[for="unified-search__input"] {
- align-self: flex-start;
- font-weight: bold;
- font-size: 19px;
- margin-left: 13px;
- }
- }
-
- &__form-input {
- margin: 0 !important;
- }
-
- &__input-row {
- display: flex;
- width: 100%;
- align-items: center;
- }
-
- &__filters {
- margin: $margin 0 $margin math.div($margin, 2);
- ul {
- display: inline-flex;
- justify-content: space-between;
- }
- }
-
- &__form {
- position: relative;
- width: 100%;
- margin: $margin 0;
-
- // Loading spinner
- &::after {
- right: $input-padding;
- left: auto;
- }
-
- &-input,
- &-reset {
- margin: math.div($input-padding, 2);
- }
-
- &-input {
- width: 100%;
- height: $input-height;
- padding: $input-padding;
-
- &,
- &[placeholder],
- &::placeholder {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- // Hide webkit clear search
- &::-webkit-search-decoration,
- &::-webkit-search-cancel-button,
- &::-webkit-search-results-button,
- &::-webkit-search-results-decoration {
- -webkit-appearance: none;
- }
-
- // Ellipsis earlier if reset button is here
- .icon-loading-small &,
- &--with-reset {
- padding-right: $input-height;
- }
- }
-
- &-reset, &-submit {
- position: absolute;
- top: 0;
- right: 4px;
- width: $input-height - $input-padding;
- height: $input-height - $input-padding;
- min-height: 30px;
- padding: 0;
- opacity: .5;
- border: none;
- background-color: transparent;
- margin-right: 0;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 1;
- }
- }
-
- &-submit {
- right: 28px;
- }
- }
-
- &__results {
- &-header {
- display: block;
- margin: $margin;
- margin-bottom: $margin - 4px;
- margin-left: 13px;
- color: var(--color-primary-element);
- font-size: 19px;
- font-weight: bold;
- }
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .unified-search__result-more::v-deep {
- color: var(--color-text-maxcontrast);
- }
-
- .empty-content {
- margin: 10vh 0;
-
- ::v-deep .empty-content__title {
- font-weight: normal;
- font-size: var(--default-font-size);
- padding: 0 15px;
- text-align: center;
- }
- }
+// this is needed to allow us overriding component styles (focus-visible)
+.unified-search-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
-
</style>
diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue
index e46c64da24c..408cccf61e9 100644
--- a/core/src/views/UnsupportedBrowser.vue
+++ b/core/src/views/UnsupportedBrowser.vue
@@ -1,24 +1,7 @@
- <!--
- - @copyright 2022 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div class="content-unsupported-browser guest-box">
<NcEmptyContent>
@@ -48,11 +31,14 @@
</template>
<script>
-import { generateUrl } from '@nextcloud/router'
+// eslint-disable-next-line n/no-extraneous-import
+import { agents } from 'caniuse-lite/dist/unpacker/agents.js'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
-import Web from 'vue-material-design-icons/Web'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import Web from 'vue-material-design-icons/Web.vue'
import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js'
import { supportedBrowsers } from '../services/BrowsersListService.js'
@@ -69,12 +55,6 @@ export default {
NcEmptyContent,
},
- data() {
- return {
- agents: {},
- }
- },
-
computed: {
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
@@ -109,12 +89,12 @@ export default {
})
return Object.keys(list).map(id => {
- if (!this.agents[id]?.browser) {
+ if (!agents[id]?.browser) {
return null
}
const version = list[id]
- const name = this.agents[id]?.browser
+ const name = agents[id]?.browser
return this.t('core', '{name} version {version} and above', {
name, version,
})
@@ -122,13 +102,6 @@ export default {
},
},
- async beforeMount() {
- // Dynamic load big list of user agents
- // eslint-disable-next-line node/no-extraneous-import
- const { agents } = await import('caniuse-lite')
- this.agents = agents
- },
-
methods: {
t,
n,
@@ -140,10 +113,22 @@ export default {
// Redirect if there is the data
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('redirect_url')) {
- const redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/'
- window.location = redirectPath
- return
+ let redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/'
+
+ // remove index.php and double slashes
+ redirectPath = redirectPath
+ .replace('index.php', '')
+ .replace(getRootUrl(), '')
+ .replace(/\/\//g, '/')
+
+ // if we have a valid redirect url, use it
+ if (redirectPath.startsWith('/')) {
+ window.location = generateUrl(redirectPath)
+ return
+ }
}
+
+ // else redirect to root
window.location = generateUrl('/')
},
@@ -179,7 +164,8 @@ $spacing: 30px;
.empty-content {
margin: 0;
- &::v-deep .empty-content__icon {
+
+ :deep(.empty-content__icon) {
opacity: 1;
}
}
@@ -193,7 +179,7 @@ $spacing: 30px;
margin-top: 2 * $spacing;
margin-bottom: $spacing;
li {
- text-align: left;
+ text-align: start;
}
}
}