aboutsummaryrefslogtreecommitdiffstats
path: root/core/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/components')
-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.vue342
-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.vue71
-rw-r--r--core/src/components/LegacyDialogPrompt.vue111
-rw-r--r--core/src/components/MainMenu.js25
-rw-r--r--core/src/components/Profile/PrimaryActionButton.vue25
-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.vue10
-rw-r--r--core/src/components/UnifiedSearch/LegacySearchResult.vue29
-rw-r--r--core/src/components/UnifiedSearch/SearchFilterChip.vue6
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue126
-rw-r--r--core/src/components/UnifiedSearch/SearchResultPlaceholders.vue4
-rw-r--r--core/src/components/UnifiedSearch/SearchableList.vue51
-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.js27
-rw-r--r--core/src/components/UserMenu/ProfileUserMenuEntry.vue140
-rw-r--r--core/src/components/UserMenu/UserMenuEntry.vue106
-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.vue132
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue234
-rw-r--r--core/src/components/login/ResetPassword.vue170
-rw-r--r--core/src/components/login/UpdatePassword.vue23
-rw-r--r--core/src/components/setup/RecommendedApps.vue99
33 files changed, 2487 insertions, 1112 deletions
diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue
new file mode 100644
index 00000000000..d983226d273
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuEntry.vue
@@ -0,0 +1,117 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="href ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ class="account-menu-entry"
+ compact
+ :href="href"
+ :name="name"
+ target="_self"
+ @click="onClick">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" />
+ <slot v-else-if="$scopedSlots.icon" name="icon" />
+ <img v-else
+ class="account-menu-entry__icon"
+ :class="{ 'account-menu-entry__icon--active': active }"
+ :src="iconSource"
+ alt="">
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const versionHash = loadState('core', 'versionHash', '')
+
+export default defineComponent({
+ name: 'AccountMenuEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ computed: {
+ iconSource() {
+ return `${this.icon}?v=${versionHash}`
+ },
+ },
+
+ methods: {
+ onClick(e: MouseEvent) {
+ this.$emit('click', e)
+
+ // Allow to not show the loading indicator
+ // in case the click event was already handled
+ if (!e.defaultPrevented) {
+ this.loading = true
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.account-menu-entry {
+ &__icon {
+ height: 16px;
+ width: 16px;
+ margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size
+ filter: var(--background-invert-if-dark);
+
+ &--active {
+ filter: var(--primary-invert-if-dark);
+ }
+ }
+
+ &__loading {
+ height: 20px;
+ width: 20px;
+ margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size
+ }
+
+ :deep(.list-item-content__main) {
+ width: fit-content;
+ }
+}
+</style>
diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
new file mode 100644
index 00000000000..8b895b8ca31
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
@@ -0,0 +1,100 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="profileEnabled ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ compact
+ :href="profileEnabled ? href : undefined"
+ :name="displayName"
+ target="_self">
+ <template v-if="profileEnabled" #subname>
+ {{ name }}
+ </template>
+ <template v-if="loading" #indicator>
+ <NcLoadingIcon />
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false })
+
+export default defineComponent({
+ name: 'AccountMenuProfileEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ profileEnabled,
+ displayName: getCurrentUser()!.displayName,
+ }
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ mounted() {
+ subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ methods: {
+ handleClick() {
+ if (this.profileEnabled) {
+ this.loading = true
+ }
+ },
+
+ handleProfileEnabledUpdate(profileEnabled: boolean) {
+ this.profileEnabled = profileEnabled
+ },
+
+ handleDisplayNameUpdate(displayName: string) {
+ this.displayName = displayName
+ },
+ },
+})
+</script>
diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue
index 2213840a7c0..88f626ff569 100644
--- a/core/src/components/AppMenu.vue
+++ b/core/src/components/AppMenu.vue
@@ -1,309 +1,161 @@
<!--
- - @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <nav class="app-menu"
+ <nav ref="appMenu"
+ class="app-menu"
:aria-label="t('core', 'Applications menu')">
- <ul class="app-menu-main">
- <li v-for="app in mainAppList"
+ <ul :aria-label="t('core', 'Apps')"
+ class="app-menu__list">
+ <AppMenuEntry v-for="app in mainAppList"
:key="app.id"
- :data-app-id="app.id"
- class="app-menu-entry"
- :class="{ 'app-menu-entry__active': app.active }">
- <a :href="app.href"
- :class="{ 'has-unread': app.unread > 0 }"
- :aria-label="appLabel(app)"
- :title="app.name"
- :aria-current="app.active ? 'page' : false"
- :target="app.target ? '_blank' : undefined"
- :rel="app.target ? 'noopener noreferrer' : undefined">
- <img :src="app.icon" alt="">
- <div class="app-menu-entry--label">
- {{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
- </div>
- </a>
- </li>
+ :app="app" />
</ul>
- <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')">
+ <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')">
<NcActionLink v-for="app in popoverAppList"
:key="app.id"
- :aria-label="appLabel(app)"
:aria-current="app.active ? 'page' : false"
:href="app.href"
- class="app-menu-popover-entry">
- <template #icon>
- <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }">
- <img :src="app.icon" alt="">
- </div>
- </template>
+ :icon="app.icon"
+ class="app-menu__overflow-entry">
{{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
</NcActionLink>
</NcActions>
</nav>
</template>
-<script>
-import { loadState } from '@nextcloud/initial-state'
+<script lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
+import { loadState } from '@nextcloud/initial-state'
+import { n, t } from '@nextcloud/l10n'
+import { useElementSize } from '@vueuse/core'
+import { defineComponent, ref } from 'vue'
-export default {
+import AppMenuEntry from './AppMenuEntry.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import logger from '../logger'
+
+export default defineComponent({
name: 'AppMenu',
+
components: {
- NcActions, NcActionLink,
+ AppMenuEntry,
+ NcActions,
+ NcActionLink,
+ },
+
+ setup() {
+ const appMenu = ref()
+ const { width: appMenuWidth } = useElementSize(appMenu)
+ return {
+ t,
+ n,
+ appMenu,
+ appMenuWidth,
+ }
},
+
data() {
+ const appList = loadState<INavigationEntry[]>('core', 'apps', [])
return {
- apps: loadState('core', 'apps', {}),
- appLimit: 0,
- observer: null,
+ appList,
}
},
+
computed: {
- appList() {
- return Object.values(this.apps)
+ appLimit() {
+ const maxApps = Math.floor(this.appMenuWidth / 50)
+ if (maxApps < this.appList.length) {
+ // Ensure there is space for the overflow menu
+ return Math.max(maxApps - 1, 0)
+ }
+ return maxApps
},
+
mainAppList() {
return this.appList.slice(0, this.appLimit)
},
+
popoverAppList() {
return this.appList.slice(this.appLimit)
},
- appLabel() {
- return (app) => app.name
- + (app.active ? ' (' + t('core', 'Currently open') + ')' : '')
- + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '')
- },
},
+
mounted() {
- this.observer = new ResizeObserver(this.resize)
- this.observer.observe(this.$el)
- this.resize()
subscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
beforeDestroy() {
- this.observer.disconnect()
unsubscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
methods: {
- setNavigationCounter(id, counter) {
- this.$set(this.apps[id], 'unread', counter)
- },
- setApps({ apps }) {
- this.apps = apps
- },
- resize() {
- const availableWidth = this.$el.offsetWidth
- let appCount = Math.floor(availableWidth / 50) - 1
- const popoverAppCount = this.appList.length - appCount
- if (popoverAppCount === 1) {
- appCount--
+ setNavigationCounter(id: string, counter: number) {
+ const app = this.appList.find(({ app }) => app === id)
+ if (app) {
+ this.$set(app, 'unread', counter)
+ } else {
+ logger.warn(`Could not find app "${id}" for setting navigation count`)
}
- if (appCount < 1) {
- appCount = 0
- }
- this.appLimit = appCount
+ },
+
+ setApps({ apps }: { apps: INavigationEntry[]}) {
+ this.appList = apps
},
},
-}
+})
</script>
-<style lang="scss" scoped>
-$header-icon-size: 20px;
-
+<style scoped lang="scss">
.app-menu {
- width: 100%;
+ // The size the currently focussed entry will grow to show the full name
+ --app-menu-entry-growth: calc(var(--default-grid-baseline) * 4);
display: flex;
- flex-shrink: 1;
- flex-wrap: wrap;
-}
-.app-menu-main {
- display: flex;
- flex-wrap: nowrap;
+ flex: 1 1;
+ width: 0;
- .app-menu-entry {
- width: 50px;
- height: 50px;
- position: relative;
+ &__list {
display: flex;
+ flex-wrap: nowrap;
+ margin-inline: calc(var(--app-menu-entry-growth) / 2);
+ }
- &.app-menu-entry__active {
- opacity: 1;
-
- &::before {
- content: " ";
- position: absolute;
- pointer-events: none;
- border-bottom-color: var(--color-main-background);
- transform: translateX(-50%);
- width: 12px;
- height: 5px;
- border-radius: 3px;
- background-color: var(--color-primary-text);
- left: 50%;
- bottom: 6px;
- display: block;
- transition: all 0.1s ease-in-out;
- opacity: 1;
- }
-
- .app-menu-entry--label {
- font-weight: bold;
- }
- }
-
- a {
- width: calc(100% - 4px);
- height: calc(100% - 4px);
- margin: 2px;
- // this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
- color: var(--color-primary-text);
- position: relative;
- }
+ &__overflow {
+ margin-block: auto;
- img {
- transition: margin 0.1s ease-in-out;
- width: $header-icon-size;
- height: $header-icon-size;
- padding: calc((100% - $header-icon-size) / 2);
- box-sizing: content-box;
+ // Adjust the overflow NcActions styles as they are directly rendered on the background
+ :deep(.button-vue--vue-tertiary) {
+ opacity: .7;
+ margin: 3px;
filter: var(--background-image-invert-if-bright);
- }
- .app-menu-entry--label {
- opacity: 0;
- position: absolute;
- font-size: 12px;
- // this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
- color: var(--color-primary-text);
- text-align: center;
- left: 50%;
- top: 45%;
- display: block;
- min-width: 100%;
- transform: translateX(-50%);
- transition: all 0.1s ease-in-out;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
- letter-spacing: -0.5px;
- filter: var(--background-image-invert-if-bright);
- }
+ /* Remove all background and align text color if not expanded */
+ &:not([aria-expanded="true"]) {
+ color: var(--color-background-plain-text);
- &:hover,
- &:focus-within {
- opacity: 1;
- .app-menu-entry--label {
- opacity: 1;
- font-weight: bolder;
- bottom: 0;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
+ &:hover {
+ opacity: 1;
+ background-color: transparent !important;
+ }
}
- }
-
- }
- // Show labels
- &:hover,
- &:focus-within,
- .app-menu-entry:hover,
- .app-menu-entry:focus {
- opacity: 1;
-
- img {
- margin-top: -8px;
- }
-
- .app-menu-entry--label {
- opacity: 1;
- bottom: 0;
- }
-
- &::before, .app-menu-entry::before {
- opacity: 0;
- }
- }
-}
-
-::v-deep .app-menu-more .button-vue--vue-tertiary {
- opacity: .7;
- margin: 3px;
- filter: var(--background-image-invert-if-bright);
-
- /* Remove all background and align text color if not expanded */
- &:not([aria-expanded="true"]) {
- color: var(--color-primary-element-text);
-
- &:hover {
- opacity: 1;
- background-color: transparent !important;
+ &:focus-visible {
+ opacity: 1;
+ outline: none !important;
+ }
}
}
- &:focus-visible {
- opacity: 1;
- outline: none !important;
- }
-}
-
-.app-menu-popover-entry {
- .app-icon {
- position: relative;
- height: 44px;
- width: 48px;
- display: flex;
- align-items: center;
- justify-content: center;
- /* Icons are bright so invert them if bright color theme == bright background is used */
- filter: var(--background-invert-if-bright);
-
- &.has-unread::after {
- background-color: var(--color-main-text);
- }
-
- img {
- width: $header-icon-size;
- height: $header-icon-size;
+ &__overflow-entry {
+ :deep(.action-link__icon) {
+ // Icons are bright so invert them if bright color theme == bright background is used
+ filter: var(--background-invert-if-bright) !important;
}
}
}
-
-.has-unread::after {
- content: "";
- width: 8px;
- height: 8px;
- background-color: var(--color-primary-element-text);
- border-radius: 50%;
- position: absolute;
- display: block;
- top: 10px;
- right: 10px;
-}
-
-.unread-counter {
- display: none;
-}
</style>
diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue
new file mode 100644
index 00000000000..4c5acb7e9c8
--- /dev/null
+++ b/core/src/components/AppMenuEntry.vue
@@ -0,0 +1,189 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <li ref="containerElement"
+ class="app-menu-entry"
+ :class="{
+ 'app-menu-entry--active': app.active,
+ 'app-menu-entry--truncated': needsSpace,
+ }">
+ <a class="app-menu-entry__link"
+ :href="app.href"
+ :title="app.name"
+ :aria-current="app.active ? 'page' : false"
+ :target="app.target ? '_blank' : undefined"
+ :rel="app.target ? 'noopener noreferrer' : undefined">
+ <AppMenuIcon class="app-menu-entry__icon" :app="app" />
+ <span ref="labelElement" class="app-menu-entry__label">
+ {{ app.name }}
+ </span>
+ </a>
+ </li>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+import { onMounted, ref, watch } from 'vue'
+import AppMenuIcon from './AppMenuIcon.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+const containerElement = ref<HTMLLIElement>()
+const labelElement = ref<HTMLSpanElement>()
+const needsSpace = ref(false)
+
+/** Update the space requirements of the app label */
+function calculateSize() {
+ const maxWidth = containerElement.value!.clientWidth
+ // Also keep the 0.5px letter spacing in mind
+ needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth)
+}
+// Update size on mounted and when the app name changes
+onMounted(calculateSize)
+watch(() => props.app.name, calculateSize)
+</script>
+
+<style scoped lang="scss">
+.app-menu-entry {
+ --app-menu-entry-font-size: 12px;
+ width: var(--header-height);
+ height: var(--header-height);
+ position: relative;
+
+ &__link {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ // Set color as this is shown directly on the background
+ color: var(--color-background-plain-text);
+ // Make space for focus-visible outline
+ width: calc(100% - 4px);
+ height: calc(100% - 4px);
+ margin: 2px;
+ }
+
+ &__label {
+ opacity: 0;
+ position: absolute;
+ font-size: var(--app-menu-entry-font-size);
+ // this is shown directly on the background
+ color: var(--color-background-plain-text);
+ text-align: center;
+ bottom: 0;
+ inset-inline-start: 50%;
+ top: 50%;
+ display: block;
+ transform: translateX(-50%);
+ max-width: 100%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ letter-spacing: -0.5px;
+ }
+ body[dir=rtl] &__label {
+ transform: translateX(50%) !important;
+ }
+
+ &__icon {
+ font-size: var(--app-menu-entry-font-size);
+ }
+
+ &--active {
+ // When hover or focus, show the label and make it bolder than the other entries
+ .app-menu-entry__label {
+ font-weight: bolder;
+ }
+
+ // When active show a line below the entry as an "active" indicator
+ &::before {
+ content: " ";
+ position: absolute;
+ pointer-events: none;
+ border-bottom-color: var(--color-main-background);
+ transform: translateX(-50%);
+ width: 10px;
+ height: 5px;
+ border-radius: 3px;
+ background-color: var(--color-background-plain-text);
+ inset-inline-start: 50%;
+ bottom: 8px;
+ display: block;
+ transition: all var(--animation-quick) ease-in-out;
+ opacity: 1;
+ }
+ body[dir=rtl] &::before {
+ transform: translateX(50%) !important;
+ }
+ }
+
+ &__icon,
+ &__label {
+ transition: all var(--animation-quick) ease-in-out;
+ }
+
+ // Make the hovered entry bold to see that it is hovered
+ &:hover .app-menu-entry__label,
+ &:focus-within .app-menu-entry__label {
+ font-weight: bold;
+ }
+
+ // Adjust the width when an entry is focussed
+ // The focussed / hovered entry should grow, while both neighbors need to shrink
+ &--truncated:hover,
+ &--truncated:focus-within {
+ .app-menu-entry__label {
+ max-width: calc(var(--header-height) + var(--app-menu-entry-growth));
+ }
+
+ // The next entry needs to shrink half the growth
+ + .app-menu-entry {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+ }
+
+ // The previous entry needs to shrink half the growth
+ &:has(+ .app-menu-entry--truncated:hover),
+ &:has(+ .app-menu-entry--truncated:focus-within) {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+}
+</style>
+
+<style lang="scss">
+// Showing the label
+.app-menu-entry:hover,
+.app-menu-entry:focus-within,
+.app-menu__list:hover,
+.app-menu__list:focus-within {
+ // Move icon up so that the name does not overflow the icon
+ .app-menu-entry__icon {
+ margin-block-end: 1lh;
+ }
+
+ // Make the label visible
+ .app-menu-entry__label {
+ opacity: 1;
+ }
+
+ // Hide indicator when the text is shown
+ .app-menu-entry--active::before {
+ opacity: 0;
+ }
+
+ .app-menu-icon__unread {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue
new file mode 100644
index 00000000000..1b0d48daf8c
--- /dev/null
+++ b/core/src/components/AppMenuIcon.vue
@@ -0,0 +1,67 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <span class="app-menu-icon"
+ role="img"
+ :aria-hidden="ariaHidden"
+ :aria-label="ariaLabel">
+ <img class="app-menu-icon__icon" :src="app.icon" alt="">
+ <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" />
+ </span>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation.ts'
+
+import { n } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import IconDot from 'vue-material-design-icons/CircleOutline.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+// only hide if there are no unread notifications
+const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined)
+
+const ariaLabel = computed(() => {
+ if (!props.app.unread) {
+ return undefined
+ }
+
+ return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})`
+})
+</script>
+
+<style scoped lang="scss">
+$icon-size: 20px;
+$unread-indicator-size: 10px;
+
+.app-menu-icon {
+ box-sizing: border-box;
+ position: relative;
+
+ height: $icon-size;
+ width: $icon-size;
+
+ &__icon {
+ transition: margin 0.1s ease-in-out;
+ height: $icon-size;
+ width: $icon-size;
+ filter: var(--background-image-invert-if-bright);
+ mask: var(--header-menu-icon-mask);
+ }
+
+ &__unread {
+ color: var(--color-error);
+ position: absolute;
+ // Align the dot to the top right corner of the icon
+ inset-block-end: calc($icon-size + ($unread-indicator-size / -2));
+ inset-inline-end: calc($unread-indicator-size / -2);
+ transition: all 0.1s ease-in-out;
+ }
+}
+</style>
diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js
index 1b7b25873d0..e07a699ab9f 100644
--- a/core/src/components/ContactsMenu.js
+++ b/core/src/components/ContactsMenu.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Christopher Ng <chrng8@gmail.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
@@ -34,6 +15,7 @@ export const setUp = () => {
if (mountPoint) {
// eslint-disable-next-line no-new
new Vue({
+ name: 'ContactsMenuRoot',
el: mountPoint,
render: h => h(ContactsMenu),
})
diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue
index a450127b937..322f53647b1 100644
--- a/core/src/components/ContactsMenu/Contact.vue
+++ b/core/src/components/ContactsMenu/Contact.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<li class="contact">
@@ -39,7 +23,7 @@
:inline="contact.topAction ? 1 : 0">
<template v-for="(action, idx) in actions">
<NcActionLink v-if="action.hyperlink !== '#'"
- :key="idx"
+ :key="`${idx}-link`"
:href="action.hyperlink"
class="other-actions">
<template #icon>
@@ -47,30 +31,46 @@
</template>
{{ action.title }}
</NcActionLink>
- <NcActionText v-else :key="idx" class="other-actions">
+ <NcActionText v-else :key="`${idx}-text`" class="other-actions">
<template #icon>
<img aria-hidden="true" class="contact__action__icon" :src="action.icon">
</template>
{{ action.title }}
</NcActionText>
</template>
+ <NcActionButton v-for="action in jsActions"
+ :key="action.id"
+ :close-after-click="true"
+ class="other-actions"
+ @click="action.callback(contact)">
+ <template #icon>
+ <NcIconSvgWrapper class="contact__action__icon-svg"
+ :svg="action.iconSvg(contact)" />
+ </template>
+ {{ action.displayName(contact) }}
+ </NcActionButton>
</NcActions>
</li>
</template>
<script>
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { getEnabledContactsMenuActions } from '@nextcloud/vue/functions/contactsMenu'
export default {
name: 'Contact',
components: {
NcActionLink,
NcActionText,
+ NcActionButton,
NcActions,
NcAvatar,
+ NcIconSvgWrapper,
},
props: {
contact: {
@@ -85,6 +85,9 @@ export default {
}
return this.contact.actions
},
+ jsActions() {
+ return getEnabledContactsMenuActions(this.contact)
+ },
preloadedUserStatus() {
if (this.contact.status) {
return {
@@ -94,7 +97,7 @@ export default {
}
}
return undefined
- }
+ },
},
}
</script>
@@ -104,7 +107,8 @@ export default {
display: flex;
position: relative;
align-items: center;
- padding: 3px 3px 3px 10px;
+ padding: 3px;
+ padding-inline-start: 10px;
&__action {
&__icon {
@@ -113,9 +117,10 @@ export default {
padding: 12px;
filter: var(--background-invert-if-dark);
}
- }
- &__avatar-wrapper {
+ &__icon-svg {
+ padding: 5px;
+ }
}
&__avatar {
@@ -124,8 +129,8 @@ export default {
&__body {
flex-grow: 1;
- padding-left: 10px;
- margin-left: 10px;
+ padding-inline-start: 10px;
+ margin-inline-start: 10px;
min-width: 0;
div {
@@ -178,11 +183,11 @@ export default {
/* actions menu */
.menu {
top: 47px;
- margin-right: 13px;
+ margin-inline-end: 13px;
}
.popovermenu::after {
- right: 2px;
+ inset-inline-end: 2px;
}
}
</style>
diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue
new file mode 100644
index 00000000000..f2ee4be9151
--- /dev/null
+++ b/core/src/components/LegacyDialogPrompt.vue
@@ -0,0 +1,111 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog dialog-classes="legacy-prompt__dialog"
+ :buttons="buttons"
+ :name="name"
+ @update:open="$emit('close', false, inputValue)">
+ <p class="legacy-prompt__text" v-text="text" />
+ <NcPasswordField v-if="isPassword"
+ ref="input"
+ autocomplete="new-password"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ <NcTextField v-else
+ ref="input"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+export default defineComponent({
+ name: 'LegacyDialogPrompt',
+
+ components: {
+ NcDialog,
+ NcTextField,
+ NcPasswordField,
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ text: {
+ type: String,
+ required: true,
+ },
+
+ isPassword: {
+ type: Boolean,
+ required: true,
+ },
+
+ inputName: {
+ type: String,
+ default: 'prompt-input',
+ },
+ },
+
+ emits: ['close'],
+
+ data() {
+ return {
+ inputValue: '',
+ }
+ },
+
+ computed: {
+ buttons() {
+ return [
+ {
+ label: t('core', 'No'),
+ callback: () => this.$emit('close', false, this.inputValue),
+ },
+ {
+ label: t('core', 'Yes'),
+ type: 'primary',
+ callback: () => this.$emit('close', true, this.inputValue),
+ },
+ ]
+ },
+ },
+
+ mounted() {
+ this.$nextTick(() => this.$refs.input?.focus?.())
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.legacy-prompt {
+ &__text {
+ margin-block: 0 .75em;
+ }
+
+ &__input {
+ margin-block: 0 1em;
+ }
+}
+
+:deep(.legacy-prompt__dialog .dialog__actions) {
+ min-width: calc(100% - 12px);
+ justify-content: space-between;
+}
+</style>
diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js
index 46e0e5c510b..21a0b6a772f 100644
--- a/core/src/components/MainMenu.js
+++ b/core/src/components/MainMenu.js
@@ -1,25 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
@@ -36,7 +17,7 @@ export const setUp = () => {
},
})
- const container = document.getElementById('header-left__appmenu')
+ const container = document.getElementById('header-start__appmenu')
if (!container) {
// no container, possibly we're on a public page
return
diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue
index d09b348c62b..dbc446b3d90 100644
--- a/core/src/components/Profile/PrimaryActionButton.vue
+++ b/core/src/components/Profile/PrimaryActionButton.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -38,8 +21,8 @@
<script>
import { defineComponent } from 'vue'
-import { NcButton } from '@nextcloud/vue'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
export default defineComponent({
name: 'PrimaryActionButton',
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
new file mode 100644
index 00000000000..f3c57a12042
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <li ref="listItem" :role="itemRole" v-html="html" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+
+defineProps<{
+ id: string
+ html: string
+}>()
+
+const listItem = ref<HTMLLIElement>()
+const itemRole = ref('presentation')
+
+onMounted(() => {
+ // check for proper roles
+ const menuitem = listItem.value?.querySelector('[role="menuitem"]')
+ if (menuitem) {
+ return
+ }
+ // check if a button is available
+ const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a')
+ if (button) {
+ button.role = 'menuitem'
+ } else {
+ // if nothing is available set role on `<li>`
+ itemRole.value = 'menuitem'
+ }
+})
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
new file mode 100644
index 00000000000..413806c7089
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcListItem :anchor-id="`${id}--link`"
+ compact
+ :details="details"
+ :href="href"
+ :name="label"
+ role="presentation"
+ @click="$emit('click')">
+ <template #icon>
+ <slot v-if="$scopedSlots.icon" name="icon" />
+ <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" />
+ </template>
+ </NcListItem>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+
+const props = defineProps<{
+ /** Only emit click event but do not open href */
+ clickOnly?: boolean
+ // menu entry props
+ id: string
+ label: string
+ icon?: string
+ href: string
+ details?: string
+}>()
+
+onMounted(() => {
+ const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement
+ // Make the `<a>` a menuitem
+ anchor.role = 'menuitem'
+ // Prevent native click handling if required
+ if (props.clickOnly) {
+ anchor.onclick = (event) => event.preventDefault()
+ }
+})
+</script>
+
+<style scoped>
+.public-page-menu-entry__icon {
+ padding-inline-start: var(--default-grid-baseline);
+}
+</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
new file mode 100644
index 00000000000..0f02bdf7524
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
@@ -0,0 +1,90 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcDialog is-form
+ :name="label"
+ :open.sync="open"
+ @submit="createFederatedShare">
+ <NcTextField ref="input"
+ :label="t('core', 'Federated user')"
+ :placeholder="t('core', 'user@your-nextcloud.org')"
+ required
+ :value.sync="remoteUrl" />
+ <template #actions>
+ <NcButton :disabled="loading" type="primary" native-type="submit">
+ <template v-if="loading" #icon>
+ <NcLoadingIcon />
+ </template>
+ {{ t('core', 'Create share') }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import type Vue from 'vue'
+
+import { t } from '@nextcloud/l10n'
+import { showError } from '@nextcloud/dialogs'
+import { generateUrl } from '@nextcloud/router'
+import { getSharingToken } from '@nextcloud/sharing/public'
+import { nextTick, onMounted, ref, watch } from 'vue'
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import logger from '../../logger'
+
+defineProps<{
+ label: string
+}>()
+
+const loading = ref(false)
+const remoteUrl = ref('')
+// Todo: @nextcloud/vue should expose the types correctly
+const input = ref<Vue & { focus: () => void }>()
+const open = ref(true)
+
+// Focus when mounted
+onMounted(() => nextTick(() => input.value!.focus()))
+
+// Check validity
+watch(remoteUrl, () => {
+ let validity = ''
+ if (!remoteUrl.value.includes('@')) {
+ validity = t('core', 'The remote URL must include the user.')
+ } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) {
+ validity = t('core', 'Invalid remote URL.')
+ }
+ input.value!.$el.querySelector('input')!.setCustomValidity(validity)
+ input.value!.$el.querySelector('input')!.reportValidity()
+})
+
+/**
+ * Create a federated share for the current share
+ */
+async function createFederatedShare() {
+ loading.value = true
+
+ try {
+ const url = generateUrl('/apps/federatedfilesharing/createFederatedShare')
+ const { data } = await axios.post<{ remoteUrl: string }>(url, {
+ shareWith: remoteUrl.value,
+ token: getSharingToken(),
+ })
+ if (data.remoteUrl.includes('://')) {
+ window.location.href = data.remoteUrl
+ } else {
+ window.location.href = `${window.location.protocol}//${data.remoteUrl}`
+ }
+ } catch (error) {
+ logger.error('Failed to create federated share', { error })
+ showError(t('files_sharing', 'Failed to add the public link to your Nextcloud'))
+ } finally {
+ loading.value = false
+ }
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
new file mode 100644
index 00000000000..a4451a38bbe
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ :icon="icon"
+ href="#"
+ :label="label"
+ @click="openDialog" />
+</template>
+
+<script setup lang="ts">
+import { spawnDialog } from '@nextcloud/dialogs'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Open the "create federated share" dialog
+ */
+function openDialog() {
+ spawnDialog(PublicPageMenuExternalDialog, { label: props.label })
+ emit('click')
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
new file mode 100644
index 00000000000..5f3a4883d6d
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ click-only
+ :icon="icon"
+ :href="href"
+ :label="label"
+ @click="onClick" />
+</template>
+
+<script setup lang="ts">
+import { showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Copy the href to the clipboard
+ */
+async function copyLink() {
+ try {
+ await window.navigator.clipboard.writeText(props.href)
+ showSuccess(t('core', 'Direct link copied'))
+ } catch {
+ // No secure context -> fallback to dialog
+ window.prompt(t('core', 'Please copy the link manually:'), props.href)
+ }
+}
+
+/**
+ * onclick handler to trigger the "copy link" action
+ * and emit the event so the menu can be closed
+ */
+function onClick() {
+ copyLink()
+ emit('click')
+}
+</script>
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
index ec592732a8d..d86192d156e 100644
--- a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
+++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcModal v-if="isModalOpen"
id="unified-search"
@@ -33,9 +37,9 @@
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcModal from '@nextcloud/vue/components/NcModal'
import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
export default {
diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue
index 01f48a36709..4592adf08c9 100644
--- a/core/src/components/UnifiedSearch/LegacySearchResult.vue
+++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue
@@ -1,24 +1,7 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<a :href="resourceUrl || '#'"
class="unified-search__result"
@@ -59,7 +42,7 @@
</template>
<script>
-import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
+import NcHighlight from '@nextcloud/vue/components/NcHighlight'
export default {
name: 'LegacySearchResult',
@@ -236,7 +219,7 @@ $margin: 10px;
flex-wrap: wrap;
// Set to minimum and gro from it
min-width: 0;
- padding-left: $margin;
+ padding-inline-start: $margin;
}
&-line-one,
diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue
index 8342e9e256d..e08ddd58a4b 100644
--- a/core/src/components/UnifiedSearch/SearchFilterChip.vue
+++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div class="chip">
<span class="icon">
@@ -50,7 +54,7 @@ export default {
.icon {
display: flex;
align-items: center;
- padding-right: 5px;
+ padding-inline-end: 5px;
img {
width: 20px;
diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue
index a746a5751b7..4f33fbd54cc 100644
--- a/core/src/components/UnifiedSearch/SearchResult.vue
+++ b/core/src/components/UnifiedSearch/SearchResult.vue
@@ -1,16 +1,20 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <NcListItem class="result-items__item"
+ <NcListItem class="result-item"
:name="title"
:bold="false"
:href="resourceUrl"
target="_self">
<template #icon>
<div aria-hidden="true"
- class="result-items__item-icon"
+ class="result-item__icon"
:class="{
- 'result-items__item-icon--rounded': rounded,
- 'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
- 'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
+ 'result-item__icon--rounded': rounded,
+ 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
+ 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
[icon]: !isValidIconOrPreviewUrl(icon),
}"
:style="{
@@ -28,7 +32,7 @@
</template>
<script>
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
export default {
name: 'SearchResult',
@@ -97,73 +101,59 @@ export default {
</script>
<style lang="scss" scoped>
-@use "sass:math";
-$clickable-area: 44px;
-$margin: 10px;
-
-.result-items {
- &__item {
-
- ::v-deep a {
- border-radius: 12px;
- border: 2px solid transparent;
- border-radius: var(--border-radius-large) !important;
-
- &--focused {
- background-color: var(--color-background-hover);
- }
-
- &:active,
- &:hover,
- &:focus {
- background-color: var(--color-background-hover);
- border: 2px solid var(--color-border-maxcontrast);
- }
-
- * {
- cursor: pointer;
- }
-
- }
-
- &-icon {
- overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
- border-radius: var(--border-radius);
- background-repeat: no-repeat;
- background-position: center center;
- background-size: 32px;
-
- &--rounded {
- border-radius: math.div($clickable-area, 2);
- }
+.result-item {
+ :deep(a) {
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
- &--no-preview {
- background-size: 32px;
- }
+ * {
+ cursor: pointer;
+ }
+ }
+
+ &__icon {
+ overflow: hidden;
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 32px;
+
+ &--rounded {
+ border-radius: calc(var(--default-clickable-area) / 2);
+ }
- &--with-thumbnail {
- background-size: cover;
- }
+ &--no-preview {
+ background-size: 32px;
+ }
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
- border: 1px solid var(--color-border);
- }
+ &--with-thumbnail {
+ background-size: cover;
+ }
- img {
- // Make sure to keep ratio
- width: 100%;
- height: 100%;
+ &--with-thumbnail:not(#{&}--rounded) {
+ border: 1px solid var(--color-border);
+ // compensate for border
+ max-height: calc(var(--default-clickable-area) - 2px);
+ max-width: calc(var(--default-clickable-area) - 2px);
+ }
- object-fit: cover;
- object-position: center;
- }
- }
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
- }
+ object-fit: cover;
+ object-position: center;
+ }
+ }
}
</style>
diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
index d2a297a0a37..aec2791d8e4 100644
--- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
+++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<ul>
<!-- Placeholder animation -->
diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
index 33f45d06266..d7abb6ffdbb 100644
--- a/core/src/components/UnifiedSearch/SearchableList.vue
+++ b/core/src/components/UnifiedSearch/SearchableList.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
- -
- - @author Marco Ambrosini <marcoambrosini@proton.me>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -34,7 +17,7 @@
:show-trailing-button="searchTerm !== ''"
@update:value="searchTermChanged"
@trailing-button-click="clearSearch">
- <Magnify :size="20" />
+ <IconMagnify :size="20" />
</NcTextField>
<ul v-if="filteredList.length > 0" class="searchable-list__list">
<li v-for="element in filteredList"
@@ -46,7 +29,11 @@
:wide="true"
@click="itemSelected(element)">
<template #icon>
- <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
+ <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" />
+ <NcAvatar v-else
+ :is-no-user="true"
+ :display-name="element.displayName"
+ :show-user-status="false" />
</template>
{{ element.displayName }}
</NcButton>
@@ -55,7 +42,7 @@
<div v-else class="searchable-list__empty-content">
<NcEmptyContent :name="emptyContentText">
<template #icon>
- <AlertCircleOutline />
+ <IconAlertCircleOutline />
</template>
</NcEmptyContent>
</div>
@@ -64,22 +51,26 @@
</template>
<script>
-import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcPopover from '@nextcloud/vue/components/NcPopover'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
+import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import IconMagnify from 'vue-material-design-icons/Magnify.vue'
export default {
name: 'SearchableList',
components: {
- NcPopover,
- NcTextField,
- Magnify,
- AlertCircleOutline,
+ IconMagnify,
+ IconAlertCircleOutline,
NcAvatar,
- NcEmptyContent,
NcButton,
+ NcEmptyContent,
+ NcPopover,
+ NcTextField,
},
props: {
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
new file mode 100644
index 00000000000..171eada8a06
--- /dev/null
+++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
@@ -0,0 +1,166 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <Transition>
+ <div v-if="open"
+ class="local-unified-search animated-width"
+ :class="{ 'local-unified-search--open': open }">
+ <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder -->
+ <NcInputField ref="searchInput"
+ class="local-unified-search__input animated-width"
+ :aria-label="t('core', 'Search in current app')"
+ :placeholder="t('core', 'Search in current app')"
+ show-trailing-button
+ :trailing-button-label="t('core', 'Clear search')"
+ :value="query"
+ @update:value="$emit('update:query', $event)"
+ @trailing-button-click="clearAndCloseSearch">
+ <template #trailing-button-icon>
+ <NcIconSvgWrapper :path="mdiClose" />
+ </template>
+ </NcInputField>
+
+ <NcButton ref="searchGlobalButton"
+ class="local-unified-search__global-search"
+ :aria-label="t('core', 'Search everywhere')"
+ :title="t('core', 'Search everywhere')"
+ type="tertiary-no-background"
+ @click="$emit('global-search')">
+ <template v-if="!isMobile" #default>
+ {{ t('core', 'Search everywhere') }}
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCloudSearchOutline" />
+ </template>
+ </NcButton>
+ </div>
+ </Transition>
+</template>
+
+<script lang="ts" setup>
+import type { ComponentPublicInstance } from 'vue'
+import { mdiCloudSearchOutline, mdiClose } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { useElementSize } from '@vueuse/core'
+import { computed, ref, watchEffect } from 'vue'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+
+const props = defineProps<{
+ query: string,
+ open: boolean
+}>()
+
+const emit = defineEmits<{
+ (e: 'update:open', open: boolean): void
+ (e: 'update:query', query: string): void
+ (e: 'global-search'): void
+}>()
+
+// Hacky type until the library provides real Types
+type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }>
+/** The input field component */
+const searchInput = ref<FocusableComponent>()
+/** When the search bar is opened we focus the input */
+watchEffect(() => {
+ if (props.open && searchInput.value) {
+ searchInput.value.focus()
+ }
+})
+
+/** Current window size is below the "mobile" breakpoint (currently 1024px) */
+const isMobile = useIsMobile()
+
+const searchGlobalButton = ref<ComponentPublicInstance>()
+/** Width of the search global button, used to resize the input field */
+const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton)
+const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)')
+
+/**
+ * Clear the search query and close the search bar
+ */
+function clearAndCloseSearch() {
+ emit('update:query', '')
+ emit('update:open', false)
+}
+</script>
+
+<style scoped lang="scss">
+.local-unified-search {
+ --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw);
+ box-sizing: border-box;
+ position: relative;
+ height: var(--header-height);
+ width: var(--local-search-width);
+ display: flex;
+ align-items: center;
+ // Ensure it overlays the other entries
+ z-index: 10;
+ // add some padding for the focus visible outline
+ padding-inline: var(--border-width-input-focused);
+ // hide the overflow - needed for the transition
+ overflow: hidden;
+ // Ensure the position is fixed also during "position: absolut" (transition)
+ inset-inline-end: 0;
+
+ #{&} &__global-search {
+ position: absolute;
+ inset-inline-end: var(--default-clickable-area);
+ }
+
+ #{&} &__input {
+ box-sizing: border-box;
+ // override some nextcloud-vue styles
+ margin: 0;
+ width: var(--local-search-width);
+
+ // Fixup the spacing so we can fit in the "search globally" button
+ // this can break at any time the component library changes
+ :deep(input) {
+ // search global width + close button width
+ padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area));
+ }
+ }
+}
+
+.animated-width {
+ transition: width var(--animation-quick) linear;
+}
+
+// Make the position absolute during the transition
+// this is needed to "hide" the button behind it
+.v-leave-active {
+ position: absolute !important;
+}
+
+.v-enter,
+.v-leave-to {
+ &.local-unified-search {
+ // Start with only the overlay button
+ --local-search-width: var(--clickable-area-large);
+ }
+}
+
+@media screen and (max-width: 500px) {
+ .local-unified-search.local-unified-search--open {
+ // 100% but still show the menu toggle on the very right
+ --local-search-width: 100vw;
+ padding-inline: var(--default-grid-baseline);
+ }
+
+ // when open we need to position it absolute to allow overlay the full bar
+ :global(.unified-search-menu:has(.local-unified-search--open)) {
+ position: absolute !important;
+ inset-inline: 0;
+ }
+ // Hide all other entries, especially the user menu as it might leak pixels
+ :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) {
+ display: none;
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
new file mode 100644
index 00000000000..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 e165e784422..5c488f2341e 100644
--- a/core/src/components/UserMenu.js
+++ b/core/src/components/UserMenu.js
@@ -1,37 +1,20 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
-import UserMenu from '../views/UserMenu.vue'
+import AccountMenu from '../views/AccountMenu.vue'
export const setUp = () => {
const mountPoint = document.getElementById('user-menu')
if (mountPoint) {
// eslint-disable-next-line no-new
new Vue({
+ name: 'AccountMenuRoot',
el: mountPoint,
- render: h => h(UserMenu),
+ render: h => h(AccountMenu),
})
}
}
diff --git a/core/src/components/UserMenu/ProfileUserMenuEntry.vue b/core/src/components/UserMenu/ProfileUserMenuEntry.vue
deleted file mode 100644
index 61357f09ac6..00000000000
--- a/core/src/components/UserMenu/ProfileUserMenuEntry.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <li :id="id"
- class="menu-entry">
- <component :is="profileEnabled ? 'a' : 'span'"
- class="menu-entry__wrapper"
- :class="{
- active,
- 'menu-entry__wrapper--link': profileEnabled,
- }"
- :href="profileEnabled ? href : undefined"
- @click.exact="handleClick">
- <span class="menu-entry__content">
- <span class="menu-entry__displayname">{{ displayName }}</span>
- <NcLoadingIcon v-if="loading" :size="18" />
- </span>
- <span v-if="profileEnabled">{{ name }}</span>
- </component>
- </li>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-import { getCurrentUser } from '@nextcloud/auth'
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-
-const { profileEnabled } = loadState('user_status', 'profileEnabled', false)
-
-export default {
- name: 'ProfileUserMenuEntry',
-
- components: {
- NcLoadingIcon,
- },
-
- props: {
- id: {
- type: String,
- required: true,
- },
- name: {
- type: String,
- required: true,
- },
- href: {
- type: String,
- required: true,
- },
- active: {
- type: Boolean,
- required: true,
- },
- },
-
- data() {
- return {
- profileEnabled,
- displayName: getCurrentUser().displayName,
- loading: false,
- }
- },
-
- mounted() {
- subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
- subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
- },
-
- beforeDestroy() {
- unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
- unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
- },
-
- methods: {
- handleClick() {
- if (this.profileEnabled) {
- this.loading = true
- }
- },
-
- handleProfileEnabledUpdate(profileEnabled) {
- this.profileEnabled = profileEnabled
- },
-
- handleDisplayNameUpdate(displayName) {
- this.displayName = displayName
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.menu-entry {
- &__wrapper {
- box-sizing: border-box;
- display: inline-flex;
- flex-direction: column;
- align-items: flex-start !important;
- padding: 10px 12px 5px 12px !important;
- height: var(--header-menu-item-height);
- color: var(--color-text-maxcontrast);
-
- &--link {
- height: calc(var(--header-menu-item-height) * 1.5) !important;
- color: var(--color-main-text);
- }
- }
-
- &__content {
- display: inline-flex;
- gap: 0 10px;
- }
-
- &__displayname {
- font-weight: bold;
- }
-}
-</style>
diff --git a/core/src/components/UserMenu/UserMenuEntry.vue b/core/src/components/UserMenu/UserMenuEntry.vue
deleted file mode 100644
index 8f09137256c..00000000000
--- a/core/src/components/UserMenu/UserMenuEntry.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <li :id="id"
- class="menu-entry">
- <a v-if="href"
- :href="href"
- :class="{ active }"
- @click.exact="handleClick">
- <NcLoadingIcon v-if="loading"
- class="menu-entry__loading-icon"
- :size="18" />
- <img v-else :src="cachedIcon" alt="">
- {{ name }}
- </a>
- <button v-else>
- <img :src="cachedIcon" alt="">
- {{ name }}
- </button>
- </li>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-
-const versionHash = loadState('core', 'versionHash', '')
-
-export default {
- name: 'UserMenuEntry',
-
- components: {
- NcLoadingIcon,
- },
-
- props: {
- id: {
- type: String,
- required: true,
- },
- name: {
- type: String,
- required: true,
- },
- href: {
- type: String,
- required: true,
- },
- active: {
- type: Boolean,
- required: true,
- },
- icon: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- loading: false,
- }
- },
-
- computed: {
- cachedIcon() {
- return `${this.icon}?v=${versionHash}`
- },
- },
-
- methods: {
- handleClick() {
- this.loading = true
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.menu-entry {
- &__loading-icon {
- margin-right: 8px;
- }
-}
-</style>
diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue
index 54c29245469..da387df0ff6 100644
--- a/core/src/components/login/LoginButton.vue
+++ b/core/src/components/login/LoginButton.vue
@@ -1,28 +1,13 @@
<!--
- - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcButton type="primary"
native-type="submit"
:wide="true"
+ :disabled="loading"
@click="$emit('click')">
{{ !loading ? value : valueLoading }}
<template #icon>
@@ -33,7 +18,9 @@
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
export default {
diff --git a/core/src/components/login/LoginForm.cy.ts b/core/src/components/login/LoginForm.cy.ts
new file mode 100644
index 00000000000..1b1aeda6306
--- /dev/null
+++ b/core/src/components/login/LoginForm.cy.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import LoginForm from './LoginForm.vue'
+
+describe('core: LoginForm', { testIsolation: true }, () => {
+ beforeEach(() => {
+ // Mock the required global state
+ cy.window().then(($window) => {
+ $window.OC = {
+ theme: {
+ name: 'J\'s cloud',
+ },
+ requestToken: 'request-token',
+ }
+ })
+ })
+
+ /**
+ * Ensure that characters like ' are not double HTML escaped.
+ * This was a bug in https://github.com/nextcloud/server/issues/34990
+ */
+ it('does not double escape special characters in product name', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('h2').contains('J\'s cloud')
+ })
+
+ it('fills username from props into form', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('input[name="user"]')
+ .should('exist')
+ .and('have.attr', 'id', 'user')
+
+ cy.get('input[name="user"]')
+ .should('have.value', 'test-user')
+ })
+
+ it('clears password after timeout', () => {
+ // mock timeout of 5 seconds
+ cy.window().then(($window) => {
+ const state = $window.document.createElement('input')
+ state.type = 'hidden'
+ state.id = 'initial-state-core-loginTimeout'
+ state.value = btoa(JSON.stringify(5))
+ $window.document.body.appendChild(state)
+ })
+
+ // mount forms
+ cy.mount(LoginForm)
+
+ cy.get('input[name="password"]')
+ .should('exist')
+ .type('MyPassword')
+
+ cy.get('input[name="password"]')
+ .should('have.value', 'MyPassword')
+
+ // Wait for timeout
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(5100)
+
+ cy.get('input[name="password"]')
+ .should('have.value', '')
+ })
+})
diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue
index 9a8689dc9cc..8cbe55f1f68 100644
--- a/core/src/components/login/LoginForm.vue
+++ b/core/src/components/login/LoginForm.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form ref="loginForm"
@@ -33,9 +17,9 @@
{{ t('core', 'Please contact your administrator.') }}
</NcNoteCard>
<NcNoteCard v-if="csrfCheckFailed"
- :heading="t('core', 'Temporary error')"
+ :heading="t('core', 'Session error')"
type="error">
- {{ t('core', 'Please try again.') }}
+ {{ t('core', 'It appears your session token has expired, please refresh the page and try again.') }}
</NcNoteCard>
<NcNoteCard v-if="messages.length > 0">
<div v-for="(message, index) in messages"
@@ -57,17 +41,22 @@
<!-- the following div ensures that the spinner is always inside the #message div -->
<div style="clear: both;" />
</div>
- <h2 class="login-form__headline" data-login-form-headline v-html="headline" />
+ <h2 class="login-form__headline" data-login-form-headline>
+ {{ headlineText }}
+ </h2>
<NcTextField id="user"
ref="user"
- :label="t('core', 'Account name or email')"
+ :label="loginText"
name="user"
+ :maxlength="255"
:value.sync="user"
:class="{shake: invalidPassword}"
autocapitalize="none"
:spellchecking="false"
:autocomplete="autoCompleteAllowed ? 'username' : 'off'"
required
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
data-login-form-input-user
@change="updateUsername" />
@@ -99,7 +88,7 @@
:value="timezoneOffset">
<input type="hidden"
name="requesttoken"
- :value="OC.requestToken">
+ :value="requestToken">
<input v-if="directLogin"
type="hidden"
name="direct"
@@ -109,12 +98,16 @@
</template>
<script>
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router'
+import debounce from 'debounce'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import AuthMixin from '../../mixins/auth.js'
import LoginButton from './LoginButton.vue'
export default {
@@ -127,6 +120,8 @@ export default {
NcNoteCard,
},
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -156,30 +151,61 @@ export default {
type: Boolean,
default: false,
},
+ emailStates: {
+ type: Array,
+ default() {
+ return []
+ },
+ },
},
- data() {
+ setup() {
+ // non reactive props
return {
- loading: false,
+ t,
+
+ // Disable escape and sanitize to prevent special characters to be html escaped
+ // For example "J's cloud" would be escaped to "J&#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.')
@@ -207,6 +233,24 @@ export default {
loginActionUrl() {
return generateUrl('login')
},
+ emailEnabled() {
+ return this.emailStates ? this.emailStates.every((state) => state === '1') : 1
+ },
+ loginText() {
+ if (this.emailEnabled) {
+ return t('core', 'Account name or email')
+ }
+ return t('core', 'Account name')
+ },
+ },
+
+ watch: {
+ /**
+ * Reset form reset after the password was changed
+ */
+ password() {
+ this.resetFormTimeout()
+ },
},
mounted() {
@@ -219,10 +263,24 @@ export default {
},
methods: {
+ /**
+ * Handle reset of the login form after a long IDLE time
+ * This is recommended security behavior to prevent password leak on public devices
+ */
+ handleResetForm() {
+ this.password = ''
+ },
+
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+ submit(event) {
+ if (this.loading) {
+ // Prevent the form from being submitted twice
+ event.preventDefault()
+ return
+ }
+
this.loading = true
this.$emit('submit')
},
@@ -232,8 +290,9 @@ export default {
<style lang="scss" scoped>
.login-form {
- text-align: left;
+ text-align: start;
font-size: 1rem;
+ margin: 0;
&__fieldset {
width: 100%;
@@ -246,5 +305,10 @@ export default {
text-align: center;
overflow-wrap: anywhere;
}
+
+ // Only show the error state if the user interacted with the login box
+ :deep(input:invalid:not(:user-invalid)) {
+ border-color: var(--color-border-maxcontrast) !important;
+ }
}
</style>
diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue
index dbc4fdae695..bc4d25bf70f 100644
--- a/core/src/components/login/PasswordLessLoginForm.vue
+++ b/core/src/components/login/PasswordLessLoginForm.vue
@@ -1,61 +1,74 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form v-if="(isHttps || isLocalhost) && hasPublicKeyCredential"
+ <form v-if="(isHttps || isLocalhost) && supportsWebauthn"
ref="loginForm"
+ aria-labelledby="password-less-login-form-title"
+ class="password-less-login-form"
method="post"
name="login"
@submit.prevent="submit">
- <h2>{{ t('core', 'Log in with a device') }}</h2>
- <fieldset>
- <NcTextField required
- :value="user"
- :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
- :error="!validCredentials"
- :label="t('core', 'Username or email')"
- :placeholder="t('core', 'Username or email')"
- :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
- @update:value="changeUsername" />
-
- <LoginButton v-if="validCredentials"
- :loading="loading"
- @click="authenticate" />
- </fieldset>
+ <h2 id="password-less-login-form-title">
+ {{ t('core', 'Log in with a device') }}
+ </h2>
+
+ <NcTextField required
+ :value="user"
+ :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
+ :error="!validCredentials"
+ :label="t('core', 'Login or email')"
+ :placeholder="t('core', 'Login or email')"
+ :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
+ @update:value="changeUsername" />
+
+ <LoginButton v-if="validCredentials"
+ :loading="loading"
+ @click="authenticate" />
</form>
- <div v-else-if="!hasPublicKeyCredential" class="update">
- <InformationIcon size="70" />
- <h2>{{ t('core', 'Browser not supported') }}</h2>
- <p class="infogroup">
- {{ t('core', 'Passwordless authentication is not supported in your browser.') }}
- </p>
- </div>
- <div v-else-if="!isHttps && !isLocalhost" class="update">
- <LockOpenIcon size="70" />
- <h2>{{ t('core', 'Your connection is not secure') }}</h2>
- <p class="infogroup">
- {{ t('core', 'Passwordless authentication is only available over a secure connection.') }}
- </p>
- </div>
+
+ <NcEmptyContent v-else-if="!isHttps && !isLocalhost"
+ :name="t('core', 'Your connection is not secure')"
+ :description="t('core', 'Passwordless authentication is only available over a secure connection.')">
+ <template #icon>
+ <LockOpenIcon />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else
+ :name="t('core', 'Browser not supported')"
+ :description="t('core', 'Passwordless authentication is not supported in your browser.')">
+ <template #icon>
+ <InformationIcon />
+ </template>
+ </NcEmptyContent>
</template>
-<script>
+<script type="ts">
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
+import { defineComponent } from 'vue'
import {
+ NoValidCredentials,
startAuthentication,
finishAuthentication,
-} from '../../services/WebAuthnAuthenticationService.js'
-import LoginButton from './LoginButton.vue'
-import InformationIcon from 'vue-material-design-icons/Information.vue'
-import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+} from '../../services/WebAuthnAuthenticationService.ts'
-class NoValidCredentials extends Error {
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-}
+import InformationIcon from 'vue-material-design-icons/InformationOutline.vue'
+import LoginButton from './LoginButton.vue'
+import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
+import logger from '../../logger'
-export default {
+export default defineComponent({
name: 'PasswordLessLoginForm',
components: {
LoginButton,
InformationIcon,
LockOpenIcon,
+ NcEmptyContent,
NcTextField,
},
props: {
@@ -79,11 +92,14 @@ export default {
type: Boolean,
default: false,
},
- hasPublicKeyCredential: {
- type: Boolean,
- default: false,
- },
},
+
+ setup() {
+ return {
+ supportsWebauthn: browserSupportsWebAuthn(),
+ }
+ },
+
data() {
return {
user: this.username,
@@ -92,7 +108,7 @@ export default {
}
},
methods: {
- authenticate() {
+ async authenticate() {
// check required fields
if (!this.$refs.loginForm.checkValidity()) {
return
@@ -100,112 +116,25 @@ export default {
console.debug('passwordless login initiated')
- this.getAuthenticationData(this.user)
- .then(publicKey => {
- console.debug(publicKey)
- return publicKey
- })
- .then(this.sign)
- .then(this.completeAuthentication)
- .catch(error => {
- if (error instanceof NoValidCredentials) {
- this.validCredentials = false
- return
- }
- console.debug(error)
- })
+ try {
+ const params = await startAuthentication(this.user)
+ await this.completeAuthentication(params)
+ } catch (error) {
+ if (error instanceof NoValidCredentials) {
+ this.validCredentials = false
+ return
+ }
+ logger.debug(error)
+ }
},
changeUsername(username) {
this.user = username
this.$emit('update:username', this.user)
},
- getAuthenticationData(uid) {
- const base64urlDecode = function(input) {
- // Replace non-url compatible chars with base64 standard chars
- input = input
- .replace(/-/g, '+')
- .replace(/_/g, '/')
-
- // Pad out with standard base64 required padding characters
- const pad = input.length % 4
- if (pad) {
- if (pad === 1) {
- throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
- }
- input += new Array(5 - pad).join('=')
- }
-
- return window.atob(input)
- }
-
- return startAuthentication(uid)
- .then(publicKey => {
- console.debug('Obtained PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
-
- if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) {
- console.debug('No credentials found.')
- throw new NoValidCredentials()
- }
-
- publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
- publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) {
- return {
- ...data,
- id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)),
- }
- })
-
- console.debug('Converted PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
- return publicKey
- })
- .catch(error => {
- console.debug('Error while obtaining data')
- throw error
- })
- },
- sign(publicKey) {
- const arrayToBase64String = function(a) {
- return window.btoa(String.fromCharCode(...a))
- }
-
- const arrayToString = function(a) {
- return String.fromCharCode(...a)
- }
-
- return navigator.credentials.get({ publicKey })
- .then(data => {
- console.debug(data)
- console.debug(new Uint8Array(data.rawId))
- console.debug(arrayToBase64String(new Uint8Array(data.rawId)))
- return {
- id: data.id,
- type: data.type,
- rawId: arrayToBase64String(new Uint8Array(data.rawId)),
- response: {
- authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
- clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
- signature: arrayToBase64String(new Uint8Array(data.response.signature)),
- userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null,
- },
- }
- })
- .then(challenge => {
- console.debug(challenge)
- return challenge
- })
- .catch(error => {
- console.debug('GOT AN ERROR!')
- console.debug(error) // Example: timeout, interaction refused...
- })
- },
completeAuthentication(challenge) {
- console.debug('TIME TO COMPLETE')
-
const redirectUrl = this.redirectUrl
- return finishAuthentication(JSON.stringify(challenge))
+ return finishAuthentication(challenge)
.then(({ defaultRedirectUrl }) => {
console.debug('Logged in redirecting')
// Redirect url might be false so || should be used instead of ??.
@@ -220,21 +149,14 @@ export default {
// noop
},
},
-}
+})
</script>
<style lang="scss" scoped>
- fieldset {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-
- :deep(label) {
- text-align: initial;
- }
- }
-
- .update {
- margin: 0 auto;
- }
+.password-less-login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0;
+}
</style>
diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue
index e1d66daa4aa..fee1deacc36 100644
--- a/core/src/components/login/ResetPassword.vue
+++ b/core/src/components/login/ResetPassword.vue
@@ -1,72 +1,68 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form class="login-form" @submit.prevent="submit">
- <fieldset class="login-form__fieldset">
- <NcTextField id="user"
- :value.sync="user"
- name="user"
- autocapitalize="off"
- :label="t('core', 'Account name or email')"
- required
- @change="updateUsername" />
- <LoginButton :value="t('core', 'Reset password')" />
-
- <NcNoteCard v-if="message === 'send-success'"
- type="success">
- {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or account name, check your spam/junk folders or ask your local administration for help.') }}
- </NcNoteCard>
- <NcNoteCard v-else-if="message === 'send-error'"
- type="error">
- {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
- </NcNoteCard>
- <NcNoteCard v-else-if="message === 'reset-error'"
- type="error">
- {{ t('core', 'Password cannot be changed. Please contact your administrator.') }}
- </NcNoteCard>
-
- <a class="login-form__link"
- href="#"
- @click.prevent="$emit('abort')">
- {{ t('core', 'Back to login') }}
- </a>
- </fieldset>
+ <form class="reset-password-form" @submit.prevent="submit">
+ <h2>{{ t('core', 'Reset password') }}</h2>
+
+ <NcTextField id="user"
+ :value.sync="user"
+ name="user"
+ :maxlength="255"
+ autocapitalize="off"
+ :label="t('core', 'Login or email')"
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
+ required
+ @change="updateUsername" />
+
+ <LoginButton :loading="loading" :value="t('core', 'Reset password')" />
+
+ <NcButton type="tertiary" wide @click="$emit('abort')">
+ {{ t('core', 'Back to login') }}
+ </NcButton>
+
+ <NcNoteCard v-if="message === 'send-success'"
+ type="success">
+ {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or Login, check your spam/junk folders or ask your local administration for help.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'send-error'"
+ type="error">
+ {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'reset-error'"
+ type="error">
+ {{ t('core', 'Password cannot be changed. Please contact your administrator.') }}
+ </NcNoteCard>
</form>
</template>
-<script>
-import axios from '@nextcloud/axios'
+<script lang="ts">
import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+import AuthMixin from '../../mixins/auth.js'
import LoginButton from './LoginButton.vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import logger from '../../logger.js'
-export default {
+export default defineComponent({
name: 'ResetPassword',
components: {
LoginButton,
+ NcButton,
NcNoteCard,
NcTextField,
},
+
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -77,11 +73,12 @@ export default {
required: true,
},
},
+
data() {
return {
error: false,
loading: false,
- message: undefined,
+ message: '',
user: this.username,
}
},
@@ -94,57 +91,38 @@ export default {
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+
+ async submit() {
this.loading = true
this.error = false
this.message = ''
const url = generateUrl('/lostpassword/email')
- const data = {
- user: this.user,
- }
+ try {
+ const { data } = await axios.post(url, { user: this.user })
+ if (data.status !== 'success') {
+ throw new Error(`got status ${data.status}`)
+ }
+
+ this.message = 'send-success'
+ } catch (error) {
+ logger.error('could not send reset email request', { error })
- return axios.post(url, data)
- .then(resp => resp.data)
- .then(data => {
- if (data.status !== 'success') {
- throw new Error(`got status ${data.status}`)
- }
-
- this.message = 'send-success'
- })
- .catch(e => {
- console.error('could not send reset email request', e)
-
- this.error = true
- this.message = 'send-error'
- })
- .then(() => { this.loading = false })
+ this.error = true
+ this.message = 'send-error'
+ } finally {
+ this.loading = false
+ }
},
},
-}
+})
</script>
<style lang="scss" scoped>
-.login-form {
- text-align: left;
- font-size: 1rem;
-
- &__fieldset {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: .5rem;
- }
-
- &__link {
- display: block;
- font-weight: normal !important;
- padding-bottom: 1rem;
- cursor: pointer;
- font-size: var(--default-font-size);
- text-align: center;
- padding: .5rem 1rem 1rem 1rem;
- }
+.reset-password-form {
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+ width: 100%;
}
</style>
diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue
index e281ad7ffc7..b7b9ecccd0a 100644
--- a/core/src/components/login/UpdatePassword.vue
+++ b/core/src/components/login/UpdatePassword.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form @submit.prevent="submit">
diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
index 472c011c762..f2120c28402 100644
--- a/core/src/components/setup/RecommendedApps.vue
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -1,26 +1,10 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div class="guest-box">
+ <div class="guest-box" data-cy-setup-recommended-apps>
<h2>{{ t('core', 'Recommended apps') }}</h2>
<p v-if="loadingApps" class="loading text-center">
{{ t('core', 'Loading apps …') }}
@@ -28,20 +12,13 @@
<p v-else-if="loadingAppsError" class="loading-error text-center">
{{ t('core', 'Could not fetch list of apps from the App Store.') }}
</p>
- <p v-else-if="installingApps" class="text-center">
- {{ t('core', 'Installing apps …') }}
- </p>
<div v-for="app in recommendedApps" :key="app.id" class="app">
<template v-if="!isHidden(app.id)">
<img :src="customIcon(app.id)" alt="">
<div class="info">
- <h3>
- {{ customName(app) }}
- <span v-if="app.loading" class="icon icon-loading-small-dark" />
- <span v-else-if="app.active" class="icon icon-checkmark-white" />
- </h3>
- <p v-html="customDescription(app.id)" />
+ <h3>{{ customName(app) }}</h3>
+ <p v-text="customDescription(app.id)" />
<p v-if="app.installationError">
<strong>{{ t('core', 'App download or installation failed') }}</strong>
</p>
@@ -52,37 +29,43 @@
<strong>{{ t('core', 'Cannot install this app') }}</strong>
</p>
</div>
+ <NcCheckboxRadioSwitch :checked="app.isSelected || app.active"
+ :disabled="!app.isCompatible || app.active"
+ :loading="app.loading"
+ @update:checked="toggleSelect(app.id)" />
</template>
</div>
<div class="dialog-row">
- <NcButton v-if="showInstallButton"
- type="tertiary"
- role="link"
- :href="defaultPageUrl">
+ <NcButton v-if="showInstallButton && !installingApps"
+ data-cy-setup-recommended-apps-skip
+ :href="defaultPageUrl"
+ variant="tertiary">
{{ t('core', 'Skip') }}
</NcButton>
<NcButton v-if="showInstallButton"
- type="primary"
+ data-cy-setup-recommended-apps-install
+ :disabled="installingApps || !isAnyAppSelected"
+ variant="primary"
@click.stop.prevent="installApps">
- {{ t('core', 'Install recommended apps') }}
+ {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }}
</NcButton>
</div>
</div>
</template>
<script>
-import axios from '@nextcloud/axios'
-import { generateUrl, imagePath } from '@nextcloud/router'
+import { t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl, imagePath } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
import pLimit from 'p-limit'
-import { translate as t } from '@nextcloud/l10n'
-
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-
import logger from '../../logger.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
const recommended = {
calendar: {
description: t('core', 'Schedule work & meetings, synced with all your devices.'),
@@ -97,7 +80,7 @@ const recommended = {
icon: imagePath('core', 'actions/mail.svg'),
},
spreed: {
- description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'),
+ description: t('core', 'Chatting, video calls, screen sharing, online meetings and web conferencing – in your browser and with mobile apps.'),
icon: imagePath('core', 'apps/spreed.svg'),
},
richdocuments: {
@@ -118,6 +101,7 @@ const recommendedIds = Object.keys(recommended)
export default {
name: 'RecommendedApps',
components: {
+ NcCheckboxRadioSwitch,
NcButton,
},
data() {
@@ -127,20 +111,23 @@ export default {
loadingApps: true,
loadingAppsError: false,
apps: [],
- defaultPageUrl: loadState('core', 'defaultPageUrl')
+ defaultPageUrl: loadState('core', 'defaultPageUrl'),
}
},
computed: {
recommendedApps() {
return this.apps.filter(app => recommendedIds.includes(app.id))
},
+ isAnyAppSelected() {
+ return this.recommendedApps.some(app => app.isSelected)
+ },
},
async mounted() {
try {
const { data } = await axios.get(generateUrl('settings/apps/list'))
logger.info(`${data.apps.length} apps fetched`)
- this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false }))
+ this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible }))
logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps })
this.showInstallButton = true
@@ -154,23 +141,24 @@ export default {
},
methods: {
installApps() {
- this.showInstallButton = false
this.installingApps = true
const limit = pLimit(1)
const installing = this.recommendedApps
- .filter(app => !app.active && app.isCompatible && app.canInstall)
- .map(app => limit(() => {
+ .filter(app => !app.active && app.isCompatible && app.canInstall && app.isSelected)
+ .map(app => limit(async () => {
logger.info(`installing ${app.id}`)
app.loading = true
return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] })
.catch(error => {
logger.error(`could not install ${app.id}`, { error })
+ app.isSelected = false
app.installationError = true
})
.then(() => {
logger.info(`installed ${app.id}`)
app.loading = false
+ app.active = true
})
}))
logger.debug(`installing ${installing.length} recommended apps`)
@@ -208,6 +196,14 @@ export default {
}
return !!recommended[appId].hidden
},
+ toggleSelect(appId) {
+ // disable toggle when installButton is disabled
+ if (!(appId in recommended) || !this.showInstallButton) {
+ return
+ }
+ const index = this.apps.findIndex(app => app.id === appId)
+ this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected)
+ },
},
}
</script>
@@ -250,16 +246,17 @@ p {
.info {
h3, p {
- text-align: left;
+ text-align: start;
}
h3 {
margin-top: 0;
}
+ }
- h3 > span.icon {
- display: inline-block;
- }
+ .checkbox-radio-switch {
+ margin-inline-start: auto;
+ padding: 0 2px;
}
}
</style>